222 lines
15 KiB
Text
222 lines
15 KiB
Text
|
|
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,
|
|||
|
|
},
|
|||
|
|
}
|