Catalog Discovery Cross-Project — Tier-2 Ops Beyond the Piloto Self-Host
Proposed
Context
ADR-029 commits the protocol to permanent tier coexistence: any project may climb to tier-2 (operations adopted) voluntarily. The piloto self-host (ontoref itself) demonstrated tier-2 functionally during the session of 2026-05-26 — 10 domain operations registered, dispatched, witnessed, replay-deterministic. The piloto works because its operations are compiled into the `ontoref-daemon` binary via `inventory::collect!` at link time: `crates/ontoref-ops/src/ops/*.rs` annotated `#[onto_operation]` emit static `OperationEntry` records; `dispatch_op(id, …)` resolves against `inventory::iter::<OperationEntry>()`.
This works because the piloto and the daemon are co-developed in the same repository — the daemon binary that serves the piloto IS the binary compiled with the piloto's catalog baked in. The 18 other onboarded projects (mirador, build-in-layers, lian-build, vapora, stratumiops, kogral, libre-{daoshi,wuji,forge}, secretumvault, typedialog, jpl-website, personal-ontoref, jpl-ontoref, cloudatasave, DD7pasos, librosys, forge-fleet, mina) sit at tier-0 or tier-1; their adoption of tier-2 would surface a structural gap that the current architecture does not address:
When project P climbs to tier-2, where do P's domain operations live, and how does the daemon discover and invoke them?
Today's only answer — "compile P's ops into the daemon binary and ship that binary to P" — does not scale. It implies a per-project daemon build, breaks the "one daemon, mixed-tier projects" model that ADR-029's `ontoref-multi-tier-management` QA describes, and ties the project's release cadence to the daemon's. For the piloto self-host this is acceptable (ontoref's release IS the daemon's release); for any other project it is not.
The catalog has two sides per ADR-029:
- DECLARATIVE side (NCL): `catalog/operations/<id>.ncl` and `catalog/validators/<id>.ncl`. Per-project, in the project's own repo. Typed against `catalog/schema.ncl` (the protocol-wide contract). - EXECUTIVE side (Rust): the `#[onto_operation]`-annotated functions + `#[onto_validator]`-annotated functions. Today baked into the daemon binary via inventory.
The declarative side composes cleanly across projects — ADR-028 already content-addresses ontologies; the catalog NCL files could be published the same way (as Type-2 or Type-3 ontologies in `_ontology_refs.ncl`), discovered via `fetch_cell_witness`, type-checked locally. The executive side is the harder problem: how does the daemon, running once per host and serving N projects of mixed tiers, obtain and invoke the Rust handlers for project P's ops?
The session that produced ADR-029 surfaced this open question explicitly: "the daemon binary that hosts ontoref-piloto has the 10 piloto ops linked at compile time. When lian-build, provisioning, or any other project climbs to tier-2 and authors its own catalog, the inventory mechanism does not extend — those projects' ops are not in the daemon's binary." The piloto demonstrates that tier-2 works; this ADR acknowledges that scaling tier-2 across the ecosystem needs a discovery mechanism beyond link-time inventory.
No external pressure yet justifies picking a mechanism. Only ontoref itself is at tier-2. The remaining 18 projects have not signalled tier-2 ambitions. This ADR records the problem, the design space, and the trigger-based decision pattern (consistent with ADR-026 / D15 commitment backend deferral, ADR-027 / D18 sync backend deferral, ADR-029 / D23 blob backend deferral) — IMPLEMENT WHEN TRIGGERED, not before.
Decision
The catalog-discovery problem is real, named, and deferred behind explicit trigger thresholds. The architecture reserves the abstraction point and documents the design space so a future spike can land an impl with minimum protocol disruption.
ABSTRACTION POINT — `CatalogBackend` trait (future)
A future trait in ontoref-ops or a new crate ontoref-catalog:
pub trait CatalogBackend { fn discover_ops(&self, project: &ProjectContext) -> Vec<OperationEntry>; fn discover_validators(&self, project: &ProjectContext) -> Vec<ValidatorEntry>; }
The default impl `InventoryCatalogBackend` returns only the link-time-registered entries (what the daemon does today). Future impls extend the surface:
- DynLibCatalogBackend — loads .so/.dylib per project - WasmCatalogBackend — loads WASM modules per project - SidecarCatalogBackend — proxies dispatch to a per-project process - InterpretedNclBackend — executes NCL-declared ops in-process - CompositeCatalogBackend — combines the above
Each impl pairs with documented spike triggers (see below).
DESIGN SPACE — five candidate mechanisms
1. CompiledIntoDaemon (current state) The project's ops are compiled into the daemon binary. The project ships its own daemon build, OR the project's ops live in a workspace crate that the daemon includes as a dep. Pros: full Rust type safety; zero runtime overhead; replay determinism preserved trivially. Cons: does NOT scale beyond 1-2 projects co-developed with the daemon; binary distribution per project; impossible cross-version. Status: status quo for the ontoref piloto; not a general solution.
2. DynamicLibraryLoading (libloading / dlopen) Project compiles `.so/.dylib`; daemon loads it at startup via `libloading`. The library exposes a C-ABI surface that returns `OperationEntry` records. Pros: native Rust speed; the entries integrate naturally with inventory after a one-time registration call. Cons: ABI brittleness across Rust compiler versions; platform- specific paths; security boundary is thin; symbol versioning is a runtime hazard. Trigger: 2+ projects at tier-2 + they're willing to compile against a pinned Rust toolchain matching the daemon's.
3. WasmComponents (component model) Project compiles ops to WASM components; daemon loads them via wasmtime/wasmer with the WIT interface. Pros: portable across platforms; security sandbox; project's choice of source language (Rust, AssemblyScript, others); ABI is the component model, not Rust's. Cons: WASM build pipeline overhead; need a WIT surface for OperationEntry + Slice + ValidationCtx + Verdict + OpOutput (significant interface design); slight runtime overhead. Trigger: 2+ projects + diverse host platforms + security sandboxing required (untrusted ops authors). Note: ADR-026 mentions WASM as candidate for tier-0 Structural validators. The same infra could host operations.
4. SidecarProcess (per-project op process) Each tier-2 project runs its own ops process (binary or interpreter); daemon proxies `dispatch_op` calls via IPC (Unix socket, gRPC, named pipe). The sidecar handles its own catalog. Pros: process isolation; per-project deploy cycle independent of daemon; language-agnostic (sidecar can be Rust, Go, Python, …). Cons: extra process management; IPC latency on hot path; witness signing crosses a process boundary (security review needed); multiple processes per host complicates packaging. Trigger: projects need independent op deploy cadence + IPC overhead is acceptable per dispatch.
5. InterpretedNclOps (NCL bodies executed in-process) The op body is declared as an NCL expression (or small DSL) in the catalog/operations/<id>.ncl file. The daemon interprets it against `ctx` and `inputs`. No Rust handler required. Pros: zero deploy overhead; ops author entirely in NCL; cross- project ops trivially shareable as content-addressed ontologies. Cons: loses Rust type safety; need an expression evaluator + safe sandbox; performance lower than native; complex ops (signature schemes, blob hashing) hard to express. Trigger: most tier-2 ops in the ecosystem turn out to be small state-mutation verbs that need no native code, and the cost of expressing them in NCL is less than the cost of compiled-per- project handlers. Note: this is the most aligned with "voluntary-adoption, minimal friction" if the expressive power suffices.
6. SidecarProcessWithSharedDomainLibrary Specialisation of #4 (SidecarProcess) aligned with ADR-018 level hierarchy. The domain (Level 2, e.g. provisioning) ships a Rust LIBRARY crate `<domain>-ops` containing the generic `#[onto_operation]` handlers. Each instance (Level 3, e.g. libre-daoshi / libre-wuji / libre-forge) compiles its OWN sidecar BINARY that depends on the domain library AND adds instance-specific ops. The ontoref main daemon proxies dispatch to each instance's sidecar via HTTP.
Convergence/divergence split: CONVERGE (single source of truth) - Catalog NCL declarations (shared at domain level via ADR-028) - Rust handler code (one `<domain>-ops` library crate) - catalog/schema.ncl validations (protocol-wide) DIVERGE (per-instance) - Sidecar binaries (each instance compiles its own) - Deploy cadence (independent per instance) - Signing keys per actor per instance - Instance-specific ops (overrides + extensions) - Sidecar process per host per instance
Maps onto ADR-018 mode resolution semantics: Delegate → instance uses the domain handler unchanged Override → instance provides its own handler for the same op id Compose → instance wraps the domain handler with extra logic
Pros: ZERO new code in the daemon beyond a ~80-LOC proxy handler; full Rust type safety for handlers (lib path/git dep); per-instance deploy independence; blast radius isolated per instance; instances can climb to tier-2 voluntarily without forcing siblings; signing keys stay per-instance per-actor (no shared cryptographic boundary); replay determinism preserved. Cons: N processes per host where N = number of tier-2 instances; Rust toolchain coupling between domain library and instance sidecars; HTTP IPC overhead per dispatch (~50-500µs measured); cross-instance ops require IPC chain (instance A → main daemon → instance B's sidecar). Trigger: domain with multiple Level-3 instances reaches tier-2 AND deploy cadences are independent AND HTTP IPC overhead is acceptable. provisioning (with libre-daoshi, libre-wuji, libre-forge as instances) is the canonical example. Note: this is the RECOMMENDED near-term path for shared-domain tier-2 adoption — implementable today, no WASM spike required.
7. CompositeBackend (mix of the above) Daemon loads multiple backends in priority order; an op lookup consults each backend until one returns a match. Lets ontoref-piloto keep its CompiledIntoDaemon backend AND add a second backend for external tier-2 projects. Pros: incremental migration; the piloto doesn't have to change to accommodate new projects; the abstraction holds even with one backend. Cons: dispatch lookup is O(N backends); priority semantics need clear documentation.
CHOSEN MECHANISM — tiered selection by trigger
The 7 mechanisms split into two phases of the design space:
Phase A — implementable today, no spike required: 1. CompiledIntoDaemon — current piloto self-host 6. SidecarProcessWithSharedDomainLibrary — RECOMMENDED for shared-domain tier-2 (provisioning, etc.) 7. CompositeBackend (combines 1 + 6)
Phase B — requires a spike with documented trigger conditions: 2. DynamicLibraryLoading — Rust ABI brittleness 3. WasmComponents — WIT interface design + runtime cost 4. SidecarProcess (raw) — base case of #6 without domain lib 5. InterpretedNclOps — NCL expressive power proof needed
For Phase A mechanisms, the protocol does NOT defer — implementation may proceed when a project commits to the pattern. Mechanism #1 is already in production (the piloto). Mechanism #6 unblocks any shared domain (provisioning + its Level-3 instances) at the cost of ~80 LOC proxy handler in ontoref-daemon + path/git dep for the domain library.
For Phase B mechanisms, the protocol defers behind explicit triggers:
T1. THREE OR MORE projects (besides ontoref) author tier-2 catalogs AND find Phase A mechanisms operationally insufficient. T2. The ontoref piloto's catalog grows beyond ~30 ops to a size where the inventory pattern shows cracks (compile-time startup, debug builds, hot-reload friction). T3. A consumer project requests cross-project ops dispatch (op A in project P invokes op B in project Q) — IPC chain in mechanism #6 becomes the bottleneck. T4. Toolchain divergence — an instance wants ops in a non-Rust language; dynlib / WASM is the only way. T5. Untrusted ops authors — the deployment must sandbox ops from outside the instance's control; WASM is the natural fit.
When ANY trigger fires for Phase B, a session is opened to: 1. Audit which of the Phase B mechanisms (2, 3, 4 raw, 5) fits the empirical constraints (host platforms, security model, deploy cadence, language requirements). 2. Define the WIT / C ABI / NCL DSL surface as appropriate. 3. Implement the chosen backend behind the `CatalogBackend` trait. 4. Document the new backend's spike triggers (mirroring the ADR-026 / D15 spike-triggers pattern).
This ADR commits the abstraction point and the design space. It does not commit the implementation. The same trigger-based discipline that applies to CommitmentBackend (D15), SyncBackend (D18), CrdtMergeStrategy (D19), and BlobBackend (D23) extends to CatalogBackend (D30).
DECLARATIVE SIDE — content-addressed catalogs as ontologies
The declarative side (`catalog/operations/<id>.ncl`) composes trivially via ADR-028's content-addressed ontology layer. A project's catalog NCL files can be published as a Type-2 or Type-3 ontology; consumer projects subscribe to that ontology and gain typed knowledge of "ops project P exposes". This works TODAY at any tier — the catalog declarations are inert at tier-0/1 (declarative documentation only) but compose cross-project. The executive-side gap is what this ADR defers; the declarative-side composition is already operational.
CROSS-TIER COMPOSITION (preserved by design)
Tier-0 / tier-1 projects can declare their own `catalog/operations/` as forward-looking documentation without paying the executive cost. Tier-2 projects can reference the declarative ontology of any other project (via OntologyRef in OperationDecl). The discovery of a project's executive handlers is the only gap this ADR names; the declarative half of the catalog already supports the full cross-project composition envisioned by ADR-028.
Constraints
- Hard The default `CatalogBackend` impl MUST continue to support the ontoref piloto self-host without changes to the piloto's catalog format. Any future backend that supersedes CompiledIntoDaemon as default MUST be a strict superset of its capabilities for the piloto's 10 ops.
- Hard The declarative side of `catalog/operations/<id>.ncl` and `catalog/validators/<id>.ncl` MUST remain composable cross-project via ADR-028 content-addressing at ANY tier — independent of which `CatalogBackend` impl is in use.
- Hard When more than one `CatalogBackend` impl exists, the daemon MUST select impls based on explicit configuration in `.ontoref/config.ncl::ops.catalog_backend` (or equivalent). No automatic selection by environmental detection.
Alternatives considered
- Pick CompiledIntoDaemon as the only mechanism, declare cross-project ops out-of-scope permanently — rejected: Forces every tier-2 project to either fork the daemon or upstream their ops into ontoref's workspace. Neither is acceptable for projects with independent release cycles, private domains, or non-Rust catalog authors. Violates the spirit of voluntary-adoption — climbing to tier-2 should be a per-project act, not gated on the protocol's deploy cadence.
- Pick WASM components now and commit to it — rejected: WASM is plausible but the design surface (WIT for OperationEntry + Slice + ValidationCtx + Verdict + OpOutput) is substantial, the runtime overhead unknown, and there is no second tier-2 project signalling demand. Premature commitment without empirical evidence repeats the patterns the trigger-based discipline (ADR-026/D15, ADR-027/D18, ADR-029/D23) was designed to prevent.
- Pick DynamicLibraryLoading now — rejected: Rust ABI is unstable across compiler versions, making dynlib coupling brittle. Without a pinned Rust toolchain shared between the daemon and the project's ops crate, the ABI breaks silently. This is a real-world maintenance burden that surfaces only after the second project ships. Defer until at least one project commits to the toolchain discipline.
- Defer the abstraction point itself — don't add `CatalogBackend` trait until impl is ready — rejected: Without naming the abstraction, future sessions re-rediscover the problem and may pick conflicting solutions. Naming the trait + listing the 5 candidates with trade-offs is cheap (one ADR) and load-bearing for the next session that engages with cross-project tier-2. The design exploration is the deliverable; the impl is the follow-up.
- Push everyone toward InterpretedNclOps and declare native handlers a legacy path — rejected: NCL/expression languages have not been proven expressive enough to handle the full range of ops (Ed25519 signing, blake3 hashing, oplog append, render pipelines). The piloto's 10 ops use native Rust for non-trivial logic. Committing to NCL-only would either restrict ops to the trivial subset OR force a complex NCL expression engine with its own safety story. Defer until evidence shows the trivial subset covers most real ops.
Related ADRs
ADR-001 · ADR-024 · ADR-025 · ADR-026 · ADR-027 · ADR-028 · ADR-029