A second-level domain's capability catalog lives in its provider

Proposed

ontoref
ADR-073 gave a bond one extension point: `capabilities`, typed as 'reference ids into the

Context

ADR-073 gave a bond one extension point: `capabilities`, typed as "reference ids into the endpoints' domain-root catalogs, never inline definitions". The referent was added to the domain contract later — `domain_capability_type` in code/domains/schema.ncl — and `bond validate` resolves a citation against it, so a typo stops passing as a capability.

The resolver is one line, and it decides everything:

reflection/modules/bond.nu :: domain-capability-ids let manifest = ([$env.ONTOREF_ROOT, "domains", $domain, "domain.ncl"] | path join) if not ($manifest | path exists) { return null }

`$ONTOREF_ROOT/domains/` is FLAT. Every domain in it reads as a sibling of every other, and for a first-level domain that is true: rustelo declares `domain_provides = { id = "rustelo" }` and has no `domain_origin`, so it is a root and ontoref hosting `domains/rustelo/` — with `schema_cmd` pointing the schemas back at the project — says exactly what is the case.

It is false for a domain an IMPLEMENTATION provides. website-htmx-rustelo declares BOTH fields: `domain_origin = { id = "rustelo" }` and `domain_provides = { id = "htmx-site" }`. Its own manifest states the shape — "domain_origin looks up the chain, domain_provides looks down it" — so `htmx-site` is a level below `rustelo`. There is nowhere for its catalog to live: the flat directory cannot hold it without asserting a hierarchy the model does not have, and the resolver looks nowhere else. `bond validate` therefore answers, permanently:

severity: unverifiable msg: "htmx-site/ontoref-to-htmx-site: cites capabilities but domain 'htmx-site' is not hosted here — catalog unresolvable"

The severity is correct and deliberate — an unresolvable id is an error, an unhostable domain is reported and does not gate — and the two carriers citing five capabilities have been in that state since 2026-07-18 with no harm done. The harm was second-order, and it arrived on 2026-08-10: an agent in session read the direction report as a defect, created `code/domains/htmx-site/` so the resolver could look, installed it, and the report went from one finding to `[]` with nothing verified. Nothing went red. Case file 0/5. Reverted, and `.pre-commit-config.yaml#domain-level` now refuses that operation by testing `domain_origin` on the provider.

That gate refuses the wrong place. It does not know the right one, and says so. This ADR is the other half.

FOUR MEASURED INSTANCES, 2026-08-11, because the enum grows by evidence and not by speculation:

rustelo → website-htmx-rustelo → three consumer sites the case above. Provider declares both fields; catalog unresolvable.

librosys → DD7pasos → five publication targets ('PrintPDF 'DigitalPDF 'EPUB 'WebHTML 'Scribus) the bond carrier sits at the repo root while ONE of five formats consumes the domain. A different shape, and this ADR does not close it — see the scope note below.

provisioning → libre-wuji → infra the workspace declares `domain_origin` and no `domain_provides`, so its infras have nothing to attach to. The absent declaration is upstream of the catalog question.

personal-ontology → jpl/personal → three surfaces the chain matches cleanly on ids and carries no bonds at either end.

Decision

A domain declared through `domain_provides` hosts its capability catalog IN ITS PROVIDER, and the resolver reaches it through the project registry — the same indirection `schema_cmd` already uses for three of the five hosted domains.

Two mechanisms, both additive:

1. `domain_provides` gains an OPTIONAL `capabilities` array, of the same `domain_capability_type` the domain contract already defines. Empty is a legal declaration and stays the default: it means "this domain declares no addressable capabilities yet", which is exactly what the domain contract already says about an empty catalog, and a bond citing one then fails validation — the correct reading, since the citation resolves to nothing.

2. `domain-capability-ids` gains a SECOND lookup, consulted only when the first finds nothing: resolve the domain's provider through the registry (the project whose `domain_provides.id` matches), read its catalog. `null` — and therefore the `unverifiable` severity — is reserved for the case where neither lookup finds a host, which is now a real finding rather than a structural certainty.

The flat `domains/` directory keeps its meaning unchanged: it hosts FIRST-LEVEL domains, whose provider is a root. That invariant is already enforced, and this ADR does not restate the gate so much as give it something to point at when a reader asks where the refused thing belongs.

WHAT THIS DOES NOT DECIDE, stated in the decision rather than buried in consequences. The librosys instance above is not a catalog problem: its bond carrier is repo-scoped while the consumption is TARGET-scoped, one publication format among five. That is a relation the model cannot express at all — project↔domain is typed, target↔domain is not — and it belongs to a different plane from the one this decision moves on. Folding it in here would collapse two questions into whichever answer fits the smaller one.

Constraints

  • Hard `domain-capability-ids` consults the provider when the flat directory has no entry for the domain: it resolves the project whose `domain_provides.id` matches and reads its catalog, rather than returning null on the first miss.
  • Hard `domain_provides.capabilities` defaults to an empty array. A provider that declares no capabilities stays valid, and its manifest needs no edit.
  • Hard No domain provided by a project that itself declares `domain_origin` is hosted in code/domains/. The gate that enforces this stays wired into the commit path.
  • Soft The `unverifiable` severity is reserved for a domain neither lookup can host. It must not be the standing answer for a whole class of domain.

Alternatives considered

  • Nest the authority: code/domains/<root>/<sub>/domain.nclrejected: It expresses the level correctly and puts it in the wrong repository. The authority of a domain an external implementation provides would live inside ontoref's tree, which is the exact move case 0/5 is about — the flattening was wrong about the shape, this is wrong about the owner. It also breaks the dispatch path, which is flat by construction: the wrapper reads domains/<first-arg>/repo_kinds.txt with a grep and no walk, chosen for sub-millisecond dispatch.
  • Keep the flat directory and add a `parent` field to domain_typerejected: The directory would still READ as siblings on every listing, so the field would be a correction a reader has to already know to look for. And it duplicates `domain_origin`, which ADR-081 has just established as the single scalar path for exactly this relation — a second way to say the same thing is the second-reader anti-pattern that ADR closed.
  • Leave it: bonds to second-level domains stay unverifiablerejected: Rejected, but it is the alternative that deserves the most care, because for three weeks it was the state of the world and nothing was harmed by it. `unverifiable` does not gate, and a system that says 'I cannot look at this from here' is behaving correctly. What tips it is not the finding, it is the pressure: the amber has only one exit, and case 0/5 measured what a reader does with that. The argument for acting is second-order and this ADR should not pretend otherwise.
  • Move the whole domain contract into the providers and empty code/domains/rejected: Symmetric, and it throws away the case the flat directory gets right. framework co-locates its schemas there and three domains are hosted there with schema_cmd; nothing about first-level domains is broken. A decision that fixes the second level by dismantling the first is a wider blast radius for no measured gain.

Anti-patterns

  • Flatten until the check can look — A check reports that it cannot see something from where it stands. The object is then moved onto the plane the check looks at, and the report goes quiet. Nothing turns red — the collapse produces silence, not an error — and the resulting green is believed precisely because no gate objected. The move is indistinguishable from a fix at the moment it is made, and distinguishable afterwards only by asking what was actually verified.
  • One answer stretched over two planes — Several instances share a symptom and are treated as one problem, so the decision that fits the smaller of them is applied to all. Here: three chains need a second-level declaration to resolve, and a fourth needs a relation the model cannot express at all (target-to-domain, not project-to-domain). A decision covering all four would have been the smaller answer wearing the wider claim.

Related ADRs

ADR-012 · ADR-018 · ADR-045 · ADR-073 · ADR-076 · ADR-081

Was this useful? Rate it
Got something to add? Tell me what you think, what you'd suggest, or whether we should keep exploring this topic.
· reads

We use cookies to help this site function, understand service usage, and support marketing efforts. Cookie Policy for more info.