One authority for domain membership, and the provenance travels with it
Accepted
Context
ADR-073 gave membership a carrier. `.domains-ontoref/<domain>/bonds.ncl` is declarable without ontoref managing the project, its cardinality is uncapped, and the ADR's own anti-pattern list names the scalar cap it replaced: "membership is inferred from the project's single `repo_kind` ... not because anyone decided a project may bond to only one domain, but because the identity field happens to be scalar."
Its second Hard constraint gates the detector — `reflection/modules/domain.nu` must contain `bonded_dir` — and the detector holds. `project-domains` returns a record per membership, each TYPED by how it was found: `config_flag | domain_origin | impl_dir | bonded_dir`. The uncapped union the carrier promised exists, is exported as `domain consumers`, and works.
There is a second enumerator. `reflection/modules/describe.nu` :: `find-domain-implementations` (line 3887) is what the provider-facing surface calls — `describe domain`'s KNOWN IMPLEMENTATIONS block. It parses `projects.ncl` textually, resolves each spine, and compares exactly one thing:
let orig_id = ($mdata.domain_origin? | default {} | get id? | default "") if $orig_id != $domain_id { return null }
It never opens a carrier. So a domain whose consumers declared themselves the way ADR-073 prescribes reads, on the surface built to show them, the sentence "No registered implementations found in ~/.config/ontoref/projects.ncl".
Measured 2026-08-08, on a domain declared that same week:
ontoref domain consumers htmx-site -> 2 consumers, both kind=bonded_dir ontoref describe domain (htmx-site) -> "No registered implementations found"
Same machine, same registry, same instant. The registry is not the problem and was mistaken for it once during the investigation: `projects.ncl` carries 13 entries and resolves cleanly once `~/.config/ontoref/schemas` is on the import path. The control confirms the rest of the path is sound — from rustelo, `describe domain` reports "KNOWN IMPLEMENTATIONS 1 registered: website-htmx-rustelo", because that project declares `domain_origin.id = "rustelo"` and the scalar match happens to hit.
Why the scalar match cannot simply be widened in place: `domain_origin` is load-bearing AS A SCALAR elsewhere. `load-framework-hard-constraints` resolves a project's inherited Hard constraints against `domain_origin.path`, and `nickel-import-path` resolves against the same root. There is exactly one upstream whose contracts a project inherits, and turning that field into a list would break the resolution it exists for. The live case shows why this is not hypothetical: DD7pasos declares `domain_origin.id = "librosys-framework"` — its editorial upstream, whose constraints it does inherit — while its `outreach/site` member consumes htmx-site. Both memberships are true. Only one of them inherits constraints.
describe.nu's function carries, in its own comment, the diagnosis of an identical failure it already survived: a `return null` on a missing manifest rendered as "the perfectly ordinary sentence ... A provider reading it has no way to tell 'none exist' from 'none were looked for'." That was fixed for the spine path. The same shape survived one field over, on the reading side, in the same function.
Decision
`project-domains` (reflection/modules/domain.nu) is the SINGLE AUTHORITY for "which domains does this project belong to". Every surface that answers a membership question — `describe domain`'s implementations block first, and any future one — consults it. `find-domain-implementations` stops reimplementing a narrower match and becomes a caller.
The union it returns stays TYPED. A consumer found by `bonded_dir` is not reported as the same thing as one found by `domain_origin`, because it is not: only the latter inherits the provider's Hard constraints, and only the latter has a resolvable framework root. Provenance travels with every row and is rendered, not flattened into an undifferentiated list of "implementations".
`domain_origin` stays scalar and keeps its meaning: THE upstream whose contracts, schemas and Hard constraints this project inherits and resolves against. It is not widened, not deprecated, and not made redundant by the carrier. The two fields answer different questions, and this ADR is the statement that they do.
The manifest schema is not touched. ADR-073's third Hard constraint — `manifest.ncl` must not mention `bond` — remains in force, and this decision is compatible with it by construction: the carrier stays orthogonal to the manifest, and the reconciliation happens in reflection, where reading lives.
An empty result must be distinguishable from an unasked question. A surface reporting no implementations reports WHICH provenances it consulted, so "none exist" and "none were looked for" stop rendering as the same sentence — the correction this function's own comment already made once, applied to the failure mode that replaced it.
Constraints
- Hard describe.nu must not carry its own membership match: `find-domain-implementations` resolves membership through domain.nu's project-domains, never by comparing domain_origin.id itself.
- Hard Any surface rendering domain membership carries the provenance kind with each row; a list of implementations with no kind field is the flattening this decision forbids.
- Hard `domain_origin` remains a single record in the manifest schema. Widening it to an array is the alternative this ADR rejected, and doing so would break the single-root resolution that load-framework-hard-constraints depends on.
- Soft A surface reporting no domain implementations states which provenances it consulted, so 'none exist' is not rendered identically to 'none were looked for'.
Alternatives considered
- Make `domain_origin` an array so a project can declare several origins — rejected: It is the constraint-resolution path. load-framework-hard-constraints and nickel-import-path both resolve against exactly one root; a list forces an arbitrary pick or a merge of contract surfaces nobody declared compatible. It would also make every existing manifest a migration, to express something the carrier already expresses without one — raising adoption friction to reach a formalization already available.
- Leave the two enumerators and teach find-domain-implementations to read carriers too — rejected: It fixes this instance and preserves the cause. Two readers of one declaration diverged once silently; a second reader taught the same lesson is still a second reader, and the next rule added to project-domains reproduces the gap.
- Report the union flat, without provenance — rejected: It would claim that a bonded consumer and a domain_origin implementation stand in the same relation to the provider. They do not — only one inherits Hard constraints. This trades a silent omission for a confident misstatement, which is worse.
- Record the finding in the backlog and change nothing — rejected: bl-054 was that, and re-reading it produced this ADR because the backlog entry's own framing was wrong: it said the two surfaces disagree about cardinality. They do not. domain.nu already implements the uncapped typed union; the disagreement is between two readers, and that is a decision about authority, not an item to schedule.
Anti-patterns
- A second reader of the same declaration — One rule, two implementations that read the same declared data. A constraint gates one of them, reports compliance, and the other diverges underneath — invisibly, because the validator is green and the surface is merely quiet. ADR-073 gated domain.nu's carrier reading; describe.nu read past it for as long as both existed.
- A flattened union asserts an equivalence nobody declared — Merging results found by different means into one untyped list tells the reader they stand in the same relation. Here a bonded consumer would appear to inherit Hard constraints it has no resolvable path to — trading a silent omission for a confident misstatement.
- Widening the field that is correctly narrow — Reading 'the scalar is too small' off a symptom whose cause is elsewhere, and enlarging a field whose singularity is load-bearing. domain_origin is scalar because exactly one upstream's constraints are inherited and resolved against; an array breaks that resolution to express something the carrier already expresses.
Related ADRs
ADR-018 · ADR-029 · ADR-032 · ADR-045 · ADR-070 · ADR-072 · ADR-073 · ADR-078