ADR 0009 — the pin trinity: one per-repo lock + one pin explain for the design / atlas / substrate refs a repo runs against
- Status: accepted
- Date: 2026-06-04
- Refines: 0005 §4 (the toolchain-pin +
repository_dispatchpropagation rails) - Relates: 0006 (substrate channels), 0008 (the release model)
- Tracking: #140 · depends on #41/#92 (the per-repo lockfile + docs-toolchain bundle) and #47 (gate activation)
Three pins are accreting with no shared declaration and no shared question. A repo already pins which
designit draws against, and is about to pin whichatlas/docs toolchain it runs and whichsubstrateclosure it resolves to — three mechanisms, three places, three cadences. Nobody can answer "given repo X, which design + atlas + substrate should it run, and is it actually on them?" That unowned, un-queryable convention is the rotting-convention failure 0001/0004 fight, displaced one level up — into the toolchain pins instead of the versions.
Context
Three independent pinning mechanisms are emerging, each added for its own axis:
- design — the
DESIGN_REFSHA pinned in atlas'sci.yml(#125, live): the exactdesigncommit the constellation figure is generated and figure-linted against. atlas adopts a newdesigndeliberately, so a movingdesignmaincan't red an unrelated PR. - atlas / docs toolchain — the per-repo lockfile + pinned docs-toolchain bundle
(
.atlas/lock.json, #41/#92, deferred): whichatlasCLI + docs toolchain a repo regenerates its cross-repo artifacts with, so figure/doc generation is reproducible and not "whatevernpxfetched today." - substrate — the
substratesubscription in a repo'satlas.json(a release id under 0004; a channel name under 0006, partly live): which version closure the repo's lanes co-resolve to.
Each was justified on its own — figure freshness, toolchain reproducibility, version co-resolution — and none is wrong. But there is no unified declaration (the three pins live in a CI env var, a not-yet-built lockfile, and a manifest field) and no unified query: nothing prints, for one repo, the trinity it should run and whether it is on it. That is two distinct gaps — a declaration gap and a diagnostic gap — and left unaddressed they reproduce the failure these ADRs exist to kill: a cross-repo invariant ("a repo runs a coherent (design, atlas, substrate) triple") that no file owns and no check enforces, so it drifts silently. The membership test holds (this spans ≥2 systems and has no single-repo owner), and #140 sanctions a dedicated pinning ADR; this is it.
Decision
Treat the three pins as one trinity, declared in one generated lock and answered by one diagnostic, kept coherent by the same fan-out that already moves substrate.
1. Name the trinity
A repo pins along three axes, and only three: design (the look & read toolchain it draws against), atlas (the build & relate toolchain it regenerates with), and substrate (the version closure it resolves to). "Which (design, atlas, substrate) does repo X run?" is the one question; the three pins are its three coordinates.
2. One generated lock is the single source of truth
The per-repo .atlas/lock.json (the lockfile #41/#92 introduce) carries all three resolved
pins, not just the docs toolchain: the resolved design ref + its input digest, the atlas/
docs-toolchain digest, and the substrate channel → resolved release id + closure digest.
The lock is a generated fact (the artifact-policy class):
atlas lock write resolves the repo's declared subscriptions into it, --check drift-gates it,
and it is never hand-edited — it sits beside constellation.json and .docs-manifest.json as
generated, not authored, state.
The split is deliberate: the subscription stays declared and human (the substrate channel
name in atlas.json; a repo's opt-in to design/atlas toolchain tracking); the resolution
of those subscriptions into concrete digests is generated into the lock. A human names a
channel; only resolution names a SHA.
3. One diagnostic answers the question
atlas pin explain --repo <name> prints the trinity for one repo: each axis's declared
subscription, its resolved pin in the lock, and the actual state (on/off-channel,
fresh/stale, lock-vs-live drift). With no --repo it is the constellation dashboard — every
member's triple, the way substrate conformance already reports wiring. atlas pin check is
the gate form (nonzero on drift), wired token-guarded into member CI alongside the dormant
gates (#47). The diagnostic gap closes with a
command, not a doc.
4. One fan-out keeps the three coherent
The pins do not update on three independent cadences. The same repository_dispatch +
SHA-pinned reusable-workflow rails that advance a substrate channel
(0005 §4 / 0006 §5)
advance the design and atlas/toolchain pins too: whichever axis moved opens one auto-bump
PR that rewrites the lock, gated on the consumer's own pin check. "Which design/atlas/substrate
runs everywhere" becomes a coordinated, auditable move, never three hand-bumps that can disagree.
5. The lock is schema'd — no new unowned surface
.atlas/lock.json is a declaration file like the rest, so it gets a schema and joins
the declaration taxonomy; fold its schema into the
schema-gap work (#176) rather than shipping a
fourth code-only-validated file.
Consequences
- One place to read, one command to ask. "What does this repo run?" is the lock; "is it
current?" is
pin check/pin explain. The trinity stops being tribal knowledge spread across a CI env var, a manifest field, and a toolchain bundle. - It depends on the unbuilt pieces, by design. The lockfile + docs-toolchain bundle
(#41/#92) and the gate activation (#47) are deferred; this ADR's value is that they get built
unified — one lock carrying three axes, one
pinsurface, one fan-out — instead of a third silo bolted beside the first two. It sequences them, it does not pre-empt them. - Shared bot/trust surface with 0006. Auto-bumping the toolchain pins rides the same scoped fan-out identity that advances substrate channels; named here so #41/#47 build that trust surface once, for all three pins, gated on the consumer's own check (never a self-attestation).
- atlas dogfoods first. Today's atlas-only
DESIGN_REFenv var (#125) is pin #1's bootstrap; this generalizes it into atlas's own lock entry before any rollout, so atlas runs the model it ships — the same discipline 0006 applies to the member wiring. - A real, bounded ongoing cost. A per-repo lock file and a
pincommand surface are new surface; but single-sourced (one generated lock, one command, one fan-out), the cost is bounded and is the price of the convention being owned instead of evangelized.
Alternatives considered
- Document the three pins, don't unify them. A
docs/page explaining the trinity. Rejected as the whole answer: prose can neither resolve the query nor gate drift — the convention still rots between reads. (The model is now documented indocs/substrate.mdanddocs/model.md, but documentation is the map, not the mechanism.) pin explainas a pure query over the three existing locations, no unified lock. Half the value: a query is worth shipping, but without one resolved lock the three pins still advance on three cadences and can disagree between query runs. The lock is what makes "current" a well-defined, gateable fact rather than a momentary snapshot.- Fold all three pins into
atlas.json. Rejected: resolved digests are generated facts, not editorial judgment, so they belong in a generated lock besideconstellation.json— not in the hand-authored manifest.atlas.jsonkeeps the human subscription (the channel name); the lock holds the resolution. - Per-axis lockfiles (
.atlas/design.lock,.atlas/substrate.lock, …). Rejected: that is the three-silo problem with more files. One lock + one fan-out is the point.
The work that realizes this ADR
Deferred to the #41/#47 convergence cycle, tracked under #140, built unified from the start:
.atlas/lock.jsoncarrying all three axes + its schema (with #176), andatlas lock write|check(#41/#92) extended past the docs toolchain to the design ref and substrate channel→release.atlas pin explain --repo <name>(the per-repo + dashboard diagnostic) andatlas pin check(the drift gate, activated with #47).- The design/atlas toolchain pins join the substrate fan-out — one auto-bump PR for whichever axis moved, gated on the consumer's own check (#41/#47, the 0006 §5 rails). The atlas SENDER half is live (#182):
atlas dispatch pin-plancomputes the design + atlas bump from atlas's own committed.atlas/lock.json(the trinity authority, #255), and thepin-dispatch.ymlcaller hands thebitspark.pin.bumpedpayload (a newkind:"pin"branch of the shared dispatch-payload schema) to the ONE reusable_dispatch.yml— reusing, not duplicating, the substrate fan-out family. The member-side RECEIVER that applies the bump (rewrites its lock + auto-PRs, gated onpin check) is the #239-style member adoption, tracked as the #182 follow-up. - Generalize atlas's own
DESIGN_REF(#125) into its lock entry — atlas runs the trinity model before it asks any member to.