Bitspark constellation
proposed source ↗

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.json requires go, rs and ts at both the catalog and coordinates levels, 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-publisher skips 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 rs exists 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.
  • assemble carries 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:

  1. readers — atlas accepts absent in the schema, verify-publisher and assemble; no emitter writes it;
  2. fleet pin bump — every member's pinned atlas can read it. The slow step, and where workflow sync's non-travel property (#791) bites;
  3. emitters — ontos writes release-facts.data-json.json with rs.absent;
  4. 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.

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

GitHub