chum
← journal

Deletion done right: tombstones, a write journal, and a guarded purge

SP6 · 2026-06-10

“The record is the proof” is a claim about persistence: an object’s address is the hash of its bytes, and the name that points to it is a signed, append-only chain. SP6 is where that claim met its hardest question — how do you delete? In a content-addressed, de-duplicated, append-only substrate, deletion has no obvious meaning. Two names can resolve to the same CID. A name’s whole history references the CID it once pointed at. Erasing bytes would break the proof; never erasing them would make deletion a lie. SP6 separates the two things “delete” usually means and gives each an honest mechanism.

Why reference-counted GC does not apply

The instinct is reference counting: track how many names point at a CID, delete the bytes when the count hits zero. ADR-0006 (chum/docs/decisions/0006-deletion-reclamation.md, Accepted 2026-06-10) records why that is the wrong model here. Under the history-preserving liveness rule, a CID is live as long as any record in any name’s immutable chain references it. A deletion is itself an appended record, and that record — like every record before it — still names the CID. So a delete decrements nothing to zero. The “reference count” survives only as a boolean: is this CID named by any record at all?

That reframe is deliberate and the spec (docs/superpowers/specs/2026-06-10-sp6-deletion-and-reclamation-design.md, §1.1) insists on stating it plainly rather than shipping a “ref-counted GC” headline that isn’t true. The only bytes that are ever physically reclaimable are write-orphans: bytes committed to the substrate whose signed pointer-append never landed, because the process crashed in the gap between the two.

Delete is a signed tombstone

Service.Delete in chum/internal/object/service.go does not touch the substrate at all. It resolves the current head, then appends a new PointerRecord with Tombstone: true, Prev set to the old head’s RecordCID, and — crucially — the same CID the head pointed at:

rec := PointerRecord{
    Bucket: bucket, Key: key, CID: head.CID, Prev: head.RecordCID, DID: s.signer.DID(),
    ContentType: head.ContentType, Size: head.Size, Visibility: head.Visibility,
    Tombstone: true, Timestamp: clock(),
}

That record is signed and CID-addressed like any other link in the chain. Resolve and List already exclude a tombstoned head, so the name stops resolving. But the tombstone still references the bytes, which is what keeps history-preservation intact: GetByCID of the prior CID keeps working after a delete. The name is gone; the proof of what the name once held — and that it was deliberately retired, when, and by whom — is permanent. Deletion removes the name, not the proof.

The two-phase write that makes reclamation airtight

The hard part is the orphan reaper, and it depends on a substrate seam change. Before SP6 the substrate had a single Put. SP6 splits it into Stage (hash and write to a pending area, returning the CID before the bytes are addressable) and Commit (atomically promote). Service.Put now records the CID in a write-intent journal between the two phases:

op  = journal.Begin
cid = sub.Stage(r)        // pending, hashed, NOT addressable
      journal.Record(op, cid)
      sub.Commit(tok)     // bytes become addressable
      ptr.Append(rec)     // the name now references cid
      journal.Commit(op)  // op closed

chum/internal/journal/sqlite/journal.go is the journal adapter — a write_journal table on the pure-Go modernc.org/sqlite driver, in its own database (CHUM_JOURNAL_DSN) so the GC concern stays uncoupled from the pointer store. The trick is that a clean write deletes its own row via Commit. So any row still present is, by construction, a write that did not finish. Reap returns exactly those rows older than a grace cutoff — no full-substrate scan, ever. The journal’s Commit is best-effort in Put: a failed row cleanup must not fail a durably-committed, referenced write, because the reaper will later find the lingering row, see the CID is referenced, and just drop it.

ReapOrphans walks those stale entries and, for each CID, asks ptr.IsReferenced. The atproto pointer store answers this through an indexed blob_cid column added in chum/internal/pointer/atproto/atproto.goSELECT 1 FROM blocks WHERE blob_cid=? LIMIT 1. Referenced CIDs are kept and their op dropped; unreferenced ones get sub.Delete. The crash matrix collapses to a single safe rule: a CID the chain references is never deleted; a committed-but-unreferenced CID is.

The crash model is pinned by chum/internal/object/reclaim_test.go — six cases: orphan deleted, referenced-and-kept, grace respected, dry-run mutates nothing, a zero-CID row dropped-without-deleting, and ForcePurge then reading back as ErrPurged.

The purge carve-out, kept honest

Compliance — legal takedown, right-to-be-forgotten — genuinely needs to remove referenced bytes. SP6 grants that, but refuses to make it silent. Service.ForcePurge signs a blue.chum.purge.record (the canonical bytes come from UnsignedPurgeBytes in chum/internal/object/record.go), appends it to a purge log, then deletes the bytes despite chain references. Afterward readBytes consults purge.IsPurged first and returns the ErrPurged sentinel — distinct from ErrNotFound — so a deliberately removed byte never reads as a mere miss. The records that named the CID stay in the chain, now intentionally dangling; ErrPurged is what makes that state legible. Even the destruction of bytes leaves a signed record. Purge is operator-only (chumd purge <cid>), never a network endpoint.

What this enables, and what is still open

Deletion in Chum is now three honest things instead of one dishonest one: a name retired by a signed tombstone, crash-orphaned bytes swept by a reaper that never scans, and a compliance purge that is itself a provable, signed event. None of them weaken “the record is the proof” — the tombstone and the purge record are both links in the same verifiable chain.

What’s deferred is named in ADR-0006 and the spec’s §7. The background reaper ships default-off (CHUM_GC_INTERVAL=0); its observability and graceful-shutdown hardening are SP-13’s job before it runs in production. A delegated or network-authorized purge endpoint is a later compliance sprint; today purge is a CLI only. And the atproto blob_cid column is not backfilled for pre-SP6 rows — safe under the journal-gated reaper, but a prerequisite if a future full-scan reaper ever replaces it. SP6 delivers the mechanism and the crash-correctness proof; the operational hardening is honestly still ahead.

Get early access →