ontoref/adrs/adr-018-level-hierarchy-mode-resolution-strategy.ncl
Jesús Pérez 82a358f18d
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (push) Has been cancelled
feat: #[onto_mcp_tool] catalog, OCI credential vault layer, validate ADR-018 mode hierarchy
ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in
  the catalog at link time via inventory::submit!; annotated item is emitted unchanged,
  ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring
  (net +5: ontoref_list_projects, ontoref_search, ontoref_describe,
  ontoref_list_ontology_extensions, ontoref_get_ontology_extension).

  validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every
  .ncl mode for level declared, strategy declared, delegate chain coherent, compose
  extends valid. mode resolve <id> shows which hierarchy level handles a mode and why.
  --self-test generates synthetic fixtures in a temp dir for CI smoke-testing.

  validate run-cargo: two-step Cargo.toml resolution — workspace layout first
  (crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo
  basename. Lets the same ADR constraint shape apply to workspace and single-crate repos.

  ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry
  coordination, push targets, participant scopes, per-namespace capability.

  reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age
  ≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and
  install_hint (ADR-017 toolchain surface).

  ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema
  additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl.
  secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three
  new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage,
  no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/.
  Integration templates: domain-producer/, mode-producer/, mode-consumer/.

  UI: project_picker surfaces registry badge (⟳ participant) and vault badge
  (⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel
  adds collapsible Registry section with namespace, endpoint, and push/pull capability.
  manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart
  via HTMX POST /ui/manage/services/{service}/toggle.

  describe.nu: capabilities JSON includes registry_topology and vault_state per project.
  sync.nu: drift check extended to detect //! absence on newly registered crates.
  qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram),
  credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named
  errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement,
  integration-troubleshooting.

  on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes.
  Deleted stale presentation assets (2026-02 slides + voice notes).
2026-05-12 04:46:15 +01:00

148 lines
14 KiB
XML

let d = import "defaults.ncl" in
d.make_adr {
id = "adr-018",
title = "Level Hierarchy and Mode Resolution Strategy — Observable Boundary Traversal",
status = 'Accepted,
date = "2026-05-01",
context = "ADR-012 introduced a domain extension system that enables project-specific specialization of ontoref. In practice this created a three-level hierarchy: (1) ontoref base — generic protocol and reflection operations; (2) project domain — specialization of ontoref for a specific project type (provisioning, personal, ...); (3) domain instance — a concrete project derived from a domain (a workspace, an infra, a team ontoref). The hierarchy was not formalized: no level declares its identity, no mode declares whether its implementation is complete or delegates to the level above, and no mechanism exists to determine which level answered a given operation. The result is implicit traversal — a caller invoking 'build-docs' has no way to know whether the operation ran at level 1, 2, or 3, whether it merged contributions from multiple levels, or whether it silently fell through to the base because the domain had not implemented it yet. The discussion that produced this ADR also identified that resolution strategies cannot be uniform: the correct strategy depends on the mode, the project context, and the adoption phase. A domain in early adoption may Delegate all documentation modes to the base; the same domain at maturity Overrides them with project-specific implementations. The transition between strategies must itself be observable — crossing a level boundary is an architectural event, not an implementation detail.",
decision = "Formalize the three-level hierarchy with four mechanisms: (1) Level identity declaration — each ontoref instance declares level.index (1=base, 2=domain, 3=instance), level.name, and level.parent (name of the level above; absent at level 1) in manifest.ncl. This makes level identity explicit and queryable. (2) Per-mode resolution strategy — each reflection mode declares a strategy field using one of four values: 'Override (implementation is complete at this level, traversal stops), 'Delegate (no implementation here, traverse to parent), 'Merge (accumulate fields bottom-up across all levels; lower level wins on conflicts), 'Compose (declare explicit partial inheritance via an extends field naming specific steps or fields to inherit from the parent). Strategy is required at level 2+; absent at level 1 (base is always Override by definition). Implicit absence at level 2+ is treated as 'Delegate with a Soft validation warning. (3) FSM-bound strategy transitions — when a mode's strategy is expected to change as the project matures, declare a state dimension in state.ncl whose current_state tracks the strategy value. The transition from 'Delegate to 'Override (or any other pair) is then an observable FSM event: tracked in state.ncl, visible in ore describe state, and triggerable by the same transition conditions as any other dimension. Strategy changes are architectural events, not silent refactors. (4) Observable traversal via ore mode resolve — the command 'ore mode resolve <id>' reports which level answered the operation, the strategy applied, the source file, and the reason (declared strategy or FSM state). No mode resolution is ever silent.",
rationale = [
{
claim = "Implicit traversal makes level boundaries invisible and coupling complaints undiagnosable",
detail = "The coupling discussions that preceded this ADR (provisioning workspace management appearing coupled to ontoref, credential management appearing to require ontoref) were symptoms of invisible level traversal. When it is impossible to determine whether an operation ran at level 1 or 2, it is also impossible to determine whether the coupling is load-bearing (level 2 deliberately uses level 1) or accidental (level 2 forgot to implement something and fell through). Explicit level declaration and strategy declaration make this distinction mechanically checkable.",
},
{
claim = "Per-mode strategy is the correct granularity — global strategy per level does not hold",
detail = "A domain in active use may Override its core modes (workspace management, infra deploy) while still Delegating peripheral modes (documentation generation, ADR validation) to the base. A global strategy per level would force a false choice: either all modes are overridden (blocking base adoption) or all modes delegate (making the domain invisible). Per-mode strategy reflects the actual adoption curve: domains specialize incrementally, not all at once.",
},
{
claim = "FSM-bound transitions make strategy changes architectural events",
detail = "Without FSM binding, a mode moving from 'Delegate to 'Override is a silent code change with no recorded rationale. With FSM binding, the transition requires updating current_state in state.ncl, which is a deliberate action visible to ore describe state, tracked in git history, and subject to the same transition conditions (catalyst, blocker) as any other dimension. The architectural significance of 'we now own this mode' is formally recorded.",
},
{
claim = "'Compose is necessary for surgical specialization that preserves base infrastructure",
detail = "Merge and Override are extremes: Merge accumulates everything (field-level conflicts resolved by lower level winning), Override discards everything from above. Compose covers the real case: a domain wants to keep the base's generate-api step and the base's lint-docs step, but replace the publish step with a domain-specific one. Without Compose, the domain must either fully override (losing base infrastructure it wants) or Merge (risking unintended inheritance of base steps it does not want).",
},
{
claim = "Level parent as name reference decouples identity from location",
detail = "parent = 'ontoref-base' in a domain's level declaration is a name reference, not a file path or OCI artifact coordinate. The resolution of that name to a concrete implementation is context-dependent: local checkout for development, OCI artifact for distributed domains. The ADR defines the declaration contract; resolution is implementation-dependent and may itself evolve (ADR-010 migration system handles protocol evolution).",
},
{
claim = "ore mode resolve makes the system self-describing at the operational layer",
detail = "ontoref's core identity (ADR-001, core.ncl) is self-describing: it consumes its own protocol. ore mode resolve extends this to the operational layer: the resolution mechanism is itself queryable. An agent or developer can always answer 'which level is handling this mode right now and why' without reading source code. This is the concrete expression of the 'always know where you are and when you cross the frontier' requirement.",
},
],
consequences = {
positive = [
"Level identity is always queryable — ore describe project shows level.index, level.name, level.parent",
"Mode resolution is always observable — ore mode resolve <id> shows level, strategy, source, reason",
"Strategy changes are FSM events — visible in git, queryable in state, subject to transition conditions",
"Delegate chains that break (no Override anywhere in the parent chain) are caught by ore validate modes",
"Phased domain adoption is formally supported — early phases Delegate, mature phases Override",
"Coupling complaints become diagnosable — is provisioning using ontoref base because it Delegates (load-bearing) or because it forgot to Override (accidental)?",
"Compose enables surgical specialization without full Override — base infrastructure is reusable explicitly",
],
negative = [
"All existing modes at level 2+ require strategy field addition — migration 0017 needed",
"ore mode resolve is not yet implemented — constraint delegate-chain-complete cannot be checked until it is",
"ore validate modes is not yet implemented — constraints are declared but not executable at ADR acceptance",
"Compose requires extends field with precise step/field references — mismatches produce runtime errors, not schema errors",
"FSM dimensions for strategy transitions add state.ncl surface area — domains with many modes in transition accumulate many dimensions",
],
},
alternatives_considered = [
{
option = "Implicit inheritance — current state, no declaration required",
why_rejected = "Makes level traversal invisible. Coupling is undiagnosable. Adoption phase is unknown. Boundary crossing is unobservable. This is the state that produced the architectural confusion this ADR resolves.",
},
{
option = "Global strategy per level — one strategy applies to all modes at a given level",
why_rejected = "Domains specialize incrementally. A global Override for level 2 blocks early adoption (all modes must be implemented before any work). A global Delegate for level 2 makes domains invisible (nothing is ever implemented). Per-mode strategy reflects the real adoption curve.",
},
{
option = "Override-only — levels either implement fully or do not appear",
why_rejected = "Eliminates phased adoption. A domain cannot exist in the system until it has implemented every mode — a steep entry cost that contradicts ADR-001 (voluntary coherence, no enforcement). Delegate and Compose are specifically needed for incremental adoption.",
},
{
option = "Separate level.ncl file instead of level field in manifest.ncl",
why_rejected = "manifest.ncl already holds structural self-description (requirements, capabilities, registry topology, layers). Level identity is structural metadata of the same kind. A separate file adds coordination overhead (two files to keep in sync, two files for describe to read) for no additional expressiveness.",
},
],
constraints = [
{
id = "level-declared-at-domain",
claim = "Any ontoref instance at level 2 or 3 must declare level.index, level.name, and level.parent in manifest.ncl",
scope = "all manifest.ncl files in domain and instance projects",
severity = 'Hard,
check = {
tag = 'NuCmd,
cmd = "ore validate modes --check level-declared",
},
rationale = "Level identity is the precondition for all other mechanisms in this ADR. An undeclared level cannot participate in observable traversal or FSM-bound strategy transitions.",
},
{
id = "strategy-declared-at-domain",
claim = "Every reflection mode at level 2+ must declare strategy explicitly; implicit absence is a Soft violation",
scope = "reflection/modes/*.ncl in domain and instance projects",
severity = 'Soft,
check = {
tag = 'NuCmd,
cmd = "ore validate modes --check strategy-declared",
},
rationale = "Implicit Delegate (mode exists at level 2 without strategy field) is functionally equivalent to explicit Delegate but invisible. The Soft severity allows gradual adoption — existing modes that predate this ADR are not hard-blocked, but the warning drives migration.",
},
{
id = "delegate-chain-complete",
claim = "No Delegate chain may terminate without an Override at some level — every mode invocation must resolve to a concrete implementation",
scope = "all level 2 and 3 modes with strategy = 'Delegate",
severity = 'Hard,
check = {
tag = 'NuCmd,
cmd = "ore validate modes --check delegate-chain",
},
rationale = "A Delegate chain that ends without an Override means the operation has no implementation — invoking it produces a silent no-op or an opaque error. This is the failure mode that makes implicit traversal dangerous.",
},
{
id = "strategy-state-coherent",
claim = "If state.ncl declares a dimension for a mode's strategy, its current_state must match the strategy field declared in the mode NCL",
scope = "state.ncl dimensions whose id matches the pattern '<mode-id>-strategy'",
severity = 'Soft,
check = {
tag = 'NuCmd,
cmd = "ore validate modes --check strategy-state",
},
rationale = "Strategy drift between the FSM state and the mode declaration means the state dimension has become decorative — it records an intention that is no longer reflected in the actual implementation. Soft severity allows the transition to be in-flight (state says Override, code not yet complete).",
},
{
id = "compose-extends-valid",
claim = "A mode with strategy = 'Compose must declare an extends field; every step or field reference in extends must exist in the named parent mode",
scope = "reflection/modes/*.ncl with strategy = 'Compose",
severity = 'Hard,
check = {
tag = 'NuCmd,
cmd = "ore validate modes --check compose-extends",
},
rationale = "A Compose mode with a broken extends reference silently inherits nothing from the parent — equivalent to an unintentional Override. Since Compose is specifically chosen to preserve parent infrastructure, a broken reference is always a bug.",
},
],
related_adrs = [
"adr-001-protocol-as-standalone-project",
"adr-010-protocol-migration-system",
"adr-011-mode-guards-and-convergence",
"adr-012-domain-extension-system",
],
ontology_check = {
decision_string = "three-level hierarchy (base, domain, instance) with per-mode resolution strategy (Override, Delegate, Merge, Compose); strategy transitions are FSM events in state.ncl; ore mode resolve makes traversal observable; ore validate modes enforces chain completeness",
invariants_at_risk = ["protocol-not-runtime", "no-enforcement"],
verdict = 'Safe,
},
}