Catalog Extensibility Beyond Rust — `kind` Discriminator on `OperationDecl` with ondaod-Pre Required for Non-Rust Kinds

Proposed

ontoref
ADR-024 (operations as the agent's only project-touching path) and

Context

ADR-024 (operations as the agent's only project-touching path) and ADR-026 (three-planes validation + SLA) commit the protocol to a typed catalog of domain operations, each declared in `.ontoref/catalog/operations/<id>.ncl` and paired with a Rust handler annotated `#[onto_operation]`. The Rust pairing is enforced by `inventory::collect!(OperationEntry)` at link time — there is no protocol surface today for a catalog declaration without a matching Rust function.

ADR-030 (catalog discovery cross-project) named the broader problem of how non-piloto projects expose tier-2 ops and enumerated seven candidate mechanisms:

1. CompiledIntoDaemon (current — Rust + inventory at link time) 2. DynamicLibraryLoading (.so/.dylib via libloading) 3. WasmComponents (WIT-typed WASM modules via wasmtime) 4. SidecarProcess (HTTP/IPC to per-project process) 5. InterpretedNclOps (declarative NCL data-transform) 6. SidecarProcessWithSharedDomainLibrary (refinement of #4) 7. CompositeBackend (mix of the above)

ADR-030 split these into Phase A (implementable today: #1, #6, #7) and Phase B (spike-required behind explicit triggers: #2, #3, #4-raw, #5) and committed the `CatalogBackend` trait surface without committing any non-default impl.

WHAT THIS ADR DOES vs DOES NOT DO

ADR-034 does NOT re-open ADR-030's deferral. The trigger-based discipline for choosing WHICH executive backend lands first remains intact: WASM is deferred behind T3/T4/T5, dynlib behind toolchain-coupling pressure, NCL-interpreted behind expressive-power proof.

ADR-034 commits a different layer: the catalog's DECLARATIVE schema. Today an `OperationDecl` in `catalog/schema.ncl` implicitly assumes the op is implemented in Rust (no `kind` field, the runtime looks up the inventory entry by id). For tier-2 to scale across the ecosystem — even under Phase A mechanisms only — the catalog declaration must be honest about what mechanism a particular op uses. The piloto's `move_fsm_state` is Rust; a future provisioning op in libre-daoshi's sidecar is Sidecar-mechanism; a future trivial CRUD op in a tier-2 mirador clone might be NclTransform. Each kind has different witness emission rules, different ondaod-discipline requirements, and different cross-project composability properties.

ADR-034 adds a `kind` field to `OperationDecl` with the canonical enumeration: `'Rust` (default), `'NclTransform`, `'Wasm`, `'Sidecar`. The schema admits all four kinds. The Rust runtime implements `'Rust` today (inventory dispatch); the other three kinds are declarable but unexecutable until an executive backend lands per ADR-030's trigger discipline. The schema decoupling is the deliverable; the executive landings remain trigger-deferred.

WHY DECLARE BEFORE EXECUTE

Declaring the four kinds in the schema does three things that executive-only would not:

1. Consumer projects can author non-Rust ops as documentation today (declarative side of catalog composes cross-project via ADR-028 content-addressing per ADR-030's confirmation). A tier-0 project can declare its future tier-2 catalog including non-Rust kinds without paying the executive cost. 2. Cross-project ontology consumers can read the declared `kind` and understand what dispatch path the foreign project's op takes — useful for trust analysis (a Sidecar op carries a process boundary; a Wasm op carries sandbox guarantees). 3. ondaod-discipline can be enforced structurally at the catalog level: non-Rust kinds cannot inline-execute the ondaod-evaluation op (NCL is declarative, WASM is sandboxed from the host runtime, Sidecar crosses a process boundary). The schema requires `evaluate_ondaod` in `constraints.pre` for non-Rust kinds, making the host runtime perform the discipline check before dispatch.

WHAT VALIDATORS DO

Validators are pure (no state mutation). The kind field applies symmetrically to `ValidatorDecl` — `predicate_ref` becomes mechanism-dependent. Because validators don't mutate state, the ondaod-pre requirement does not apply to them; only operations are gated. The schema admits the same four kinds for validators with the same trigger-deferred executive impls.

Decision

SCHEMA FIELD — `kind` discriminator on `OperationDecl` and `ValidatorDecl`

The `OperationDecl` contract in `.ontoref/catalog/schema.ncl` gains a new field `kind` typed as:

let t_OpKind = [| 'Rust, # #[onto_operation] + inventory (DEFAULT) 'NclTransform, # declarative NCL data-transform spec 'Wasm, # WIT-typed WASM module path 'Sidecar, # HTTP endpoint on a per-project process |] in

The field is optional with `default = 'Rust`, so every existing operation declaration continues to typecheck unchanged. Adding a new op requires no `kind` declaration unless the author chooses a non-Rust mechanism.

PER-KIND BODY FIELD — `body | optional` (open record)

Each non-Rust kind requires an additional declaration of WHERE the mechanism's runtime locates the implementation. The `body` field is an optional open record whose shape is per-kind:

'Rust → body absent (impl resolved by id via inventory)

'NclTransform → body = { target | String, # NCL file path relative to project root selector | String, # JSONPath into the exported value operation | [| 'Set, 'Append, 'Remove, 'Merge |], value_expr | String, # NCL expression evaluated in op context }

'Wasm → body = { module_path | String, # path under .ontoref/catalog/wasm/<id>.wasm export_name | String, # name of the WIT-exported function capabilities | Array String, # explicit capability grants (file:read, net:none, ...) }

'Sidecar → body = { endpoint | String, # HTTP URL (host-local default) auth_token | String | default = "<from .ontoref/config.ncl::ops.sidecar_token>", timeout_ms | Number | default = 5000, }

The `body` field is validated structurally by the schema (Nickel contracts enforce per-kind shape); executive backends interpret it at dispatch time. The protocol does NOT validate `body` semantically until an executive backend exists — declaration without execution is honest documentation per ADR-030.

EXECUTIVE IMPL DEFERRAL — unchanged from ADR-030

The `'Rust` kind is the only kind the daemon dispatches today. The other three kinds remain trigger-deferred per ADR-030:

'NclTransform → trigger: most tier-2 ops in the ecosystem turn out to be small state-mutation verbs that need no native code, AND the expressive power of the declarative spec is proven sufficient for ≥3 real ops. 'Wasm → trigger: ADR-030 T3 / T4 / T5 — cross-instance dispatch becomes the bottleneck, toolchain divergence, or untrusted ops authors require sandboxing. 'Sidecar → already Phase A per ADR-030 mechanism #6 (SidecarProcessWithSharedDomainLibrary). The ~80-LOC proxy handler is implementable today; provisioning + Level-3 instances is the canonical first consumer.

This ADR commits the schema discriminator. It does NOT commit the non-`'Rust` executive impls beyond what ADR-030 already commits. Sidecar may land near-term (Phase A); Wasm and NclTransform stay deferred.

ONDAOD-PRE REQUIREMENT FOR NON-Rust KINDS

ADR-024 places `evaluate_ondaod` as a structural ondaod-discipline check that runs before architectural-mutation operations. The Rust runtime can invoke this directly from inside an op body (the op has access to the dispatch context). Non-Rust kinds cannot:

- `'NclTransform` ops are declarative — no body to run ondaod inside - `'Wasm` ops are sandboxed — the host's ondaod context is not automatically reachable; passing it across the boundary requires interface design - `'Sidecar` ops cross a process boundary — invoking ondaod from the sidecar would require the sidecar to call back into the daemon

The protocol resolves this by requiring `evaluate_ondaod` (or a declared specialised ondaod validator) to appear in the `constraints.pre` array of every non-`'Rust` `OperationDecl`. The host runtime invokes the pre-validator before dispatching to the non-Rust kind. This makes ondaod-discipline a structural property of the catalog declaration, not a behavioural property of the op body — which is exactly what the declarative side of the catalog is for.

The constraint is enforced as a Hard schema-level check at typecheck time:

let t_OperationDecl = ... in let t_OndaodPreRequired = std.contract.from_predicate (fun decl => decl.kind == 'Rust || std.array.any (fun v => v == "evaluate_ondaod") decl.constraints.pre) in

Rust-kind ops MAY still declare `evaluate_ondaod` in their `constraints.pre` (and ondaod-touching ops SHOULD); the constraint only *requires* it for non-Rust kinds. The asymmetry matches the asymmetry of executive access: Rust ops can synthesise ondaod context internally, non-Rust ops cannot.

VALIDATOR EXTENSIBILITY — same field, no ondaod-pre requirement

`ValidatorDecl` in `.ontoref/catalog/schema.ncl` also gains the `kind` field with the same enumeration. The ondaod-pre requirement does NOT apply because validators are pure (no state mutation). Otherwise the treatment is symmetric: `'Rust` is default and dispatches today; `'NclTransform` / `'Wasm` / `'Sidecar` are declarable but unexecutable until the corresponding executive backend lands.

WITNESS SHAPE HONESTY

ADR-024 requires every operation to emit a witness covering the act. The witness shape declared in `OperationDecl.witness_shape` must reflect what the executive backend can attest:

'Rust → witness covers the post-state of the host's in-process application; the signature is over the canonical serialisation of the state delta (current behaviour). 'NclTransform → witness covers the same shape — the host runtime applies the transform in-process and signs the delta. No additional attestation needed. 'Wasm → witness covers the host's view of inputs + outputs of the WASM call; the signature does NOT attest WASM- internal computation (the sandbox is honest about being a black box). 'Sidecar → witness covers the sidecar's response payload as received by the daemon; the signature is the daemon's signature, not the sidecar's. The protocol does NOT claim the sidecar's computation is honest — only that the daemon faithfully forwarded the sidecar's reported effect.

The witness-shape distinction is documented in the schema's comments; it is NOT enforced as a contract because the protocol cannot statically verify which witness mode the executive backend will use. It IS documented in the constraint rationale below so that future executive backend implementations honour the declared boundaries.

Constraints

  • Hard The `OperationDecl` contract in `.ontoref/catalog/schema.ncl` MUST declare a `kind` field typed as an enum admitting at minimum `'Rust`, `'NclTransform`, `'Wasm`, `'Sidecar`. The field MUST default to `'Rust` so existing declarations remain valid without modification. The `ValidatorDecl` contract MUST declare the same field with the same default.
  • Hard Every `OperationDecl` with `kind != 'Rust` MUST include `"evaluate_ondaod"` (or a documented specialised ondaod validator id) in its `constraints.pre` array. The schema MUST enforce this with a contract that fails typecheck when the requirement is unmet.
  • Hard The `'Rust` kind MUST remain the default and MUST remain executable by the daemon under every set of feature flags including `--no-default-features`. Adding new kinds MUST NOT regress the Rust-kind dispatch path.
  • Hard When a project declares an `OperationDecl` with `kind = 'Wasm`, `'NclTransform`, or `'Sidecar` and the daemon was built without the corresponding executive feature flag (`wasm`, `ncl-transform`, `sidecar` respectively when those features exist), the daemon's `/ops/{id}` endpoint MUST return a 501 Not Implemented response with a structured error explaining the missing feature. Silent fall-through to 404 is forbidden.
  • Hard For each non-Rust `kind`, the schema MUST validate the `body` field's required keys at typecheck time. `kind = 'NclTransform` requires `body.target`, `body.selector`, `body.operation`, `body.value_expr`. `kind = 'Wasm` requires `body.module_path`, `body.export_name`, `body.capabilities`. `kind = 'Sidecar` requires `body.endpoint` (other fields default).
  • Hard Adding the `'Wasm` kind to the schema MUST NOT introduce a `wasmtime` (or `wasmer`) dependency in the default `ontoref-daemon` build. Any WASM runtime dependency MUST be feature-gated behind an opt-in flag (e.g. `--features wasm`).
  • Hard The daemon MUST NOT infer an op's `kind` from artefact presence (e.g. existence of a `.wasm` file, a sidecar URL in config, an NCL body in the declaration). The `kind` field is the authoritative source. When the field is absent, the kind is `'Rust` by default — never any other value.
  • Soft For non-Rust kinds (`'Wasm`, `'Sidecar`), the witness emitted on dispatch MUST attest only the host-observable inputs and outputs of the foreign execution. The witness MUST NOT claim to attest the foreign computation's internal correctness, and the `payload_kind` MUST encode this honestly (e.g. `wasm_call_envelope`, `sidecar_response_envelope`).

Alternatives considered

  • Add only `'Rust` and `'Sidecar` to the kind enum; defer `'Wasm` and `'NclTransform` declarations entirelyrejected: Forces a schema migration when `'Wasm` or `'NclTransform` later become declarable. The cost of declaring all four kinds today is one Nickel enum line per kind; the cost of migrating the schema later (with all consumer projects' content needing re-checking) is higher. ADR-030 already enumerated all the mechanisms; adding them to the schema follows the same enumeration.
  • Declare `kind` but make `body` field free-form `Dyn` for all non-Rust kindsrejected: Loses the typecheck protection that the per-kind shape gives. Authors of `kind = 'Sidecar` ops would get no help from the schema confirming they declared `endpoint`, `auth_token`, `timeout_ms`. The open-record-per-kind approach offers minimal typecheck for the canonical fields while leaving room for executive-backend-specific extensions.
  • Require ondaod-pre for ALL ops (Rust and non-Rust)rejected: Over-constrains Rust ops. Rust-kind ops have inline access to dispatch::call("evaluate_ondaod", …) and currently invoke it from inside the body when needed (operationally — see crates/ontoref-ops/src/ops/transition_adr.rs). Forcing every Rust op to also declare it in constraints.pre would duplicate enforcement and create false-positive failures for ops that don't touch architectural state. The asymmetry matches the executive-access asymmetry: structural where structural is needed.
  • Add Nushell as a fifth kind (`'NuScript`)rejected: Nushell is the existing automation surface (reflection/modules/) and is already declarable via `'Sidecar` with the sidecar being a Nushell-based HTTP server. Adding `'NuScript` as a separate kind would create overlap with `'Sidecar` and require duplicate documentation. If empirical pressure later shows that Nushell ops are common enough to justify a dedicated mechanism, a future ADR can add the kind — the four-kind enum is the minimum coherent set today.
  • Defer the schema discriminator entirely — wait until an executive backend lands and add the field thenrejected: Anti-pattern from ADR-030's perspective: schema decoupling is cheap; executive backend landings are expensive and require triggers. Adding the schema field now means the FIRST executive backend doesn't have to bump the schema — it implements its kind, and consumer projects that declared it speculatively start working without re-publication. The schema field also unblocks ADR-030 #6 (Sidecar Phase A) by giving it a place to declare without retro-fitting later.
  • Use a tag union without per-kind body field — embed mechanism-specific fields directly in OperationDeclrejected: Pollutes OperationDecl with N×M fields (each kind's fields × every declaration). The per-kind body is the canonical encoding pattern (matches `t_ConstraintCheck` in adr-schema.ncl — tag + per-tag required fields). Authors only see fields relevant to their chosen kind.

Related ADRs

ADR-024 · ADR-026 · ADR-028 · ADR-029 · ADR-030 · ADR-032 · ADR-033

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.