Bitspark constellation
accepted source ↗

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 -d finds 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 because logos-contract transitively 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.sum is not Go's resolved graph — Go uses MVS and the build list comes from go 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/validUntil are 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 = an edge release 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 deterministic substrate promote that 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 gates edge on the candidate's green canary + an edge verdict, and stable on a soak window (default 24h from the edge pointer's createdAt) plus a stable verdict with REAL conformance/graph/canary proofs; it stands up the stable channel 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/@failed marker (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.

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:

  1. build all packages from one source commit; npm pack, record tarball digests;
  2. publish exact unique versions;
  3. from a fresh environment with a clean cache, install the exact versions from the registry;
  4. verify lockfile resolved/integrity, embedded substrate.* metadata, WASM hashes;
  5. emit the publisher manifest only after the clean install verifies;
  6. 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-audit against live tags/registries); a disagreeing coordinate fails the candidate, never main. 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); the edge move (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 from go list -m all (not go.sum), Cargo from Cargo.lock (with cargo tree -d for duplicates), npm from package-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.0 vs tag v0.2.1 bug).
  • 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 deliberate peerDependencies range 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 --strict promotes resolvedGraph missing → pass by 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 npm integrity/resolved fields being null offline does NOT block promotion (they are reviewer metadata in substrate-lock.json, not gate inputs). Per §5's resolved-graph(where required), the gate enables --strict on stable (a member must commit a current resolved lock; a missing/stale lock reds the gate) and keeps it advisory on edge (the honest warning is tolerated; a direct-pin drift still errors). The strictness lives in the single-sourced reusable workflow, keyed on the channel input.

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.

The Bitspark constellation — how the systems are built and relate.

GitHub