Skip to content

Make resolved ownership scope part of the canonical rule model - #7

Merged
MirzaMerdovic merged 5 commits into
mainfrom
feat/canonical-rule-scope
Sep 5, 2026
Merged

Make resolved ownership scope part of the canonical rule model#7
MirzaMerdovic merged 5 commits into
mainfrom
feat/canonical-rule-scope

Conversation

@MirzaMerdovic

@MirzaMerdovic MirzaMerdovic commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

PO-03. Makes the ownership classification established by PO-02 a first-class part of Coral's canonical rule representation. Metadata and model plumbing only: no rule semantics change.

The problem

A rule was {page, line, class, tags}, and its resolved ownership layer lived in a separate Map<ruleId, layer>. A consumer holding a rule did not know what the rule belonged to — it had to call parseRules, parseLayers, parseKernel, parseProfiles and classifyRules in the right order and carry the second map alongside. .vitepress/config.mjs did that, scripts/rules-index.mjs did it again, and serializeIndex() did it a third time on rules it had already been handed.

The canonical model

loadRuleModel(srcDir) performs the composition once and returns rules that carry their own scope:

{ page, line, cls, tags, scope: {
    kind,            // 'kernel' | 'app-profile' | … — the registry's machine key
    profile,         // 'cli' | 'go' | null
    tag,             // 'app:cli' | 'baseline' | null (kernel)
    label,           // presentation text: 'app profile'
    surface,         // 'conformance' | 'governance' | 'opt-in'
    contractScoped   // boolean
} }

[CLI-6] is kind app-profile, profile cli. [MODEL-1] is kind kernel with no profile and no tag, resolved from the kernel block as before. A rule the model cannot resolve is returned without a scope and with a build error — nothing falls back to the baseline.

classified

The model also returns classified, which Gate 9 reads. It requires all four ownership sources — the taxonomy, the kernel registry, the profile registry and the classification itself — to have parsed clean, and every rule to carry a resolved scope:

const complete = [...rules.values()].every((r) => r.scope?.kind)
const classified = !ownershipProblems.length && complete

Kernel membership counts because it is an authoritative classification input, and a duplicated kernel row is refused while leaving the membership set intact — so every rule still resolves, and a flag that ignored it would call the model classified while resting on a registry the build had already rejected.

A problem outside those sources does not clear it. A rule-ID or enforcement-class failure elsewhere says nothing about whether the contracts are honest, and suppressing Gate 9 for it would hide a real failure behind an unrelated one.

The canonical invariant

classifyRules() returns unresolved — the rules it deliberately left without a scope, having said why — so the model can distinguish a diagnosed failure from a rule that vanished silently, structurally rather than by matching problem strings:

for (const [id, rule] of rules) {
  if (rule.scope?.kind || unresolved.has(id)) continue
  ownershipProblems.push(`[${id}] came out of the rule model with no resolved ownership scope, …`)
}

It runs unconditionally. A rule that falls out of the classifier with nothing said about it is the same bug whether or not something unrelated also failed.

Stable machine keys

The coral:layers registry gained a Key column:

kernel · framework-governance · production-baseline · app-profile · language-binding · runtime-agent-profile

Stated rather than derived, so the other columns can still move: renaming the layer app profile is presentation, renaming the tag {governance} is a tag change, and neither renames what a tool switches on. The kernel layer has neither a tag nor a family to derive from in the first place.

The cell is parsed whole, like the tag cell beside it:

const LAYER_KIND_CELL_RE = /^`([a-z][a-z0-9]*(?:-[a-z0-9]+)*)`$/

Reading it as "strip the backticks, then check what is left" repairs malformed syntax instead of refusing it — `app`-profile normalises to app-profile and is accepted, so the registry would answer for a key nobody wrote. A registry that is the single source of an identifier must not have a shape in which the identifier can be invented for it.

Two complementary guarantees, both tested:

  • existing keys do not silently rename — the six published keys are asserted as a required subset of the taxonomy, which is the only way to protect language-binding (it has zero rules, so no rule-level assertion can reach it, and renaming it alone would otherwise pass every test while rules.md regenerated cleanly);
  • new keys stay addable — a seventh layer is a seventh row, with no JavaScript enum to extend, asserted end-to-end through the rendered page.

CONVENTIONS.md and the changelog now say which is which: adding a key is supported, changing a published one is a compatibility break under [VER-2].

The previous internal key, derived from the tag/family, is gone.

Duplicated classification paths removed

  • .vitepress/config.mjs — five parser calls → loadRuleModel(SRC).
  • scripts/rules-index.mjs — the same five → loadRuleModel(SRC).
  • serializeIndex(srcDir, model) — no longer reparses the taxonomy, kernel block or profile registry; reads rule.scope.
  • checkContractScopes(srcDir, rules) — takes canonical rules; the layers argument is gone rather than kept for compatibility.
  • classifyRules returns { scopes, unresolved, problems } and stays a low-level, fixture-testable classifier.
  • groupByScope() is the single grouping behind both the layer tally and the new section, so the two cannot disagree.

scripts/rules-lock.mjs deliberately still uses parseRules alone: the lock records published IDs and enforcement classes, and gating its regeneration on ownership validation would couple two independent failure modes.

Generated rule view

rules.md gained a ## Rules by scope section — the same rules grouped by the layer that owns them rather than by the document that states them, in registry order, with a #### <profile> subsection per profile. Compact: ID, class, defining document. language-binding prints its honest zero. serializeIndex() throws if the groups do not cover every rule exactly once.

The section states its own limits rather than leaving them to whoever edits the prose:

Ownership is one applicability axis, not the whole load decision. A group here says which layer or profile a rule belongs to, and nothing more. Production-baseline rules are narrowed further by scale, as described above: the ones in ARCHITECTURE.md are app-scale and the ones in SYSTEM.md are the baseline when several apps compose, so a repository that ships one app loads part of that group and not the rest.

A test holds that wording and guards against it regressing to a "what it has to load" claim.

The diff to rules.md is 262 insertions, 0 deletions — every existing statement and per-document table is byte-identical.

Tests

scripts/model.test.mjs is new — 36 tests. scripts/layers.test.mjs is updated for the seven-column registry and the new API, keeping its synthetic-vocabulary posture: the fixture taxonomy names nothing Coral names.

The canonical invariant

  • every rule in a clean model has exactly one resolved scope, on fixtures and on the repository;
  • kind, profile and tag stay separate facts; kernel resolves with profile: null, tag: null;
  • missing ownership metadata, an unknown tag and an unregistered profile all fail without producing a null scope;
  • classifyRules() names every rule it leaves unresolved, across all six failure modes;
  • the integrity check stays quiet on a model whose only failure is unrelated.

classified

  • a duplicated kernel row leaves membership intact and every rule scoped, and still clears the flag;
  • a kernel row citing an undefined rule clears it;
  • any rule returning without a scope clears it;
  • a failure outside the ownership sources does not clear it.

The machine key

  • ten malformed cells rejected — app-profile (no span), `app`-profile, ``app-profile``, `app profile`, `app_profile`, `App-Profile`, `-app`, `app-`, `app--profile`, `{app-profile}` — each asserting both the diagnostic and that the row contributed no layer;
  • `app-profile` accepted, yielding kind app-profile; an empty cell is a malformed row;
  • keys validated for uniqueness;
  • renaming a label, and renaming a tag, both leave the machine kind where it was;
  • the six published keys are held in place as a required subset;
  • a synthetic seventh layer added by registry row alone reaches the rendered page.

The generated view

  • the grouping holds every rule exactly once and does not merge two profiles of one layer;
  • serializeIndex() follows a mutated model scope, proving it no longer reparses ownership;
  • the tally and the groups count the same rules;
  • the by-scope intro presents itself as one axis;
  • a synthetic {lang:go} resolves to language-binding / go — the real documents have no language binding, and an untested layer is one that stops working unnoticed.

The real-document tests pin kind and profile only. Tags are deliberately absent: a consistent {baseline}{foundation} migration must leave production-baseline where it is, and asserting the current spelling would make that migration fail a test with nothing to say about it. Kernel's absent tag is still asserted, across the whole kernel — that is a structural invariant, not a spelling.

Validation

Fresh run at 1f8a16d:

Command Result
npm run check:rules 164 tests, 0 failures
npm run rules:index 178 rules; idempotent on re-run
npm test versions OK, 164 tests, vitepress build OK, anchors OK
npm run check:lint pytest 99 passed; coral-lint 0 errors, 0 warnings
npm run rules:lock 178 rules, rules.lock byte-identical

Model: 178 rules, 178 resolved scopes, 0 problems, classified: true. Taxonomy keys: kernel, framework-governance, production-baseline, app-profile, language-binding, runtime-agent-profile. Scope counts reconcile — kernel 9, framework-governance 9, production-baseline 88, app-profile (backend 8, cli 11, gh-action 12, library 13, web 12), language-binding 0, runtime-agent-profile 16 = 178. Surfaces: 97 + 9 + 72 = 178.

Preserved

Still 178 rules. No rule ID added, removed or renumbered. No enforcement class changed. No rule moved between documents. No ownership classification changed. No rule statement changed. Kernel membership and profile membership unchanged. rules.lock byte-identical. VERSION not bumped; the change is recorded under CHANGELOG.md → Unreleased as patch-level.

A rule was `{page, line, class, tags}` and its resolved ownership layer
lived in a separate `Map<ruleId, layer>`, so a consumer holding a rule did
not know what the rule belonged to. `.vitepress/config.mjs`,
`scripts/rules-index.mjs` and `serializeIndex()` each composed parseRules,
parseLayers, parseKernel, parseProfiles and classifyRules to rebuild it.

Add `loadRuleModel(srcDir)`: one composition, returning rules that carry
their own `scope` — `{kind, profile, tag, label, surface, contractScoped}`.
A rule the model cannot resolve is returned without a scope and with a
build error; nothing defaults to the baseline.

Add a Key column to the `coral:layers` registry. The key is the stable
machine identifier a resolved scope reports, stated rather than derived so
renaming a label or a tag does not move it. Validated for shape and
uniqueness; the set stays open, with no list of keys in the tooling.

Remove the parallel composition from both consumers. `serializeIndex()`
takes the model and reads the scope off each rule. `checkContractScopes()`
takes canonical rules and no longer needs a second classification map.

Add a `Rules by scope` section to the generated `rules.md`: the same rules
grouped by layer in registry order, with a subsection per profile. Compact
— ID, class, defining document. The document-oriented tables are unchanged
and `rules.lock` is byte-identical.

178 rules, each with exactly one resolved scope. No rule ID, enforcement
class, ownership classification, kernel membership or profile membership
changed.
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net

…w claim

`classified` ignored `parseKernel()`'s problems, so a duplicated kernel row
— which leaves the membership set intact — let every rule resolve and left
the flag true while the build had already refused the registry the
classification rests on. It now requires all four ownership sources to
parse clean and every rule to carry a resolved scope, and Gate 9 reads
that. A problem outside those sources still does not clear it.

Make the canonical-completeness check unconditional. It waited for an
otherwise clean model, which is when it matters least: a rule that falls
out of the classifier with no diagnostic is the same bug whether or not
something unrelated also failed. `classifyRules()` now returns the rules it
deliberately left unresolved, so the check can tell that from a rule that
vanished without a word instead of matching problem strings.

Match the machine-key cell whole. Stripping backticks and testing what was
left repaired malformed syntax: `` `app`-profile `` normalised to
`app-profile` and was accepted, so the registry answered for a key nobody
wrote.

Stop pinning ownership tags in the real-document tests. They asserted the
current spelling of `{baseline}`, `{governance}` and `{app:cli}` while the
file's own comment said tags are not asserted — and a consistent tag
migration must leave `scope.kind` where it is. `kind` and `profile` are
pinned; kernel's absent tag stays asserted as the structural invariant it
is. Tag resolution is covered against synthetic fixtures.

Narrow the generated section's claim. Ownership is one applicability axis:
production-baseline rules are still narrowed by app versus system scale, so
a group there is not a load set. The section says so, and a test holds the
wording.

No rule semantics change. Still 178 rules, `rules.lock` byte-identical.
@MirzaMerdovic

Copy link
Copy Markdown
Contributor Author

All five findings addressed in 0f006c2.

1. model.classified ignored kernelProblems

Taken the preferred route. classified now means the complete ownership model is valid:

const complete = [...rules.values()].every((r) => r.scope?.kind)
const classified = !ownershipProblems.length && complete

where ownershipProblems is taxonomy + kernel + profiles + classification, plus any canonical-integrity problem. Gate 9 reads that.

The duplicate-kernel-row case is exactly why it needed fixing, and it is now the test: parseKernel() refuses the row while returning the same membership set, so every rule still resolves and scopes is complete — and classified is false.

New tests:

  • a duplicated kernel row leaves membership intact, every rule scoped, and classified === false;
  • a kernel row citing an undefined rule also clears it;
  • a rule that came back without a scope clears it;
  • a problem outside the ownership sources (a rule carrying two enforcement classes) does not clear it — so the flag does not quietly become "nothing at all went wrong", and a contract failure cannot hide behind an unrelated one.

2. Real-document tests pinned ownership tags

They did, and the file's own comment said they did not. KIND_OF is now SCOPE_OF and pins kind and profile only:

const SCOPE_OF = {
  'MODEL-1': ['kernel', null],
  'CONC-1': ['production-baseline', null],
  'VER-1': ['framework-governance', null],
  'CLI-6': ['app-profile', 'cli'],
  'BE-1': ['app-profile', 'backend'],
  'ORCH-4': ['runtime-agent-profile', null],
}

A consistent {baseline}{foundation} migration now leaves these passing, which is the whole point of the key column.

Kernel's tag === null is kept and moved into its own test asserting it across the whole kernel — a structural invariant, not a spelling. Tag resolution and tag renaming remain covered against the synthetic fixtures.

3. Rules by scope overclaimed

The generated intro no longer describes itself as the load decision, and adds a paragraph naming the second axis:

The same rules, grouped by the ownership layer that owns them rather than by the document that states them. …

Ownership is one applicability axis, not the whole load decision. A group here says which layer or profile a rule belongs to, and nothing more. Production-baseline rules are narrowed further by scale, as described above: the ones in ARCHITECTURE.md are app-scale and the ones in SYSTEM.md are the baseline when several apps compose, so a repository that ships one app loads part of that group and not the rest.

A test asserts both sentences are present and that the intro does not regress to a "what it has to load" claim. The section itself is unchanged. The same overclaim in the CHANGELOG.md entry is corrected too.

4. Machine-key cell parsing

The cell is now matched whole, like the tag cell beside it:

const LAYER_KIND_CELL_RE = /^`([a-z][a-z0-9]*(?:-[a-z0-9]+)*)`$/

Code spans are required — that is what the canonical table writes — and the key is the captured token. .replace(//g, '')` is gone.

Tests reject app-profile (no span), `app`-profile, ``app-profile``, `app profile`, `app_profile`, `App-Profile`, `-app`, `app-`, `app--profile` and `{app-profile}`, each asserting both the diagnostic and that the row contributed no layer. `app-profile` is accepted and yields kind app-profile; an empty cell is still a malformed row. Uniqueness and the taxonomy's other checks are unchanged.

CONVENTIONS.md now documents the grammar: written as a code span, matched whole, refused rather than tidied.

5. Canonical invariant no longer gated on problems.length

classifyRules() returns unresolved — the rules it deliberately left without a scope, having said why:

for (const [id, rule] of rules) {
  if (rule.scope?.kind || unresolved.has(id)) continue
  ownershipProblems.push(`[${id}] came out of the rule model with no resolved ownership scope, …`)
}

Returned rather than inferred from the problem strings, so the check is structural and does not become a third parser. It runs unconditionally, feeds complete, and feeds classified.

Two tests: classifyRules() names every rule it leaves unresolved across all six failure modes (no tag, two tags, unknown tag, unregistered profile, wrong document, kernel rule carrying a tag), and the integrity check stays quiet on a model whose only failure is unrelated.

Validation

Command Result
npm run check:rules 163 tests, 0 failures
npm run rules:index 178 rules; idempotent on re-run
npm test versions OK, 163 tests, vitepress build OK, anchors OK
npm run check:lint pytest 99 passed; coral-lint 0 errors, 0 warnings
npm run rules:lock 178 rules, rules.lock byte-identical

Verified explicitly:

  1. loadRuleModel() → 178 rules, 178 resolved scopes, 0 problems, classified: true.
  2. A malformed kernel registry cannot produce classified === true.
  3. A synthetic tag rename preserves scope.kind.
  4. The real-document tests no longer freeze {baseline}, {governance} or {app:cli}.
  5. A malformed layer-key cell cannot be normalised into a valid key.
  6. The generated scope view no longer implies ownership alone determines the load set.
  7. rules.lock byte-identical against main.
  8. npm run rules:index idempotent.

rules.md against main is still additive only — 262 insertions, 0 deletions. Ownership assignments, kernel membership, profile membership, enforcement classes, rule wording and rule locations are unchanged, as are the canonical rule.scope design, loadRuleModel() as the composition point, classifyRules() as a low-level fixture-testable function, the Rules by scope section, and the open taxonomy.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net

The rule-level scope assertions reach five of the six layers and cannot
reach `language-binding`, which has no rules. Renaming only that key would
have moved a documented stable identifier past every test on the branch
while `rules.md` regenerated cleanly, and a consumer switching on
`scope.kind === 'language-binding'` would have broken with nothing failing.
An empty layer is still a published one.

Assert the published keys as a required subset of the taxonomy, so a
seventh layer stays a registry edit — the synthetic test that proves it
needs no JavaScript change is unaffected. Existing keys do not silently
rename; new keys stay addable.

Say so in CONVENTIONS.md and CHANGELOG.md: adding a key is supported,
changing a published one is a compatibility break under [VER-2].
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net

CONVENTIONS.md claimed the six layers are not listed anywhere but the
registry. That stopped being literally true when the published machine keys
gained a compatibility guard: scripts/model.test.mjs holds them as a
required subset so an existing key cannot be renamed unnoticed.

The distinction worth stating is authority versus compatibility lock. The
table is the authoritative definition of which layers exist. No production
tooling enumerates them, so adding one stays a registry change. The tests
pin only keys already published, and that subset does not grow when a layer
is added.

Prose only — no generated output changes.
@MirzaMerdovic

Copy link
Copy Markdown
Contributor Author

Prose cleanup done in 77fbf31. Documentation only — no implementation change, and no generated output moved.

CONVENTIONS.md

The claim that the six layers are listed nowhere else stopped being literally true when the published keys gained a compatibility guard. Replaced with the authority/compatibility-lock distinction:

This table is the authoritative taxonomy, and the build reads it. The tooling carries no exhaustive list of valid layers: adding one is a registry change, never a JavaScript vocabulary change, because two lists of one vocabulary is how a renamed layer keeps passing every check. The build's tests do hold a required subset of the machine keys already published, so an existing key cannot be silently renamed — a compatibility lock, not a second authority, and one that does not have to grow when a layer is added.

And the later sentence, made equally precise:

A seventh layer is a seventh row — no exhaustive key list in the tooling has to be extended alongside it, and the compatibility lock on the published keys says nothing about a key that is new.

scripts/rules.mjs

Aligned the LAYER_KIND_CELL_RE header comment, which made the same "nothing enumerates the valid ones" claim without saying where the pin actually lives:

The set stays open — a seventh layer is a seventh row, and nothing here enumerates the valid ones. What IS pinned lives in the tests: the keys already published are held as a required subset, so an existing one cannot be renamed unnoticed while a new one still needs no code change.

Validation

Fresh run at 77fbf31:

Command Result
npm run check:rules 164 tests, 0 failures
npm run rules:index 178 rules; idempotent on re-run
npm test versions OK, 164 tests, vitepress build OK, anchors OK
npm run check:lint pytest 99 passed; coral-lint 0 errors, 0 warnings
npm run rules:lock 178 rules, rules.lock byte-identical

rules.md is unchanged by the edit — the generated prose does not depend on that section. Against main it remains 262 insertions, 0 deletions.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net

LAYER_KIND_RE was replaced by LAYER_KIND_CELL_RE when the key cell became a
whole-cell match; the typedef comment still named the old one.
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net

@MirzaMerdovic
MirzaMerdovic merged commit 8278fd2 into main Sep 5, 2026
2 checks passed
@MirzaMerdovic
MirzaMerdovic deleted the feat/canonical-rule-scope branch September 6, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant