Bitspark constellation
accepted source ↗

ADR 0004 — substrate-release coordination: a publisher-emitted release BOM + verified per-repo adoption

  • Status: accepted
  • Date: 2026-06-04
  • Refines: 0002 (the manifest + reconcile spine)
  • Tracking: #65

Atlas does not define substrate versions; it verifies that publisher-emitted release facts and consumer-resolved dependency graphs agree on one immutable substrate release ID.

This ADR is the design-of-record after research consultation 0001 (an expert review of the original proposal). The first draft of 0004 proposed a central hand-authored substrate.json + an optional substrate: field + an atlas substrate check. The consultation endorsed the paradigm — central descriptor, per-repo opt-in, CI check — but corrected what the descriptor is: not a hand-typed table of version strings, but a committed release bill of materials, a generated/verified snapshot of publisher-emitted release facts plus enough resolved-graph evidence to state exactly what atlas is and is not proving. That keeps the constraints intact (atlas stays zero-dependency, member CI stays offline, each repo still owns its pins) while ensuring the descriptor does not become the next unowned convention — the very failure 0001/0002 exist to kill.

Context

0002 gave the constellation a typed edge graph: each repo declares dependsOn: [{ to, kind }] in its atlas.json, and atlas manifest validate --reconcile confirms every declared build/contract edge against the repo's real manifest of record (go.mod / Cargo.toml / package.json). That answers "is the edge real?"existence.

It deliberately says nothing about versions. An edge stele → logos (build) is reconciled the moment stele's go.mod names logos at all; which rev, and whether the same logical version is used across stele's three language lanes, is unchecked. A second cross-cutting concern now has no owner — exactly the shape 0001/0002 set out to fix, one level down (versions instead of edges):

  • Per-language pins legitimately differ in coordinate system and so cannot be one number: Go pseudo-versions, Rust git revs, npm semver. Today stele pins logos-contract at 07cf2ae (Go) and cdd57b46 (Rust) — two revs, with nothing asserting they resolve to the same logical substrate.
  • The anchor is informal. stele's manifests instruct that pins must "match the EXACT versions thesmos resolves." That convention lives in prose comments, owned by no tool — copies rot silently, the failure atlas exists to kill.
  • The cost is concrete. stele#6 (the derived-query search / read-gate) is not blocked upstream — logos shipped the searcher (logos-kernel). It is blocked because adopting it is a co-resolution problem: every core must link exactly one logos-contract and one ontos, the TS single-ontos-instance hazard applies, and the kernel arrives by three different delivery mechanisms (wazero/wasm in Go, native in Rust, bundled .wasm in TS). With no checked release, that bump is high-risk by hand; with one, it is "move to the next substrate release."

This concern passes the atlas membership test: it spans ≥2 systems and has no single-repo owner (ontos, logos, thesmos, stele each pin independently; "they must agree" lives in none of them).

A related but distinct axis is already in flight: #41 pins the infrastructure artifacts a leaf consumes from atlas/design (generated diagrams, the docs-toolchain, the render ABI) via a per-repo lockfile + digests + fan-out PRs. That is freshness of atlas's own outputs, not co-resolution of the substance stack. The two share machinery; they are not the same lockfile.

Why the original "hand-authored descriptor" framing was wrong

If atlas maintainers type 2026.06's coordinates into substrate.json, atlas becomes "the person who typed the versions" — a new authority with no ground truth behind it, the same drift-prone hand-copy 0002 abolished for edges. The fix is an ownership inversion: publishers emit facts, atlas assembles and enforces them, consumers adopt them. The descriptor is then a coordination artifact (a verified snapshot), not the source of truth.

The three truths

Co-resolution is not one claim; it is the agreement of three distinct facts, each owned by a different party. Naming them keeps atlas from overclaiming:

  • Publisher truth"these artifacts/coordinates were built and published from this source revision, with these substrate dependencies." Owned by each component publisher (ontos, logos, thesmos), emitted from its release workflow.
  • Consumer truth"this repo's direct manifests, and its resolved-graph evidence, use only the coordinates of release 2026.06." Owned by each consumer repo, asserted by its committed manifests/locks.
  • Atlas truth"atlas did not choose versions; it verified that publisher truth and consumer truth match a named, immutable release." Owned by atlas — the gate, not the vendor.

Decision

Add, to the same manifest/reconcile spine, a committed substrate release BOM (assembled by atlas from publisher-emitted facts) and a per-repo adoption check that every member runs in its own CI — reporting honestly by proof level.

The descriptor — topology/substrate.json (a committed release BOM)

The descriptor splits into a stable catalog (logical-name → ecosystem identity) and immutable releases (per-release coordinate facts). It is assembled and audited from publisher manifests, not hand-typed.

{
  "schemaVersion": 1,
  "catalog": {
    "ontos": {
      "typeIdentityCritical": true,
      "source": { "repo": "github.com/Bitspark/ontos" },
      "go": { "module": "github.com/bitspark/ontos" },
      "rs": { "crate": "ontos", "git": "https://github.com/Bitspark/ontos" },
      "ts": { "package": "@bitspark/ontos-core", "registry": "https://npm.pkg.github.com" }
    }
  },
  "releases": {
    "2026.06": {
      "status": "active",
      "immutable": true,
      "components": {
        "ontos": {
          "source": { "tag": "v0.1.4", "commit": "0123456789abcdef0123456789abcdef01234567" },
          "go": { "version": "v0.1.4", "sum": "h1:..." },
          "rs": { "ref": { "kind": "tag", "value": "v0.1.4" }, "resolvedCommit": "0123…4567" },
          "ts": { "version": "0.1.4", "integrity": "sha512-…", "gitHead": "0123…4567" },
          "substrateDeps": {}
        },
        "logos-contract": {
          "source": { "tag": "logos-contract/v0.1.0", "commit": "cdd57b46513e2758a78e59c1c5517bd69414099a" },
          "go": { "version": "v0.0.0-20260601061411-cdd57b46513e", "commit": "cdd57b46513e…" },
          "rs": { "ref": { "kind": "rev", "value": "cdd57b46513e…" }, "resolvedCommit": "cdd57b46513e…" },
          "ts": { "version": "0.1.0", "integrity": "sha512-…", "gitHead": "cdd57b46513e…" },
          "substrateDeps": {
            "ontos": { "go": "v0.1.4", "rs": { "kind": "tag", "value": "v0.1.4" }, "ts": "0.1.4" }
          }
        }
      }
    }
  }
}

The descriptor's shape encodes five hardening decisions:

  1. catalog owns the logical-name → ecosystem-name mapping (Go module path, Rust crate + git source, npm scoped package + registry), so "ontos means @bitspark/ontos-core" lives as data, not as hardcoded parser knowledge — and typeIdentityCritical flags the packages where a duplicate instance breaks instanceof / SPI type identity.
  2. Source identity is canonical; ecosystem coordinates are bindings. Each release row anchors on a full upstream commit plus the human-recognizable tag, then the per-language coordinate. A Go pseudo-version is a locator, not the identity.
  3. All coordinates are structured — no bare strings. Rust especially is { ref: { kind: "tag" | "rev", value }, resolvedCommit }: a bare "cdd57b46" or "v0.1.2" loses whether the manifest declared a tag or a rev, which the honest comparison needs. npm carries the scoped package name + registry + integrity; Go carries module + version (+ sum).
  4. Full source commits are canonical; short SHAs appear only in diagnostics. Signed tags and/or artifact-attestation references are optional publisher evidence. For artifact-delivered components (logos-kernel's .wasm), record artifact sha256 / SRI integrity, since source commit alone does not pin the built blob.
  5. Each component stores its own publisher substrate-dependency closure (substrateDeps). This is the bridge from a surface check to the type-identity hazard: if logos-contract was published against ontos@v0.1.2, a release row that also tells consumers to pin ontos@v0.1.4 is internally inconsistent and must fail before any consumer sees it.

The descriptor governs only the substrate (the Greek-named substance stack + its published contracts/kernels). It does not pin a repo's ordinary third-party dependencies; those are each repo's own concern. A package a repo does not yet consume (e.g. logos-kernel before stele#6) is simply present in the release and unreferenced by that repo — declared intent, the same way an edge with no backing dependency is "intent-only" under 0002, not an error.

Publisher manifests are the source of truth

Each component publisher emits a machine-readable release manifest (a file or release asset) recording its source commit, per-language coordinates, and its own substrate deps for a named release. Atlas imports those into the BOM and audits that the committed BOM is exactly what the publisher manifests imply. That ownership inversion — publisher repos emit facts; atlas assembles and enforces them; consumer repos adopt them — is the most important hardening move in this revision.

The manifest gains one optional field

A member's atlas.json may declare which release it targets:

{ "name": "stele", "...": "...", "substrate": "2026.06" }

Absent ⇒ the repo is not (yet) substrate-pinned; the check is a no-op for it (so adoption is incremental, exactly like the docs block under 0003).

The resolved-graph snapshot — .atlas/substrate.lock.json (optional → required)

A surface check on direct manifests cannot see transitive divergence (Go MVS selecting a higher version, an npm nested duplicate, a Cargo duplicate package id). To close that gap without making atlas a resolver, a generated, committed substrate-only resolved snapshot records the inputs (hashed) and the selected substrate packages per lane:

{
  "schemaVersion": 1,
  "release": "2026.06",
  "inputs": { "go.mod": "sha256:…", "Cargo.lock": "sha256:…", "package-lock.json": "sha256:…" },
  "lanes": {
    "go": { "selected": { "ontos": { "module": "github.com/bitspark/ontos", "version": "v0.1.4" } } },
    "rs": { "selected": { "ontos": [ { "crate": "ontos", "source": "git+…?tag=v0.1.4#0123…" } ] } },
    "ts": { "selected": { "@bitspark/ontos-core": [ { "version": "0.1.4", "integrity": "sha512-…" } ] } }
  }
}

The snapshot is generated in a non-offline workflow (atlas substrate resolve, which may run go list -m -json all, parse Cargo.lock / cargo metadata --locked, parse package-lock.json) but verified offline by recomputing input hashes and comparing the recorded substrate closure to the BOM. It starts optional (a substrate-pinned repo may have only direct pins) and becomes required once a release is generally adopted. Atlas may record resolver output; it must never compute a "best" version itself.

The check — atlas substrate check (per-repo, local; in each member's CI)

The check is one command with explicit sub-rules, and reports by proof level so it never overclaims:

substrate/release-known
substrate/direct-pin-match
substrate/no-direct-double-pin
substrate/publisher-closure-match
substrate/resolved-lock-present
substrate/resolved-lock-inputs-current
substrate/resolved-graph-match
{
  "check": "substrate",
  "proof": { "surface": "pass", "publisherClosure": "pass", "resolvedGraph": "missing" },
  "findings": [
    { "rule": "substrate/resolved-lock-missing", "severity": "warning",
      "message": "No .atlas/substrate.lock.json; direct pins match 2026.06, but resolved-graph co-resolution was not verified." }
  ]
}

The three proof levels — and what each can and cannot prove:

Proof level Offline in member CI? Proves Does not prove
Surface (direct manifests) Yes Direct substrate pins match the release; no direct double-pins; declared lanes point at one release Hidden transitive divergence (Go MVS, npm nested dup, Cargo duplicate id)
Publisher-closure (substrateDeps) Yes The release is internally coherent: each component's declared deps agree with the release's other rows Whether the consumer's actual resolver selected that closure
Resolved-graph (substrate.lock.json) Yes, once the snapshot exists The committed resolved substrate graph matches the named release Only as good as the snapshot's inputs and generation policy

A divergence (e.g. stele's current Go 07cf2ae vs Rust cdd57b46 for the same logical logos-contract) is a reported failure at the surface level, not silent. The publisher-closure level additionally catches the original blind spot — a repo pinning logos-contract correctly while it transitively pulls a different ontos — without running any language toolchain. The check reads only committed files; like every other atlas command it stays zero-dependency.

Command surface

atlas substrate import   --release 2026.06   # assemble/refresh the BOM from publisher manifests
atlas substrate audit    --release 2026.06   # verify the committed BOM == publisher manifests imply
atlas substrate plan     --to 2026.07        # print intended edits + hazards (dry-run)
atlas substrate apply    --to 2026.07 --write # rewrite direct pins + atlas.json (conservative codemod)
atlas substrate resolve  --write             # refresh .atlas/substrate.lock.json via language toolchains
atlas substrate check    --strict            # per-repo adoption check, reported by proof level

import/audit/resolve run where release truth is minted (atlas CI, publisher CI), where network/toolchains are allowed; check runs offline in every member's CI against the committed snapshot. apply is a conservative codemod: it preserves formatting, replaces only exact recognized entries, fails closed on ambiguous syntax, requires --force to rewrite unknown coordinates, and emits a unified diff under --check/dry-run — so it never silently stomps a deliberate fork or hotfix.

Releases are append-only and immutable

A published, active release's coordinates are never silently edited. A bad release is deprecated (a status change steering new adopters away) or yanked (a status change marking it unsafe); new facts are appended as a new release row. This is the registry-immutability discipline applied to the substance axis.

Roll-forward, ownership, delivery

  • Low-risk roll-forward is plan → apply --write → resolve --write → check, with a canary sequence: publisher release manifests → atlas import PR → stele canary PR (the tri-core stress case) → other consumer fan-out PRs → mark the release adoptable/active. A release that cannot update stele is not generally adoptable.
  • Reuse #41's rails. The BOM is a sibling lockfile on the substance axis; rolling a release out reuses #41's fan-out PRs (repository_dispatch / SHA-pinned reusable workflows) rather than reinventing propagation.
  • New gate alongside #47. atlas substrate check joins the dormant cross-repo gates (--reconcile, workflow check, constellation-sync) activated under #47 — wired token-guarded, dormant until adopted, then required.

Ownership — split by fact type, not "atlas" generically

Fact Owner Enforcement
Component source revision, artifacts, language coordinates, its substrate deps Publishers (ontos, logos, thesmos maintainers) Publisher release workflow emits a signed/attested manifest
Release-train assembly ("2026.06 exists") Substrate release captain / release group CODEOWNERS + CI on topology/substrate.json
BOM schema, the checks, proof-level honesty atlas maintainers Schema/check tests in atlas
Adoption in a consumer repo Consumer repo maintainers Fan-out PR + consumer CI
Emergency yanks/deprecations Release captain + affected component owner Append-only status change

The governance principle: publishers are accountable for truth; atlas is accountable for refusing unverifiable truth; consumers are accountable for adoption. A scheduled atlas-only atlas substrate audit --all-active re-verifies active releases against publisher manifests/tags/registry metadata over time.

The work that realizes this ADR

This ADR is the design-of-record; the implementation lands as sub-issues under #65:

  • #80 — the BOM descriptor schema (catalog + structured coordinates + substrateDeps).
  • #81substrate import / audit (assemble + verify the BOM from publisher manifests).
  • #82substrate check (the per-repo, proof-level adoption check).
  • #83.atlas/substrate.lock.json + substrate resolve (the resolved-graph snapshot).
  • #85 — governance (CODEOWNERS, owner split, append-only/yank discipline, scheduled audit).

Consequences

  • The descriptor is a verified coordination artifact, not a hand-typed authority. Atlas assembles the BOM from publisher facts and refuses unverifiable ones; it pins by checking, never by vending. That keeps atlas from becoming the next unowned convention.
  • The de-facto "match thesmos" convention becomes machine-checked, and the existing Go/Rust logos-contract divergence in stele becomes visible rather than latent — at the surface level, with the transitive blind spot closed at the publisher-closure and resolved-graph levels.
  • The check is honest about its reach. It reports surface / publisher-closure / resolved-graph separately and never claims full co-resolution from a direct-manifest pass alone.
  • stele#6 is de-risked: adopting logos-kernel becomes a substrate-release move with a guarantee all three cores advance together and none double-pins ontos — the hazard that makes the bump high risk today.
  • atlas stays what it is — maintainer/CI tooling, zero-dependency, never a runtime dependency; member CI stays offline (it consumes the committed snapshot).
  • 0002 is refined, not superseded — edges (existence) and substrate (version co-resolution) are two checks on the same atlas.json + manifest-of-record spine.
  • Adoption is incremental — repos without a substrate field are unaffected; the resolved-graph snapshot starts optional and becomes required as a release is adopted; members opt in one PR at a time, as with the docs block.

Alternatives considered

  • A central hand-authored version table (the original 0004 framing). Endorsed in paradigm, rejected in mechanism: hand-typed coordinates make atlas an unowned authority with no ground truth — the drift 0002 abolished for edges, re-introduced for versions. The BOM is assembled and audited from publisher facts instead.
  • One version number for the whole substrate. Impossible to honor: Go pseudo-versions, Rust revs, and npm semver share no version space. The per-language structured-coordinate map is the irreducible shape.
  • Let thesmos remain the implicit anchor ("pin whatever thesmos resolves"). The status quo: unowned, un-checked prose, already drifting across stele's lanes. (A machine-readable thesmos BOM that atlas imports is the explicit version of this and is compatible — but it is not sufficient alone, since consumers also depend on lower substrate layers directly.)
  • Atlas as a full cross-repo resolver / monorepo, or Nix/Bazel-style pinning. Far heavier than the problem; abandons the per-repo-ownership model 0002 is built on. Atlas verifies co-resolution and records resolver output; it does not compute it.
  • Dependabot / Renovate as the authority. Useful as update transport (grouped PRs) layered on top, but the unit they update must be the substrate release ID / the generated BOM — not independent per-ecosystem version bumps.
  • Fold this into #41's lockfile. Tempting (shared machinery) but conflates two axes: #41 pins atlas/design infra artifacts; this pins the substance stack. A member must be able to bump one without the other. Same rails, separate descriptors.

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

GitHub