ADR 0025 — producer announcements are append-only content-addressed events; the BOM is their materialized projection
- Status: accepted
- Date: 2026-06-15
- Relates: 0004 (the BOM this projects), 0006 (channels resolve a release id), 0008 (the release model + the existing content-addressed release objects), 0023 (release identity = digest, epoch = atlas-owned label — this ADR generalizes that ignore-and-normalize move from the date to the whole announcement)
- Source: research-docs/0001 + its external-expert advice (§3, §4)
ADR 0023 fixed the date. This fixes the announcement. A producer's facts were still a single overwritten file plus a hand-authored roster — the same blurred state ownership and the same ceremony 0023 removed, one layer up. Three things are now stated and enforced:
- A producer announcement is an append-only, content-addressed event.
- The assembly of a release is itself a recorded fact (a SnapshotAssembled event), so the BOM can carry
inputs/predecessorprovenance without an editable row field.substrate/bom.jsonis a materialized projection of that event log — generated by one command, byte-stable, withimport --check/assemble --checkas its exact checkers.The transition is dual-read and non-breaking: the assembler reads either an event or today's single-file manifest, byte-identically. The fleet migrates lazily, one member per release. Nothing is forbidden now.
Why this ADR exists
ADR 0023 established that a release's identity is its content digest and the epoch date is an
atlas-owned label, and removed the uniform-date check that forced hand-edits. But the layer
underneath was untouched: a producer's facts live in a single file substrate/publishers/<comp>.json
that emit overwrites on every release (history lost), and the release roster
substrate/publishers/release.json is hand-authored (the same governance ceremony — adding
stele to the roster was a manual edit, exactly the class 0023 set out to kill). The expert
consultation (research-docs/0001) named this directly: "manifest.release is coordinator-owned
placement metadata masquerading as a producer-owned artifact fact," and the deeper smell —
"generated but hand-edited" — every surface a command could derive but a human edits becomes a
second source of truth.
Meanwhile substrate/releases/ already holds 41 content-addressed, append-only release objects
(component/constellation snapshots), and no gate protects them — a delete or in-place rewrite of
an existing one sails through CI today, because the immutability job diffs only substrate/bom.json.
The expert's §4 prescription: make announcements append-only content-addressed events, replace the hand-authored roster with a generated assembly event, and make the BOM a projection that "may be stored redundantly only because it is a generated projection with an exact checker."
Decision
A producer announcement is an append-only content-addressed event at
substrate/publishers/<comp>/sha256-<manifestDigest>.json, wheremanifestDigest = sha256(canonicalJson(manifest minus {release, $schema})). Strippingreleaseis the same ignore-and-normalize move 0023 made for the date: the event's identity is the producer facts only (component, catalog identity, source, coordinates, artifacts, substrateDeps), so it is stable across epoch normalization — a component announced at one date and re-stated under the active epoch has the same event digest. A generated, mutablecurrent.jsonin the component dir names the active event (a pointer, never authority).The assembly of a release is itself a fact: a SnapshotAssembled event at
substrate/assemblies/<bom-release-id>.jsoncarrying{predecessor, inputs: {comp → manifest-event-digest}, reason, generator, assembledAt}. It is keyed by the BOM release id (O(1) lookup from a row), not by its own content digest — its body holds volatileassembledAt/generator, which would churn a content key every run and break--check. The BOM references the producer-event digests through this file; the digests are never row fields, so the immutable ledger's rows stay pure coordinate facts.substrate/bom.jsonis a materialized projection of the event log, assembled by one command, byte-stable, withimport --check/assemble --checkas its exact checkers (the expert's rule: store a surface redundantly only as a generated projection with an exact checker).The transition is dual-read and non-breaking. The assembler resolves each component to either an event file (preferred when the component dir exists — via the authoritative
current.json) or today's single-file manifest, producing byte-identical assembled output. The single-file format is not forbidden now; the fleet migrates lazily, one member per release. Forbidding it is a later, separately-greenlit closer.Append-only is enforced at PR time. A new gate job freezes every content-addressed event tree —
substrate/releases/,substrate/publishers/*/sha256-*.json, andsubstrate/assemblies/— to add-only (never mutate or delete), also closing the pre-existing unenforced gap on the 41releases/objects, going forward.
Consequences
- + Producer history is never overwritten; the announcement is a replayable, content-addressed record.
- + One-command assembly (ADR follow-on
assembleverb): no hand-edited dates or rosters — the horos/stele manual-registration class is gone. - + The BOM gains
inputs/predecessorprovenance without changing any existing release key (the provenance lives in the separate assembly file, not the row). - + The 41 existing
releases/objects become delete/mutate-protected. - Byte-stability (the invariant): every existing frozen BOM row reproduces byte-for-byte.
Provenance never enters the digested
components, is never added to existing or re-imported rows, and the row shape stays{status, immutable, components};import --check/auditstay byte-stable; the existing immutability job is untouched (the append-only enforcement is a second, additive job). - − / accepted: the 41 pre-existing
releases/objects are frozen going forward only (their filenames are content addresses, so a mis-hashing body is already rejected on read, but a pre-ADR delete was unguarded).release.jsonshifts from authority to a generated projection with a--check(members stop hand-editing it). - Out of scope (a later, separately-greenlit closer): forbidding the single-file manifest once telemetry shows the fleet has migrated; and any optional row-level assembly pointer (which would need a schema + catalog-epoch migration).
- Alternatives rejected: (a)
inputs/predecessoras BOM-row siblings — would relax the release row'sadditionalProperties: falseand create an editable, un-gated, un-audited field inside the immutable ledger (the gate andauditinspect only.components); (b) keying the assembly event by its own content digest — volatileassembledAt/generatorchurn the digest every run, breaking--check.