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// 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 /extensions// /catalog/components/<...>/ (when consuming) /infra//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//`, 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, }, }