v1 packs (chunks-only) were under-engineered: a new peer landing on
v1 packs would have chunk bodies indexed by leaf_hash but no documents
table, no audit chain, no merkle interior, no edges — couldn't actually
hydrate. fox: "isn't what I wanted you under engineered..."
v2 packs ship every load-bearing shard table alongside chunk bodies in
the same tar.zst:
manifest.jsonl # chunk catalog (unchanged)
tables/documents.jsonl # array-per-line columnar JSONL
tables/chunks.jsonl # without content column
tables/merkle_nodes.jsonl
tables/edges.jsonl # FAN-IN restructured
tables/audit_events.jsonl
tables/derivations.jsonl
tables/concept_relations.jsonl
tables/concept_token_idf.jsonl
tables/providence_cache.jsonl
tables/citation_aliases.jsonl
tables/term_aliases.jsonl
tables/snapshots.jsonl
tables/document_http_meta.jsonl
blobs/<hash[:2]>/<hash[2:]> # raw UTF-8 chunk bodies
Two compression strategies inside the pack:
1. Array-per-line JSONL ({"_columns": [...]} header line + ["v1","v2",...]
data lines) drops ~30% of uncompressed bytes vs object-per-row JSONL.
zstd recovers most of that on its own, but smaller uncompressed
footprint also speeds up stream-restore.
2. Edges fan-in restructure at pack-build time: 22M rows of
(src_root, edge_type, dst_root, dst_uri, anchor) → ~500k unique
(dst_uri, edge_type, anchor, dst_root) groups with src_roots as an
array. ~5-10x compressed savings on the dominant table. Reverses on
unpack into the per-edge live schema. Live queries unchanged.
NOT shipped (per-peer state): mesh_*, selfmodel_*, capital_ledger,
memory_*, controller_events, fork_score_branches, adapter_loss_reports,
falsifications, schema_meta, meta. NOT shipped (rebuildable): chunks_fts*,
documents_fts* — restored from chunks.content + documents.title on
unpack.
push_pack no longer appends `cold_pack_pushed` to the audit chain.
That event leaked into the next push's audit_events.jsonl dump and
broke the "two writers at the same corpus state produce identical
pack_hash" determinism property. The bucket/disc file IS the receipt;
the snapshot_root pinned inside the pack metadata binds it to a corpus
state. No load-bearing consumer of the audit row.
pull_pack restored to handle both v1 (chunks-only) and v2 (tables +
chunks) packs. For v2 it extracts tables/*.jsonl to a temp dir,
calls restore_shard_metadata (which INSERT OR IGNOREs into the live
schema and expands edges back to per-edge rows), then fills chunk
content for every leaf_hash in blobs/. Idempotent against populated
DBs (INSERT OR IGNORE all the way down). Self-cleaning temp dir.
Sizing measured 2026-05-26: ~2.1 GB per shard pack compressed (chunk
content 1.78 GB + metadata ~0.3 GB), ~8.5 GB total across 4 shards.
~20% more than v1 chunks-only for self-sufficient hydration.
24 cold-object + evict tests pass (+1 new test_push_pack_v2_hydrates_fresh_empty_db
that builds a pack from a populated DB and unpacks into a completely
empty DB to verify all tables restored). Full suite: 2558 passed,
28 skipped, 1 xfailed.
9.3 KiB
Ticket #000061 — Cold-pack distribution tier
Status: in progress — opened 2026-05-25
Opened: 2026-05-25
Scope: ship arborist corpus state to new peers (and to DVD-R archival)
via point-in-time tar.zst packs hosted on an S3-compatible bucket
and/or burned to physical media. One artifact serves both channels.
Audience: dav1d (architectural inflection — new optional dep, new
optional network egress, new public-readable surface when CDN is
enabled, new "delayed snapshot" semantics around falsifications).
Hard constraint: packs are content-addressed
(pack_hash = hash_leaf(manifest_bytes) over a leaf-hash-sorted,
deduped manifest). Same chunk set → same pack_hash. Each pack pins the
corpus snapshot_root it covers in the audit chain — consumers can
detect drift between the pack and current corpus state.
Problem
A new peer comes up with an empty SQLite shard. How does it become a working arborist node?
Options today:
- Re-ingest every source from upstream (Wikipedia dumps, textbooks, crawls). Hours-to-days; depends on every upstream being reachable.
rsyncsomeone else's shard. Works but bypasses the audit chain — the receiving node has no proof the bytes came from a trusted producer with verifiable provenance.- Download a tarball. Fast, content-addressed, audit-row-pinned, verifiable on unpack.
snapshot.py already covers corpus identity (the snapshot_root
Merkle hash over sorted document_roots). What's missing is the
delivery — getting the bytes to a fresh node.
Design
What goes in the bucket
Only packs. No individual chunk objects. Layout:
<bucket>/packs/<pack_hash>.tar.zst # pack body
<bucket>/packs/<pack_hash>.manifest.ndjson # contents sidecar
Per-pack contents (inside the tar):
manifest.ndjson # one line per chunk: {leaf_hash, size}
blobs/<hash[:2]>/<hash[2:]> # one tar member per chunk, raw UTF-8 body
The blobs/ prefix inside the tar is an in-pack convention, not a
bucket layout — there is no blobs/ prefix in the bucket itself.
Pack identity and idempotence
pack_hash = hash_leaf(manifest_bytes)where the manifest is sorted byleaf_hashand deduped before hashing.- Same chunk set → same pack_hash. Two writers building the same pack collide on bucket upload — no GC after duplicate runs.
- Pack uploads are idempotent. Pack contents are append-only by construction.
Pack-size cap (DVD-R safe-fit)
Default max_pack_bytes = 4_400_000_000 (4.4 GB) — sits ~6.5 % below
the 4.7 GB DVD-R marketing capacity to absorb:
- ISO9660 / UDF filesystem overhead
- growisofs lead-in / lead-out
- Media manufacturing variance (~1–2 %)
- Older drives refusing the outer edge (~1–3 %)
Sits between industry-standard tool defaults (HandBrake DVD-5 = 4.59 GB;
DVDFab fit-to-DVD-5 = 4.3 GB; mkisofs default DVD = 4.59 GB). Cap
applies to uncompressed bytes so the compressed .tar.zst is ≤ cap by
construction. v1 produces ~30–50 % media fill on prose; future
streaming-compressed cap fills discs better.
Multi-pack splitting is greedy first-fit by accumulated raw bytes.
Two channels — one artifact
┌── S3CompatibleBackend.put_pack ── DO Spaces / R2 / S3
build_pack ─┬────┤
└────└── --local-dir DIR ─── growisofs ─── /dev/sr0
Byte-identical packs in both channels. A pack burned at one site and
fetched from CDN at another collide on sha256sum.
Delayed-snapshot discipline
Packs are not live mirrors. The bucket is eventually consistent with intent, not with the live corpus. Three operational consequences:
- Each pack pins a
snapshot_root(compute_snapshot_root(conn)at pack time). Recorded in thecold_pack_pushedaudit row and in thepush_packreturn body. - Falsifications produce new packs. Between repacks, drift
detection /
arborist falsify/rehydrate_driftcan flipprovidence_cacherows tostale. Chunk content is immutable; what changes is the corpus-state envelope. A re-pack with the same chunk set produces a differentsnapshot_doc_countif documents were added; same hash if not — but the audit row'ssnapshot_rootdistinguishes the moments. - Stale packs accumulate. No automatic GC in v1. Old
pack_hashes stay in the bucket as valid snapshots of past corpus states. GC is a separate ticket once we have a re-pack cadence to GC against.
Cores never evict; packs include them
Packs cover every hot chunk with local content — surfaces AND cores. Cores carry the distillation derivations a new peer needs to bootstrap the v9.8 chain. (Earlier draft restricted packs to surfaces only — a bug that would have shipped a fresh peer with no derivation roots.)
Verification on unpack
Every chunk's leaf_hash is the tar member name. open_pack recomputes
hash_leaf(body).hex() per member and refuses to restore on mismatch.
Hostile-bucket bytes never reach the local DB.
After unpack, consumers can arborist snapshot verify <root> against
the pack's recorded snapshot_root to confirm clean restore (or detect
drift if the local corpus has moved on since).
What was deleted from this ticket's first attempt
The original implementation included per-chunk individual blob storage
(evict_to_object, rehydrate_from_object, cold push, cold pull,
blob_key(), BLOB_PREFIX, put_chunk/get_chunk/has_chunk/
list_chunk_hashes on the backend ABC). Fox's correction same-day
(2026-05-25): "what ever was blobs? I wanted a way to hydrate using
tarballs (the core and important data) for bringing new machines up".
Five-step deletion record:
- Step 1 — Make the requirement less dumb: the requirement was always "ship a corpus to a new peer," not "expose every chunk as an S3 object." Individual blobs solved a problem nobody asked for.
- Step 2 — Delete the part: 14M-object storage, ~$70/hydrate request cost, 14M-entry LIST walks — all deleted. ~250 lines of source + 7 tests gone.
- Result: packs-only design lands at ~17 GB bucket storage (vs 38 GB for raw blobs), ~4 GETs/hydrate (vs 14M; packs filled to 4.4 GB compressed each via streaming zstd), DO Spaces request cost falls from ~$70 to ~$0.00002 per fresh peer.
Implementation
arborist/cold_object.py—ObjectStoreBackendABC (raw bytes, pack-shaped wrappers) +S3CompatibleBackend(boto3) +MemoryBackend(tests) +build_pack/open_pack/parse_manifest.arborist/evict.py—push_pack/pull_pack. Per-call binds the corpussnapshot_rootinto pack audit + return body.arborist/cli.py—arborist cold pack | unpack | stats.tests/test_cold_object.py— pack invariants, splitting, snapshot binding, local-dir, no-push mode. Default MemoryBackend so the default suite runs without boto3.tests/test_cold_object_boto3.py— moto-mocked S3 wire test (boto3 + moto gated).pyproject.toml—[object-store] = boto3>=1.34; dev extras pullmoto>=5.0.Makefile—bootstrap-object-store,cold-pack,cold-pack-dvd,cold-unpack,cold-stats.docs/cold-object-store.md— full design, DO Spaces quickstart, CDN hydrate recipe, DVD-burn recipe, delayed-snapshot discipline, failure-mode table.
Scope boundaries
In scope: pack build / push / pull / unpack, snapshot binding, hash verification, audit chain, DVD-R local-dir output, multi-pack splitting.
Out of scope (future tickets if needed):
- Multipart upload (single-pack > 5 GB on DO Spaces / AWS).
- Streaming pack builder (lift the in-memory tar ceiling, target compressed-bytes cap).
- Stale-pack GC.
- Range-fetch partial pack pulls.
- Server-side encryption (KMS / SSE-S3); mesh ciphertext on public bucket is the v1 confidentiality path.
- Cross-provider replication.
Status
In progress. Code lands incrementally on main.
Pack format v2 (2026-05-26): v1 (chunks-only) was correctly flagged
by fox as under-engineered — packs contained only chunk bodies, not the
shard tables a new peer needs to actually bootstrap. v2 ships every
load-bearing table inside the same tar.zst alongside the chunk blobs.
Tables go in as tables/<name>.jsonl (array-per-line columnar JSONL).
The edges table goes through a fan-in restructure at pack-build time
(group by dst_uri + edge_type + anchor, ship src_roots as an array)
which yields ~5-10× compression vs the live-schema row layout. Live
SQLite schema is unchanged; the restore step expands fan-in rows back
to the per-edge form. Pack uploads NO LONGER append a
cold_pack_pushed audit event (would leak into the next push's dumped
metadata and break the "two writers at the same corpus state produce
the same pack_hash" determinism property — the bucket / disc file IS
the receipt). 24 tests pass including a new test_push_pack_v2_hydrates_fresh_empty_db
that round-trips push from a populated DB into a completely empty DB.
Sizing (v2, current shards): ~2.1 GB per shard pack compressed (chunk content 1.78 GB + tables ~0.3 GB), ~8.5 GB total bucket footprint for new-peer-ready packs across 4 shards.
Per-peer state NOT shipped: mesh_, selfmodel_, capital_ledger, memory_, controller_events, fork_score_branches, adapter_loss_reports, falsifications, schema_meta, meta. FTS5 (chunks_fts, documents_fts*) not shipped either — rebuilt from chunks.content + documents.title on unpack.