Our first object store named files by random UUID, kept metadata in a database, and treated deduplication as a cleanup job bolted on afterward. That design held up until it didn’t, and the failure taught us the most important lesson in the storage core: the name of an object should be derived from its content, not assigned arbitrarily.
The Real Problem
A customer uploaded the same 50 GB dataset three times under different names. We stored 150 GB. The nightly dedup job took six hours and still missed duplicates that landed on different nodes. Worse, when a bit flipped on disk and silently corrupted a file, we had no way to detect it without a separate checksum index that could itself drift out of sync.
We asked: what if the name was the checksum?
That idea is content-addressable storage (CAS): instead of inventing an identifier, hash the object’s bytes and use the hash as its address. SHA-256 is the hash momo uses. Two identical byte streams necessarily produce the same hash, so identical uploads collide on purpose and deduplicate for free.
Why Obvious Solutions Failed
Application-level dedup (separate index)
| |
Content hash as metadata only
| |
File-system level dedup (ZFS/Btrfs)
| |
Each of these keeps the identifier and the integrity check as two separate things that can disagree. Content addressing fuses them.
The Solution — Content Addressing
Pivotal decision: store objects by their SHA-256 content hash. Names become metadata pointing at a blob address.
| |
Key insight: the hash is the key. Two identical uploads yield the same hash, therefore the same blob ID, therefore the same stored bytes. Dedup happens by construction — no background job, no window where duplicates coexist.
Principle Callout
Pattern: Content-Addressable Write Chokepoint Hash on ingest → hash is the key → validate → write. Every write path (native PUT, S3 gateway, replication, FUSE mount) funnels through ONE function.
Applies when: you need dedup, integrity, and a single trust boundary. Doesn’t apply: mutable objects, append-only logs, or any case where the content is not immutable.
| |
⚡ Bolt + 🛡 Sentinel Lens
Bolt: Zero-Escape SHA-256 Hashing
| |
h.Sum normally allocates a fresh slice for the digest. Passing a stack-array
slice with length zero lets the hash write into memory that never escapes to the
heap — zero bytes allocated per object. See
024 for the full zero-escape pattern.
Sentinel: Path Traversal & Injection Prevention
The chokepoint is the natural place for security checks, because every write passes through it:
| |
Because the backend is handed a validated hash and never a user-supplied name,
there is no filename for an attacker to traverse with ../ or to smuggle CRLF
into. See 015 for the CRLF injection, request
smuggling, and raw-store traversal findings that hardened this path.
Verification
| Property | Test | Result |
|---|---|---|
| Dedup correctness | Upload same content 100x → 1 blob | ✅ |
| Integrity | Flip 1 bit on disk → read fails | ✅ |
| Write throughput | 50k obj/s, 2.4 KB/alloc → 0 B/alloc | ✅ |
| Cross-backend | local, nfs, s3, raw all dedup | ✅ |
Failure Modes
| Risk | Mitigation |
|---|---|
| Hash collision (SHA-256) | 2^256 space — practically impossible; monitor for research breaks |
| Backend corruption | Verify-on-read hashes every block on GET |
| Metadata/Blob drift | GC reconciles metadata with blob files (007) |
When NOT to Use Content Addressing
- Mutable objects — a content change means a new hash and a new blob; use versioning instead.
- Append-only logs — these are written sequentially, not looked up by content.
- When content isn’t immutable — e.g. user-editable documents that change in place.
See docs/CORE/STANDARDS.md for the ⚡ Bolt / 🛡 Sentinel mindsets.