ADR 0006 — continuous substrate channels: auto-minted content-addressed releases + a moving channel pointer + an automated co-resolution loop
- Status: proposed
- Date: 2026-06-04
- Refines: 0004 (the substrate BOM + per-repo adoption check)
- Relates: 0005 (the
repository_dispatchfan-out rails this reuses) - Tracking: #65 (substrate epic)
0004 was right about the cure and wrong about the cadence. The atomic release ID and the publisher-closure proof dissolve the technical co-resolution hazard. But 0004's forcing function — a human release captain who hand-cuts a
YYYY.MMrelease and shepherds a manual canary/fan-out — does not survive multiple releases per day. At that cadence the captain becomes the deadlock: "nobody goes first" turns into "everybody waits on the captain." This ADR changes the forcing function from a person goes first each release to automation goes first, continuously.
Context
0004 gave the constellation the right
substance-axis spine: a committed release BOM (topology/substrate.json) assembled
from publisher-emitted facts, a per-repo offline substrate check reporting by proof
level, the publisher-closure proof that catches the transitive-ontos hazard, and an
append-only/immutable discipline. None of that is in question here — it is the load-
bearing, already-implemented core (#103/#104/#106).
Two forces break the part of 0004 that was supposed to drive adoption:
Cadence. 0004's release identity is
YYYY.MMand its rollout is a captain-driven train: collect manifests →import --write→ open the descriptor PR → shepherd a stele canary PR → fan out → flip status toactive. That is a monthly shape. The constellation now cuts multiple substrate releases per day. A monthly ID cannot name what changes hourly, and a human-shepherded train cannot keep pace — so the train either falls behind reality (the BOM stops describing what repos actually run) or the captain becomes a full-time serializer of everyone's bumps. The first reintroduces the rotting-convention failure 0004 set out to kill; the second is the original "nobody goes first" standoff displaced onto a single overloaded role.A checker is a veto, not a trigger. 0004's genuine forcing functions are the canary precondition ("a release that cannot update stele is not adoptable") and the named captain. Both require a human to initiate the first move every release.
substrate checkand the closure proof only block unsound PRs — they never open a sound one. The deadlock is the absence of the first PR; a gate that runs only once a PR exists sits downstream of the decision that is actually stuck. The concrete cost is live: stele#6 is not blocked upstream (logos shippedlogos-kernel); it is blocked because no automated actor opens the tri-core bump and no human volunteers to absorb the risk by hand.
The membership test still holds (this spans ≥2 systems and has no single-repo owner), so the fix belongs in atlas — but the fix is an operational-model change, not a new descriptor field bolted onto the monthly train.
Decision
Make substrate co-resolution continuous and automated: immutable releases are auto-minted at the publisher's cadence, a small set of channels is the moving human-facing pointer, and a closed automation loop — not a person — performs the canary, the promotion, and the fan-out. The release captain is retained but demoted to an exception handler.
1. Releases are auto-minted and content-addressed (revises 0004's YYYY.MM)
A release ID is a digest of its resolved closure, not a hand-typed calendar label.
Each publisher push that yields a coherent closure mints, in CI, a release whose key is
<date>-<shortdigest> (e.g. 2026.06.04-d6ab9473201c — the first real release) where
the shortdigest is the first 12 hex of sha256 over the canonicalized per-component
coordinate set (the length the assembler pins; see DIGEST_HEX_LEN in
src/commands/substrate/import.mjs). Properties this buys:
- Idempotent. The same closure always hashes to the same ID, so re-runs and no-op publishes do not mint redundant releases. The ID is the closure's identity.
- Cadence-free. Minting is driven by publisher events, not the calendar. Ten releases in a day is ten append-only rows, not a contradiction.
- No hand-typed authority. The ownership inversion of 0004 is preserved and sharpened: the ID itself is now derived from publisher facts, so a human cannot even name a release into existence — only publishers' closures can.
releases stays append-only and each row stays immutable (the 0004 discipline is
unchanged for releases). The _comment placeholder era ends: IDs are real because the
facts are real.
2. Channels are the moving pointer (the one mutable field)
A new top-level channels map names a small, stable set of tracks, each a pointer
to one release ID:
{
"channels": {
"stable": "2026.06.04-9f3a1c2b8e04",
"edge": "2026.06.04-c71e0ab35f91"
}
}
- Consumers subscribe to a channel, not a release:
{ "substrate": "stable" }.substrate checkresolves the channel → its current release → verifies the repo's pins against that release's coordinates. A consumer never edits a release ID by hand. edgeadvances to every release whose canary passes;stableadvances after a soak. Production repos trackstableand are insulated from intraday churn; the tri-core/canary path lives onedge.- The channel pointer is the only mutable field in the descriptor, and it may be moved only by the promotion gate (below). Each release it points at remains immutable. This is the single, deliberate carve-out from 0004's immutability rule — and it is safe precisely because the pointer is auditable (every advance is a gated commit citing the canary run) and reversible (rolling a channel back to the last-known-good release is an instant, first-class rollback).
3. The automated co-resolution loop (the new forcing function)
No human is in the happy path. On a publisher release:
auto-import + audit — atlas CI assembles the candidate release from the publisher manifest and verifies it against publisher truth (0004's
import/audit, offline). A non-coherent closure never mints.auto-canary — the candidate is applied to stele (the tri-core stress case: one
logos-contract, oneontos, the kernel across wazero/native/bundled-wasm) in an ephemeral branch;substrate check --strictplus stele's own build/test gate it.auto-promote — on green, the loop advances
edgeto the candidate; after the soak window, it advancesstable. Each advance is a committed, CODEOWNERS-routed, auditable pointer move.auto fan-out — advancing a channel dispatches
bitspark.substrate.channel.moved(reusing 0005 §4 / #41'srepository_dispatch+ SHA-pinned reusable-workflow rails) to every consumer on that channel. Each consumer's listener runssubstrate apply(the codemod), opens an update PR, and the PR auto-merges when itssubstrate checkis green.The sender of that dispatch is a single, shared piece — not a per-track copy. Both fan-out halves (this substrate event, and 0005 §4's
bitspark.toolchain.updatedviews event)uses:one reusable dispatcher workflow (.github/workflows/_dispatch.yml, #134) that validates theclient_payloadagainst one schema (the discriminatedschema/atlas-dispatch-payload.schema.json, instantiated by both events) and POSTs it safe-by-default (dry-run unless armed; degrades on a tokenless fork). The atlas-side substrate caller (#215) computes the plan withsubstrate dispatch-planand hands it to that dispatcher; the views caller (#93) will do the same. So there is one dispatcher family, not two near-duplicate ones drifting into two conventions — the §5 rot, applied to the dispatch sender. Note this is distinct from the member GATE below: the gate checks a member's pins; the dispatcher wakes the member. The pin-trinity work (#182) reuses this same family rather than adding a third convention.
The captain handles only exceptions: a canary or soak failure (a real incompatibility), a yank, or a manual channel pin. That is where human judgment belongs and is rare enough to staff sustainably.
4. Gates on — off-channel drift fails CI (#47 activation)
The dormant per-repo gate is activated for substrate-subscribed repos: a repo whose pins do not co-resolve to its channel's current release fails its own CI. Combined with the fan-out auto-PR, the channel becomes the path of least resistance and divergence becomes loud — deferral stops being free.
5. Membership participation & conformance — the wiring must not become the next rotting convention
The continuous loop has holes unless every member is wired in, and wired identically. Hand-copied CI steps and onboarding prose are exactly the unowned, silently-rotting convention 0004/0001 exist to kill — one level up, in the wiring instead of the versions. So participation is itself generated, single-sourced, and checked:
- Scaffold, don't document.
atlas substrate adopt --channel <stable|edge>generates into a member: the{ "substrate": "<channel>" }field in itsatlas.jsonand a thin caller workflow thatuses:atlas's reusable workflow. The member never holds the gate/listener logic, so there is nothing to copy and nothing to drift. - CI logic lives once (a reusable workflow). The member-side
substrate checkGATE lives in a single reusable workflow in atlas (Bitspark/atlas/.github/workflows/substrate-member.yml, invoked viaworkflow_call). A member holds only a generated ~10-line caller — its triggers (which include therepository_dispatchlistener the fan-out wakes), oneuses:line, and the channel — so there is no per-repo CI logic to duplicate or diverge. The irreducible per-repo surface (events + theuses:reference) is itself generated. (The sender of thatrepository_dispatchis a different shared piece — the reusable dispatcher_dispatch.yml, §3.4 / #134 — not this member gate; the gate checks pins, the dispatcher wakes members.) - Prefer org-level application — no per-repo file at all. Where GitHub org required workflows / repository rulesets are available, the gate is attached to all members by one org ruleset; the generated thin caller is only the fallback for repos a ruleset cannot cover. Fewer files, less to drift.
- The wiring is itself a pinned, auto-bumped release. The caller pins the reusable
workflow by SHA, and that SHA is advanced by a dedicated wiring-SHA bump fan-out that
rides the same dispatcher family as the channel fan-out (#216 —
atlas substrate wiring-plan→ the shared_dispatch.yml, eventbitspark.substrate.wiring.bumped; not the channel-moved fan-out itself, the false equivalence #129 once implied). On a change to the gate /wiring.json, it wakes every member to runatlas substrate adopt --writeand auto-PR its pin, so "which gate version runs everywhere" is coordinated, never hand-bumped per repo (0005 §4 applied to the wiring, not just regenerated artifacts). - Zero-install keeps it toolchain-drift-free. The reusable workflow runs the CLI via
npx github:Bitspark/atlas(zero-dependency by charter) — no per-repo setup step, Node pin, or action-version matrix to skew between members. - A conformance meta-gate lints the wiring byte-for-byte.
atlas substrate conformancechecks every member's caller against the canonical generated template: the current reusable-workflow SHA, the required triggers, the gate marked required, and the channel matchingatlas.json. A stale SHA, a missing trigger, or a hand-edited step is a named failure — drift in how a member participates is caught the way drift in coordinates is, so the CI integration cannot rot silently. - The participant set is single-sourced, never "all org repos". Participation is
scoped to exactly the substance-layer members of
topology/constellation.json(today: ontos, logos, thesmos, stele, arche);orthogonalinfrastructure (design, atlas) is excluded, and the ~120 unrelated org repos are not in the constellation at all. The scaffold, conformance gate, and fan-out iterate that derived set — they have no path to a repo the constellation does not list, so over-reach is structurally impossible, not merely a matter of discipline. - Participation is a report, not a hope. atlas already owns that member list. The aggregate view becomes a participation + conformance dashboard: every member, its channel, wired? / conformant? / on-channel? — one table. A member that should participate but does not is loud, by name.
- Onboarding docs are generated from the same source, so they cannot rot out of sync with the scaffold they describe.
This is the membership-test discipline turned on atlas's own rollout: a cross-member invariant ("everyone participates, identically") gets a tool and a check, never a README paragraph.
6. Repository layout — one directory per axis
topology/ today conflates two orthogonal axes under a name that describes only one: the
structure axis (registry → constellation → family) and the version axis (publisher
manifests → BOM). They share nothing but a member set and a folder; the name misleads. This
ADR separates them so each directory is one axis with one source-of-truth concept:
topology/ # STRUCTURE — the graph (who exists, how they relate)
registry.json # roster (input)
constellation.json # assembled graph (generated)
family.md # prose (generated)
substrate/ # VERSION — release coordination (which versions co-resolve)
bom.json # the release BOM + channels (was topology/substrate.json)
publishers/ # publisher manifests (was topology/substrate/)
release.json ontos.json logos-contract.json logos-kernel.json thesmos.json
GOVERNANCE.md
README.md
The move is atlas-internal: members reference a channel name in their atlas.json
("substrate": "stable"), never a path, so it does not ripple to any member repo. The
blast radius is the CLI's path constants (src/substrate.mjs, src/commands/substrate/*),
the two governance workflows, CODEOWNERS, and test fixtures — bounded and mechanical. It
also resolves a smaller smell: today the assembled file substrate.json sits beside an
inputs folder also named substrate/; bom.json + publishers/ makes the two visibly
distinct. Relocating a file with identical content does not violate the append-only /
immutable rule (that forbids editing a release's coordinates, not moving the file), and
doing it before the channel work lands avoids rewriting the new code onto the old paths.
What is retained from 0004 unchanged
The BOM catalog, per-language structured coordinates, the substrateDeps closure and
its publisher-closure proof, the offline zero-dep substrate check with honest proof
levels, append-only immutable releases, publisher-emitted facts, and per-repo
ownership (atlas verifies and records; it never resolves or vends). This ADR changes
how releases are named, pointed at, and rolled out — not what a release is or who owns
its truth.
Consequences
- The deadlock dissolves structurally. "Who goes first?" is answered "the loop, on every release," so the human standoff never forms for the normal case. stele#6 becomes the first exercise of the canary path, not a feat of volunteer courage.
- Rollback is a pointer move. Because channels are mutable but releases are not, reverting the whole constellation to a known-good closure is one gated commit — safer than any hand-coordinated downgrade.
- Intraday churn is tamed, not suffered.
stable/edgeseparate "always latest green" from "what production tracks," so high cadence does not whip every repo. - Release cardinality grows fast. Many releases/day means the
releasesmap needs an archival/compaction policy (e.g. keep all referenced-by-a-channel or newer-than-N-days inline, fold the rest into a compressed history). New work, called out so it is owned rather than discovered. - A bot identity gains merge rights. Auto-merge on green check is a real trust/security surface: the fan-out actor must be scoped, its merges must be gated on the consumer's own check (never a self-attestation), and its token governed. This is named here so #47/#41 build it deliberately.
- The captain role shrinks but does not vanish. It must still be staffed for exceptions and yanks — but as on-call-for-failures, not a per-release serializer.
- Participation and its CI wiring are enforced uniformly, not evangelized. Because the gate/listener is single-sourced (a reusable workflow, ideally an org ruleset) and members hold only a SHA-pinned generated caller, "everyone wires it the same" is a checked invariant rather than a code-review habit. The conformance meta-gate turns a non-participating or mis-wired member into a named failure instead of a silent hole in the loop. The cost is that atlas must own a member-facing reusable workflow and a scaffold/conformance surface — a small, single-sourced one, but a real ongoing contract with every member repo.
- It depends on the unbuilt 0004 pieces becoming real:
substrate plan/apply(#84), therepository_dispatchfan-out + auto-promote workflows (#41/#47), and continuous publisher facts (real manifests emitted on every release, ending the placeholder era). This ADR is the design-of-record that sequences those so they are built against the continuous model, not the monthly one.
Alternatives considered
- Keep
YYYY.MM+ a bigger captain team. Rejected: more humans on a serial train raise throughput linearly while releases arrive super-linearly; the captain remains the bottleneck. Scaling the serializer does not remove the serialization. - One moving
currentpointer, no channels. Rejected: every consumer would chase every intraday green, so production repos churn on unsoaked releases. Two channels (edge/stable) is the minimal set that separates "latest green" from "production track"; it is not gold-plating. - Timestamp / monotonic-counter IDs instead of content-addressed. Rejected: non-idempotent — re-runs and no-op publishes mint redundant releases, and two identical closures get two IDs, defeating the "the ID is the closure" property the check relies on.
- Renovate / Dependabot per-ecosystem auto-merge. Useful as transport underneath the fan-out, but rejected as the authority: the unit they bump must be the channel's release closure (co-resolved across lanes), not independent per-ecosystem version bumps — exactly the trap 0004 named.
- Full monorepo / Nix or Bazel global pin. Rejected as in 0004: far heavier than the problem and abandons the per-repo-ownership model. Channels give monorepo-at-HEAD's coordination without its consolidation.
- Mutable releases (edit
stable's coordinates in place). Rejected: it would destroy the auditability and instant-rollback that make the moving pointer safe. The pointer moves; the thing it points at never does.
The work that realizes this ADR
Lands as sub-issues under #65, built on the continuous model from the start:
- Repository reorg — lift the version axis out of
topology/into a top-levelsubstrate/(bom.json+publishers/); update the CLI path constants, the two governance workflows,CODEOWNERS, and fixtures. Lands first, before the channel work. - Schema + channels — add
channelstosubstrate/bom.json(the one mutable, gate-only field); content-addressed release IDs;substrate checkresolves a channel. - Auto-mint
import— derive the release ID from the closure digest; idempotent re-import. substrate plan/apply(#84) — the format-preserving, fail-closed codemod, channel-targeted (apply --channel stable).- Automation loop (#41/#47) — auto-import → auto-canary(stele) → auto-promote
(
edgethen soakedstable) →repository_dispatchfan-out → consumer auto-PR + auto-merge-on-green; the scoped bot identity and the channel-advance immutability gate. - Gate activation (#47) — turn the per-repo
substrate checkfrom dormant to required for subscribed repos. - Member scaffold + reusable workflow —
substrate adopt --channel <c>generates the channel field + thin caller; single-source the gate/listener as a SHA-pinned reusable workflow (and an org required-workflow/ruleset where available). - Conformance meta-gate + participation dashboard —
substrate conformancelints that every member is wired uniformly (current SHA, required triggers, on-channel); the aggregate participation report (every member: wired? / conformant? / on-channel?). - Generated onboarding docs — produced from the same source as the scaffold, so they cannot drift from the wiring they describe.
- Release archival/compaction — bound the growth of the append-only
releasesmap.