chum
← journal

Building the AT Protocol pointer store

SP1 · 2026-06-07

Chum’s “the record is the proof” claim has two halves. The substrate half is about bytes: an object’s address is the hash of its bytes, verifiable by anyone with the content. The pointer half is about names: a bucket + key resolves to a CID, and that resolution should be as verifiable as the bytes it points to. SP1 is where the second half got real.

What the scaffold was doing wrong

Before SP1, the pointer chain signed a bespoke length-prefixed preimage (recordSigningBytes in internal/object/service.go) and addressed it with a CIDv1 raw-codec CID — the same codec as the object substrate. Raw codec is correct for opaque bytes. It is not the form AT Protocol tooling computes for a record. That means the RecordCID produced by the old scaffold would never match what atproto tooling would independently derive. The “independently verifiable” property — the one that makes “record is the proof” mean something beyond Chum’s own say-so — was absent.

ADR-0004 (chum/docs/decisions/0004-pointer-storage.md, Accepted 2026-06-07) named the gap and the fix: replace the bespoke scheme with canonical DAG-CBOR records using a dag-cbor RecordCID (CIDv1 / codec 0x71 / sha-256), and sign those same bytes with a P-256 did:key. The public key is encoded in the DID string itself, so verification is fully offline — no network, no DID-document fetch.

The canonical record shape

The pointer record is defined in chum/api/lexicons/blue/chum/pointer/record.json as blue.chum.pointer.record. The lexicon’s required fields match the DAG-CBOR encoding exactly: bucket, key, cid (a cid-link — a tag-42 DAG-CBOR link to the object’s substrate CID), did, contentType, size, visibility, tombstone, createdAt, and optionally prev (also a cid-link, to the previous record’s RecordCID).

internal/object/record.go implements UnsignedBytes(PointerRecord) ([]byte, error) — the canonical preimage. The encoding uses DAG-CBOR length-first key sort (the DAG-CBOR profile, not plain CBOR), emitted via github.com/fxamacker/cbor/v2 with Sort: cbor.SortLengthFirst. The prev field is absent — not null, absent — when a record is the chain head. CID fields are tag-42 links with a 0x00 multibase-identity prefix byte, per the IPLD spec.

The RecordCID is cid.ComputeRecordCID(block) — CIDv1 / dag-cbor / sha-256 — over those exact bytes. Identity, integrity, and authenticity all commit to one byte string. The same UnsignedBytes call is the signature preimage and the CID preimage: they cannot drift.

DecodeUnsigned([]byte) (PointerRecord, error) inverts the encoding, with a round-trip test: encode → decode → re-encode must reproduce byte-identical output. The test in internal/object/record_test.go asserts this directly, and asserts that RecordCID and Sig are not present in the canonical bytes (they are storage-only fields reattached after decode).

The did:key adapter

internal/sign/didkey/didkey.go is the P-256 did:key Signer/Verifier. DID() encodes the compressed P-256 public key (33 bytes) with the 0x80 0x24 multicodec prefix in base58btc multibase, producing did:key:zDna…. The zDna prefix is structurally guaranteed: base58btc of [0x80, 0x24, <33-byte compressed P-256 key>] always starts that way.

Signatures are 64-byte compact r||s with low-S normalization. Low-S is the atproto convention and prevents signature malleability: for any valid (r, s) pair, the high-S form (r, n - s) is also valid under naive ECDSA, but only the low-S form is accepted here. Verify rejects high-S signatures explicitly before calling ecdsa.Verify.

The Verifier type is stateless: it derives the public key from the did string passed to Verify(did, msg, sig). The seam in internal/object/types.go was widened to carry this DID: Verifier.Verify(did string, msg, sig []byte) bool and Signer.DID() string. The dev ed25519 verifier ignores did, which preserves existing tests — the seam is a superset, not a replacement.

Compile-time seam assertions (var _ object.Signer = (*Signer)(nil)) ensure neither the didkey nor any future adapter can drift from the interface silently.

The blockstore

internal/pointer/atproto/atproto.go is the PointerStore implementation. The schema is two SQLite tables: blocks (keyed by record_cid, storing the canonical DAG-CBOR block and detached sig) and heads (a (bucket, key) → record_cid index with a tombstone flag). This is a repo blockstore without the Merkle tree — the blocks and CIDs are identical to what a future MST/commit layer would compute, so adding the tree later does not require re-encoding.

Append verifies content-addressing before writing: it re-derives cid.ComputeRecordCID(block) from the canonical bytes and rejects the record if it does not match r.RecordCID. Content-addressed storage is not just a naming convention here — the store enforces it on every write.

Resolve returns object.ErrNotFound for both absent keys and tombstoned heads. History walks the prev chain from head to genesis, reversing to oldest-first before returning. List queries heads by prefix, excluding tombstoned entries.

load re-derives the decoded record by calling object.DecodeUnsigned(block) on the stored bytes, then reattaches the storage-only fields (RecordCID, Sig). A test (TestStoredBlockHashesToRecordCID) confirms that the canonical bytes of a resolved record hash to its stored RecordCID — the blockstore’s content-addressing invariant holds after a round-trip through SQLite.

What this enables

The pointer chain is now independently verifiable. Given a record_cid, any consumer with the canonical block can check: does sha-256(block) produce this CID? Does the did:key in the record’s did field verify the detached sig over this block? Is prev the CID of the preceding block? None of these checks require trusting Chum. The whole chain can be walked from genesis, and each link is cryptographically bound to the next.

That is what “verifiable across time” means in practice: not a log Chum promises not to rewrite, but a chain where rewriting would require forging both the content hash and the DID-key signature — and where any observer can verify the chain independently, offline, with standard AT Protocol tooling.

SP3 (deferred) will expose an auditor-facing export and CLI proof-of-history view. SP1 delivers the in-process substrate; the verifiability is real, the export path is not yet built.

Get early access →