ADR 0008 — substrate release model (expert-refined): component & constellation releases, signed channel pointers, atomic advertisement, scoped-expiring skew exceptions, the release verdict, and the catalog-epoch trust root
- Status: accepted
- Date: 2026-06-04
- Refines: 0007 (the principle), 0006 (channels), 0004 (the BOM)
- Source of truth: the external expert review in
research-docs/0001-substrate-version-coordination.advice.md - Tracking: #141; sub-issues #142–#148 under epic #65
A component release is complete only when every lane and every package from one source commit is published-or-obtainable, verified from a clean environment, and carries a signed publisher manifest. A constellation release is promotable only when the vector of component releases is closure-coherent, has no duplicate type-identity-critical instances in any resolved lane graph, and has the required conformance proofs. A channel is only a signed, mutable pointer to such a constellation release.
This ADR turns ADR 0007's principle into an implementable model, after external expert review. The review validated the direction — its "simplest model to try first," a central release-ledger + fan-out, is essentially what atlas is — and corrected three things ADR 0007 stated imprecisely.
1. Corrections to ADR 0007
- Cargo does NOT guarantee one crate version per graph. It allows duplicates; types
from different instances are distinct (
cargo tree -dfinds them). So our single-instance rule for type-identity-critical crates is necessary, not redundant — ADR 0007's "cargo one-version rule" framing was wrong. Rust's ontos pin is forced low becauselogos-contracttransitively pins it and we forbid duplicate instances, not because cargo dedupes for us. - "Atomic publish" is impossible as literal registry atomicity → it is atomic advertisement (§4). npm versions are immutable/non-reusable; GitHub Packages artifacts are deletable. There is no multi-package transaction.
go.sumis not Go's resolved graph — Go uses MVS and the build list comes fromgo list -m all, never a lockfile (§7).- npm can pin git URLs — banning them is our policy (reproducibility, private packages), not an npm impossibility. We enforce the ban explicitly.
2. Three release objects (replaces "one release")
Stop treating "current release" as a property of a component alone (#142).
Component release — one source commit + its per-lane coordinates/artifacts + a semantic proof. Content-addressed (
component:sha256:…).Constellation release — a vector of component releases plus a closure proof, a resolved-graph proof, and a canary proof. Content-addressed (
constellation:sha256:…). This is the unit a channel points at, and the unit we promote — never a component head.Channel pointer — a small signed, monotonic file, separate from the immutable BOM content:
{ "channel": "edge", "sequence": 1842, "previous": "constellation:sha256:123…", "target": "constellation:sha256:def…", "createdAt": "2026-06-04T12:34:56Z", "validUntil": "2026-06-07T12:34:56Z", "proof": { "canary": "stele@sha256:…", "resolvedGraph": "sha256:…", "conformanceBundle": "sha256:…" }, "signatures": ["…"] }sequence/previous/validUntilare not ceremony: they defend against rollback, freeze, and mix-and-match (the TUF threat classes). We do not implement full TUF; we copy those invariants for the mutable pointer.edge= latest coherent constellation release that passed the canary.stable= anedgerelease that survived a soak window with no blocklisted incidents and at least one successful non-canary fan-out.Consumers commit the resolved constellation release ID, not
"stable"— so member CI stays offline and deterministic. The bot PR says "updates you to stable as of sequence 1842"; the repo commits the immutable ID.The promotion DECISION is
atlas substrate roll(#128) — the guarded orchestration around the deterministicsubstrate promotethat realizes the §2-of-0006 loop: auto-canary → auto-promote. It advances a channel only on a green canary, is idempotent (a settled state is a no-op), and offline-degrades (a placeholder-proof era HOLDS, never over-claiming). It gatesedgeon the candidate's green canary + anedgeverdict, andstableon a soak window (default 24h from theedgepointer'screatedAt) plus astableverdict with REAL conformance/graph/canary proofs; it stands up thestablechannel on its first successful roll. Two small model artifacts make the canary verdict decidable offline:- the canary RESULT SIDECAR
substrate/canary/<bomRelease>.json({ release, status: "green"|"red", canary, member }) — the candidate-keyed canary verdict the auto-canary CI writes (a CI-produced artifact, like the channel pointers; not part of the immutable BOM). It is the authoritative gate input; absent, the decision falls back to the verdict's canary leg. - the RED-canary signal — a canary proof reference carrying a
@red/@failedmarker (e.g.stele@red:sha256:…) records "the canary RAN and the candidate could not be absorbed", distinct from a placeholder (no canary yet). A red canary is required-blocking: the channel freezes, never advertising a release the canary rejected.
- the canary RESULT SIDECAR
3. Atomic advertisement (the npm-robustness rule)
A registry may contain incomplete or orphaned versions, but no BOM release or channel may ever reference them (#138). Enforced by a clean-room verification in the publish flow:
- build all packages from one source commit;
npm pack, record tarball digests; - publish exact unique versions;
- from a fresh environment with a clean cache, install the exact versions from the registry;
- verify lockfile
resolved/integrity, embeddedsubstrate.*metadata, WASM hashes; - emit the publisher manifest only after the clean install verifies;
- import to the BOM; only then move
edge.
On partial publish, the already-published version is an orphan: deprecate/blocklist it, never reuse the version. npm dist-tags are not a substrate channel mechanism (mutable, per-package).
This flow is the canonical release-on-merge CI (#109): a member's
.github/workflows/substrate-release.yml (vendored from atlas's workflow/ scaffold) runs
steps 1–5 on a version-tag push — build the lanes, atlas substrate emit the publisher
manifest, gate it with atlas substrate verify-publisher (the source-tag/version/commit
agreement) + verify-published (the clean-room registry-existence + co-publish), upload the
manifest as a release asset — then announces the verified release to atlas
(repository_dispatch(bitspark.substrate.publisher.released)). atlas's
.github/workflows/substrate-publisher.yml reacts with step 6 (import to the candidate BOM
- re-
auditagainst live tags/registries); a disagreeing coordinate fails the candidate, nevermain. The announce is gated on both verify steps and idempotent (re-importing a published row is a no-op; the immutability gate forbids editing one), so an incoherent or unpublished set can never be advertised. The live per-lane registry write stays with each member's existing bespoke publish path until an org owner consolidates it (the follow-up); theedgemove (step 6's tail) is the separate canary → promote → fan-out loop (#128).
4. Scoped, expiring skew exceptions + compatibility epochs
ADR 0007's blunt "no lag" is replaced by an exception-based rule (#143): skew is forbidden by default, permitted only as an explicit, expiring, scoped certificate:
{
"id": "skew-ontos-0.1.2-to-0.1.4-2026-06-04",
"package": "ontos",
"fromRelease": "component:sha256:…",
"toRelease": "component:sha256:…",
"scope": ["wire", "signing-bytes"],
"notValidFor": ["typeIdentity", "runtimeSingleton", "wasmAbi"],
"vectorSuite": "ontos-codec-v1@sha256:…",
"matrixProof": "sha256:…",
"owner": "substrate-release-captain",
"expiresAt": "2026-06-05T00:00:00Z",
"maxConsumerCount": 1,
"reason": "temporary DAG convergence while logos-contract catches up"
}
Compatibility epochs make the policy decidable:
{ "wire": "ontos-codec-v1", "signingBytes": "ontos-codec-v1",
"typeIdentity": "ontos-value-epoch-3", "spi": "logos-checker-spi-epoch-2",
"wasmAbi": "logos-kernel-wasm-abi-epoch-1" }
- Same wire epoch may allow byte-skew if the vector matrix passes.
- Same type-identity epoch is not enough if two instances can co-exist in one process — those still need one resolved instance.
- A vector suite justifies a wire-level bridge only; it may never justify two
type-identity-critical instances.
substrate check: the resolved coordinate must equal the targeted release unless an unexpired, in-scope, under-count exception permits it.
5. One release verdict + attestation bundle
The substrate proof (coordinates/closure) and the conformance proof (bytes) collapse into one verdict (#144):
substrate-ready(R) = coordinate ∧ publisher-closure ∧ resolved-graph(where required)
∧ package/artifact availability ∧ conformance(required epochs) ∧ canary
Bound by an in-toto/SLSA-style attestation bundle (the JSON shape, no heavy
dependency): a signed statement whose subject is the per-lane artifacts (with digests)
and whose predicate records component, source commit, release id, substrateDeps,
vectorSuite, per-lane conformance, and toolchains. stable promotion requires the
relevant conformance proofs present; a consumer check may honestly report missing,
but promotion may not proceed without them.
6. The catalog-epoch trust root (clean bootstrap)
The catalog has bootstrap identity errors (#137). Rather than punch a hole in the
immutability gate, declare a new trust root: mark the current placeholder BOM
{ "status": "bootstrap-draft", "trusted": false }, then open
{ "catalogEpoch": 1, "trustedFrom": "2026-06-04T…", "reason": "first corrected catalog identity root" }
Immutability and append-only start at catalogEpoch: 1. After that, a catalog
identity change requires a migration record, an alias, or a new component identity.
7. Resolved graph, publisher correctness, private Go
substrate-lock.json(#83): a normalized, committed cross-lane resolved snapshot — Go build list fromgo list -m all(notgo.sum), Cargo fromCargo.lock(withcargo tree -dfor duplicates), npm frompackage-lock.json— plus recorded toolchain versions.- Publisher-correctness gate (#145): source tag / declared package version / embedded
release id / manifest must all agree (the thesmos
version=0.2.0vs tagv0.2.1bug). - Private Go modules have no public checksum DB, so the BOM/lock is the checksum authority: record the resolved commit + module hash, not just the version string.
- Exact pins, no ranges for substrate deps (the logos
@bitspark/ontos-core: "^0.1.1"is forbidden) except a deliberatepeerDependenciesrange under the skew policy. - The checker is part of the trust base (#147): vendor/pin it; do not
npx-fetch it in "offline" CI. - The resolved-graph proof is offline-complete, and required per channel (#199). The
member gate's
substrate check --strictpromotesresolvedGraph missing → passby comparing the committed.atlas/substrate.lock.json's resolved coordinates (go/ts versions, the rs ref+commit) and per-package duplicates to the release — none of which need the registry, so the npmintegrity/resolvedfields being null offline does NOT block promotion (they are reviewer metadata insubstrate-lock.json, not gate inputs). Per §5'sresolved-graph(where required), the gate enables--strictonstable(a member must commit a current resolved lock; a missing/stale lock reds the gate) and keeps it advisory onedge(the honest warning is tolerated; a direct-pin drift still errors). The strictness lives in the single-sourced reusable workflow, keyed on thechannelinput.
Failure-mode register
| Failure mode | Mitigation |
|---|---|
| Partial npm publish | Atomic advertisement; orphan blocklist/deprecate; never reuse versions (#138) |
| npm package pair mismatch | Fixed TS version line per component; umbrella package (declared #146, generated + currency-gated #228); clean-room install verify (#146/#228) |
| Cargo duplicate type instances | Parse Cargo.lock; forbid duplicate type-identity-critical crates; cargo tree -d (#108) |
go.sum mistaken for lockfile |
Generate substrate-lock.json from go list -m all (#83) |
| Private Go tag drift | Record source commit + module hash; sign manifests; audit tags (#109) |
| Channel rollback/freeze | Signed pointer: monotonic sequence, previous, expiry (#142) |
| Dist-tag mix-and-match | Do not use npm dist-tags as substrate channels (#138) |
| Semver range drift | Exact pins; explicit peer ranges only under the skew policy (#143/#146) |
| Toolchain drift | Record Go/Rust/Node/npm versions in the resolve proof (#83) |
| Conformance vectors too narrow | Vector-suite digest, compatibility epochs, differential/fuzz supplements (#143/#144) |
| Catalog bootstrap hole | catalogEpoch trust root; old BOM marked draft/untrusted (#137) |
| Checker supply-chain drift | Pin/vendor the zero-dep checker; no network fetch in offline CI (#147) |
| Credential blast radius | Read-only consumer tokens; separate publish creds per component; OIDC where supported |
Consequences
- The two observed failures (ontos cross-lane lag; the npm
(contract, kernel)mismatch) become structurally impossible to advertise and, once publisher CI lands, impossible to produce. - Skew is no longer ambient: it is debt with an owner, a scope, a proof, and an expiry.
- atlas remains the central release-ledger + fan-out the review endorsed over a monorepo / Bazel / Nix — most of the monorepo coordination benefit without moving source ownership. If cadence/automation later become painful anyway, that dismissal is the thing to revisit.
- The model issues (#142–#144) now implement against a ratified schema; the publisher- side issues (#109/#138/#145–#148) and the resolve/lock refinements (#83/#108) implement the rest of the register.