ontoref/adrs/adr-020-three-layer-ontoref-instance-model.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

221 lines
15 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let d = import "defaults.ncl" in
d.make_adr {
id = "adr-020",
title = "Three-Layer Model for Project Ontoref Instances",
status = 'Proposed,
date = "2026-05-03",
context = m%"
Multiple projects now adopt the ontoref protocol (lian-build is the explicit
external case under bl-002 / bl-008). Field experience across ontoref +
lian-build + provisioning shows that an ontoref-onboarded project's
repository carries TWO distinct ontoref-shaped layers, while a third layer
exists outside the project (in caller repositories). Without codification,
adopters re-derive the model per project, mix layers accidentally, and lose
boundaries silently:
- Layer 3 content (caller-side cabling) drifts into a project's qa.ncl,
making the FAQ a how-to-deploy guide for one specific caller.
- Layer 2 schemas live under reflection/ as 'project notes', drifting
from the binary because they're not on the contract path.
- Layer 1 architectural rationale ends up in catalog/domains/<id>/
contract.ncl, where consumers expecting a typed shape get prose.
The model has been observed (lian-build/reflection/qa.ncl::lian-build-what-
and-why), described (ontoref/reflection/qa.ncl::ontoref-three-layer-model),
and queued for codification (bl-009). This ADR commits the model with
machine-checkable constraints. Acceptance is gated on the constraints
running clean across the existing ontoref-onboarded projects (ontoref
itself, lian-build, provisioning).
"%,
decision = m%"
A project's ontoref instance has THREE distinct layers — but only the first
two live in the project's own repository:
LAYER 1 — Self-management ontoref (about the project itself)
paths .ontology/ reflection/ adrs/
audience this project's developers and maintainers
purpose describe the project to itself — axioms, FSM dimensions,
binding decisions, open questions, accepted knowledge
presence MANDATORY on every ontoref-onboarded project
LAYER 2 — Specialized domain/mode ontoref (the integration surface)
paths schemas/ catalog/{domains,modes}/
manifest.ncl::registry_provides
audience OTHER projects that want to integrate this project
purpose the contract surface other projects bind to — typed domain
artifacts, orchestration mode artifacts, registry-namespace
claim
presence OPTIONAL but BICONDITIONAL — a project either has all of
{schemas/, catalog/, registry_provides} or none. Half-Layer-2
is a contract violation.
LAYER 3 — Caller-side implementations (NOT in this project)
paths <caller>/extensions/<this-project>/
<caller>/catalog/components/<...>/ (when consuming)
<workspace>/infra/<ws>/integrations/
audience operators and CI of caller projects
presence PER CALLER, NEVER in this project's repo. Cross-references
from this project to Layer 3 are explicit pointers, never
copy-paste.
The three-layer axis is ORTHOGONAL to ADR-018's level hierarchy
(Base/Domain/Instance). A project at any level may have any combination of
Layer 1 (always) and Layer 2 (sometimes). Layer 3 is the boundary outward,
not a property of the project. The 3-layer × 3-level matrix is navigable in
both axes: 'where in the protocol hierarchy' (level) is independent of
'where in the project's repo' (layer).
Cross-layer references inside a project carry an explicit layer-N tag on
qa entries. A qa entry whose primary topic is a Layer N concern wears
the layer-N tag; satellite entries (operational how-to, troubleshooting)
inherit the layer of their anchor without re-tagging.
"%,
rationale = [
{
claim = "Audience separation requires lifecycle separation",
detail = "Layer 1 evolves with the project's decisions (ADR cadence). Layer 2 evolves with the binary (schemas in lock-step with code that produces and consumes them). Layer 3 evolves with caller infrastructure (workspace cabling, deployment changes). Three lifecycles in one namespace produces drift in two of them — observed in pre-codification cases where schemas drifted because they were treated as project notes.",
},
{
claim = "Layer 2 biconditional is what makes a federated peer detectable",
detail = "If catalog/ exists without manifest.ncl::registry_provides, the project has artifacts but no namespace claim — consumers can't resolve where to pull from. If registry_provides exists without catalog/, the project claims a namespace it doesn't fill. Either half-state breaks integration. Treating the pair as biconditional makes 'is this a federated peer?' a single boolean check.",
},
{
claim = "Layer 3 isolation is project-specific in spelling, universal in principle",
detail = "What counts as caller-specific differs per project (lian-build forbids `provisioning_workspace|vapora_|woodpecker_` per its adr-001; another project would forbid different patterns). The protocol-level constraint cannot enumerate; instead, each project carries its own constraint-bearing ADR and the protocol verifies presence of SUCH a constraint indirectly via cross-layer-tag-discipline. Project-specific spelling, ecosystem-wide presence.",
},
{
claim = "Cross-layer tag discipline makes filtering scalable",
detail = "An ontoref instance accumulates dozens to hundreds of qa entries over its life. 'Filter the qa for what other projects can integrate' (Layer 2) becomes a tag query rather than a full-text search. Tagging only anchors keeps the result set small and authoritative; satellites don't dilute the filter.",
},
{
claim = "Orthogonality with ADR-018 lets the model expand without conflict",
detail = "ADR-018's level hierarchy describes WHERE a project sits in the protocol specialization (Base = ontoref itself; Domain = a project-type domain; Instance = a concrete workspace). The three-layer model describes WHERE inside a project's repo content sits (self / integration-surface / caller-side). Different axes; no terminology collision (level vs layer); a 3x3 matrix populated empirically. Treating them as orthogonal avoids re-litigating ADR-018 every time the layer model evolves.",
},
],
consequences = {
positive = [
"ore describe project can statically answer 'is this a federated peer?' (Layer 2 biconditional) and 'is the project minimally onboarded?' (Layer 1 mandatory)",
"Adopters discover the model from documentation (qa::ontoref-three-layer-model) plus this ADR, not by osmosis from existing projects",
"qa-tag-based filtering (layer-1, layer-2) becomes a navigable surface as ecosystem corpus grows",
"The 3-layer × 3-level matrix is explicit, removing a recurring source of design ambiguity (e.g. 'should this go in .ontology or in catalog?')",
"ontoref setup gains a clear acceptance criterion: it produces a Layer 1-compliant scaffold by construction",
],
negative = [
"Existing projects must audit their structure for Layer 1 compliance — typically reflection/qa.ncl and reflection/backlog.ncl are missing on early adopters",
"Six new constraints add per-project CI overhead (4x FileExists, 2x NuCmd) — running cheap but additive across the ecosystem",
"Terminology discipline required: 'layer' (this ADR) vs 'level' (ADR-018) are different concepts and prose must not collapse them",
"Layer 2 biconditional rejects half-states that some projects might want as an interim — projects mid-Layer-2-rollout must either complete or not start until ready",
],
},
alternatives_considered = [
{
option = "Single 'ontoref content' namespace, no layering",
why_rejected = "Observed drift outcomes (Layer 3 in qa, Layer 2 schemas treated as notes, etc.) are caused precisely by absence of layering. The single-namespace alternative is what we're correcting; rejecting it is the entire decision.",
},
{
option = "Two-layer model collapsing self-management with integration-surface",
why_rejected = "lian-build's adoption explicitly experienced the schema-vs-rationale split: schemas/build_directives.ncl (Layer 2 contract) and adrs/adr-001-lian-build-as-standalone.ncl (Layer 1 rationale) have different audiences, different change cadences, different validation rules. Collapsing them produced the FAQ-vs-contract confusion that retiring lian-build/.ontology/FAQ.md addressed. The two-layer alternative re-creates that confusion.",
},
{
option = "Make Layer 3 (caller-side) optionally co-resident with Layer 2 in the producer's repo",
why_rejected = "Creates ambiguity about who owns the cabling. If a producer's repo carries `extensions/<self>/`, who maintains it when a caller's workspace evolves? The producer doesn't know about the caller's infrastructure; the caller can't depend on the producer to update the cabling on its schedule. Co-residence inverts the integration arrow.",
},
{
option = "Numbered layers (Layer 1 / 2 / 3) vs named layers ('self' / 'integration-surface' / 'caller-side')",
why_rejected = "Named layers are more descriptive but verbose; numbered layers are more precise but flat. The compromise (this ADR): use 'Layer N — descriptive name' in prose, 'layer-N' in tags. Tag economy wins; prose retains the descriptive name.",
},
],
constraints = [
{
id = "layer-1-self-ontology-core",
claim = "Every ontoref-onboarded project has .ontology/core.ncl",
scope = ".ontology/",
severity = 'Hard,
check = {
tag = 'FileExists,
path = ".ontology/core.ncl",
present = true,
},
rationale = "Layer 1's irreducible minimum: a project with no axioms, tensions, or practices has no ontoref instance. Overlaps with adr-016's lift-out check by intention — same observable, two architectural decisions resting on it.",
},
{
id = "layer-1-reflection-qa",
claim = "Every ontoref-onboarded project has reflection/qa.ncl",
scope = "reflection/",
severity = 'Hard,
check = {
tag = 'FileExists,
path = "reflection/qa.ncl",
present = true,
},
rationale = "Layer 1's accepted-knowledge surface. A project without qa.ncl cannot accumulate verified Q&A — adopters re-derive answers each time. ontoref setup must scaffold this; existing projects backfill.",
},
{
id = "layer-1-reflection-backlog",
claim = "Every ontoref-onboarded project has reflection/backlog.ncl",
scope = "reflection/",
severity = 'Hard,
check = {
tag = 'FileExists,
path = "reflection/backlog.ncl",
present = true,
},
rationale = "Layer 1's open-question surface. Projects without backlog.ncl bury unresolved alternatives in chat threads, defeating the protocol's routing mechanism (graduates_to). Required so every undecided question has a place to land.",
},
{
id = "layer-1-adrs-directory",
claim = "Every ontoref-onboarded project has an adrs/ directory",
scope = "adrs/",
severity = 'Hard,
check = {
tag = 'FileExists,
path = "adrs/",
present = true,
},
rationale = "Layer 1's binding-decisions slot. The directory may be empty for a brand-new project, but its presence is what makes 'where do ADRs go?' a settled question.",
},
{
id = "layer-2-biconditional",
claim = "A federated-peer catalog (catalog/domains/ or catalog/modes/) exists if and only if manifest.ncl declares registry_provides",
scope = "catalog/domains/, catalog/modes/, manifest.ncl, .ontology/manifest.ncl",
severity = 'Hard,
check = {
tag = 'NuCmd,
cmd = "let fed = (('catalog/domains' | path exists) or ('catalog/modes' | path exists)); let m_root = (try { open manifest.ncl | str contains 'registry_provides' } catch { false }); let m_onto = (try { open .ontology/manifest.ncl | str contains 'registry_provides' } catch { false }); let prv = ($m_root or $m_onto); if $fed == $prv { 0 } else { error make {msg: $'Layer 2 biconditional violated: federated_catalog=($fed) registry_provides=($prv) — half-states advertise integration the consumer cannot complete'} }",
expect_exit = 0,
},
rationale = "Federated-peer status is detectable by a single biconditional. The marker is catalog/domains/ or catalog/modes/ specifically — not catalog/ as a whole, because some projects (provisioning) carry catalog/components/, catalog/providers/, etc. that serve workspace composition rather than federated artifact publication. The manifest may live at root or under .ontology/ depending on project convention; bl-010 (forthcoming) tracks canonicalization of that path. Half-states (one side without the other) are the violation.",
},
{
id = "cross-layer-tag-discipline",
claim = "qa entries whose primary topic is a Layer N concern carry the layer-N tag (anchors only; satellites inherit by association)",
scope = "reflection/qa.ncl",
severity = 'Soft,
check = {
tag = 'NuCmd,
cmd = "let entries = (nickel export reflection/qa.ncl --format json | from json | get entries); let suspicious = ($entries | where {|e| (($e.answer | str contains 'LAYER 1') or ($e.answer | str contains 'LAYER 2')) and ($e.tags | where {|t| ($t | str starts-with 'layer-')} | is-empty) and ($e.id | str contains 'what-and-why')}); if ($suspicious | is-empty) { 0 } else { error make {msg: $'Anchor entries discussing layers must carry layer-N tag: ($suspicious | get id | str join \", \")'} }",
expect_exit = 0,
},
rationale = "Tag-based filtering scales as the corpus grows. Without discipline, querying for 'Layer 2 concerns' returns either too few entries (false negatives because anchors aren't tagged) or too many (false positives because every entry that mentions a layer gets tagged). Anchors-only is the sustainable middle path; this constraint enforces it for the most common drift case.",
},
],
related_adrs = [
"adr-001-protocol-as-standalone-project",
"adr-016-component-lift-out-pattern",
"adr-018-level-hierarchy-mode-resolution-strategy",
],
ontology_check = {
decision_string = "An ontoref-onboarded project's repository carries Layer 1 (mandatory self-management) and optionally Layer 2 (integration surface, biconditional with registry_provides). Layer 3 lives in caller projects, not in the producer. The three-layer axis is orthogonal to ADR-018's level hierarchy.",
invariants_at_risk = [],
verdict = 'Safe,
},
}