ADR 0036 — a publisher face may be absent, but never silent
- Status: proposed
- Date: 2026-08-20
- Builds on: ADR 0008 (the publisher manifest and the fixed-version-line-per-component model that makes a face a first-class thing), ADR 0025 (content-addressing a manifest as an event — the digest this record must not perturb), ADR 0027 (the two-plane substrate; the component release as the identity unit)
- Relates: #796 (the ruling request, filed from ontos#223), #776 (a false coordinate in an immutable BOM, undetected because the gate passed green — the failure this ADR refuses to reopen), #767 (members' pinned atlas cannot read schema values that postdate their pin — the sequencing constraint), #775 (the gate verifies the manifest against itself)
Every face stays
required. A face may be satisfied by an explicit absence record instead of a coordinate.atlas-publisher-manifest.schema.jsonrequiresgo,rsandtsat both thecatalogandcoordinateslevels, so a component with no Rust crate cannot conform — and the only two answers on offer were to drop the component from the BOM or to make the keys optional. Both destroy information. Dropping it makes "deliberately not a substrate component" and "a substrate component whose face nobody registered" produce the identical observable: absence. Making the keys optional discards a live fleet-wide invariant — that a missing face is impossible — to accommodate one component. This ADR keeps the invariant and adds a way to say the thing that is true:{"absent": {"reason": …, "decidedIn": …}}. Non-goals: whether any particular component is a substrate component (that stays with its owning repo); fabricating coordinates for faces that do not exist (permanently off the table, per #776).
Context
@bitspark/ontos-data-json has a Go face (data/json/go) and a TS face (data/json/ts) and no
Rust crate — deliberately ungrown until a consumer names a need (ontos#218). It has been published
to npm since ontos#219.
No conformant release-facts file can exist for it, so ontos ships releases whose BOM omits a published package (ontos#223). The schema requires all three faces at both levels:
required @/properties/catalog: [source, go, rs, ts]
required @/properties/coordinates: [go, rs, ts]
Fabricating an rs coordinate is not an option and was never proposed: that is #776's exact shape — a
false coordinate entering an immutable BOM, undetected because the gate passes green.
Decision
Faces remain required. A face is satisfied by EITHER a coordinate OR an explicit absence record.
"coordinates": {
"go": { "version": "0.6.1", "sum": "h1:…" },
"rs": { "absent": { "reason": "ungrown", "decidedIn": "ontos#218" } },
"ts": { "version": "0.3.0", "integrity": "sha512-…", "gitHead": "…" }
}
decidedIn is required, not decorative. Without it the marker becomes the cheap way out of
growing a face; with it, every absence points at a decision someone can challenge, and the BOM becomes
answerable to which faces are absent, since when, citing what — a question it cannot be asked today.
Why not "drop the component from the BOM"
Because a classification you can only infer from silence is not recorded, merely uncontradicted:
| state | observable in the BOM |
|---|---|
| deliberately npm-only, not a substrate component | absent |
a substrate component whose rs face nobody registered |
absent |
One output for a decision and an oversight. That is the defect this repo has paid for twice in one month (#776, #775), and it is the same shape each time: an instrument whose negative result is indistinguishable from its positive one.
It is also wrong about time. The face here is ungrown, not ungrowable. Recording "npm-only" writes a temporary state into a permanent classification, and nothing prompts reclassification the day the crate appears.
Why not "make the per-face keys optional"
Because required is doing work right now: it makes a silently missing face impossible across
every member. Optional keys trade that live invariant for one component's convenience, and the loss is
fleet-wide while the gain is local.
The absence record keeps both properties at once — you still cannot forget a key, and you can now say a face does not exist.
What this does not decide
Whether data-json is a substrate component at all remains ontos's call. The point is that the
schema should stop deciding it: today a component without a Rust crate is classified npm-only by a
schema accident, not because anyone chose that. A schema must not make an architecture decision by
being unable to express the alternative. If ontos concludes data-json is a projection and does not
register it, that is then a decision on the record rather than a consequence of a required array.
Consequences
verify-publisherskips anchoring checks for an absent face explicitly, so the skip is reviewable rather than incidental. A face that is neither a coordinate nor an absence record stays a hard error — silence keeps failing.- The manifest event digest (ADR 0025) hashes the canonical body, so an absence record is part of the
component's identity, as it should be: a release where
rsexists and one where it does not are different releases and must not collide. - A new audit becomes possible: absent faces, with reasons and decision pointers, across the whole BOM. Absence stops being invisible.
assemblecarries the record into the BOM row unchanged. No BOM row ever gains a fabricated coordinate — #776 stays closed.
Sequencing — readers before emitters (#767)
Members' pinned atlas versions cannot read schema values that postdate their pin, so an emitter must
not produce absent before every reader can understand it. Same shape as superseded:
- readers — atlas accepts
absentin the schema,verify-publisherandassemble; no emitter writes it; - fleet pin bump — every member's pinned atlas can read it. The slow step, and where
workflow sync's non-travel property (#791) bites; - emitters — ontos writes
release-facts.data-json.jsonwithrs.absent; - only then does an absent face appear in a BOM row.
Between 1 and 3 nothing changes observably. That is the point: the window where a member could emit something its peers cannot parse never opens.
Status note
Proposed, not accepted — this changes the publisher-manifest contract and belongs under ratification rather than landing as a schema tweak. Nothing asks ontos to move before it is accepted; #796 records that the cost of waiting is one release omitting one package, and the cost of guessing is permanent.