Make resolved ownership scope part of the canonical rule model - #7
Conversation
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.
|
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.
|
All five findings addressed in 1.
|
| 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:
loadRuleModel()→ 178 rules, 178 resolved scopes, 0 problems,classified: true.- A malformed kernel registry cannot produce
classified === true. - A synthetic tag rename preserves
scope.kind. - The real-document tests no longer freeze
{baseline},{governance}or{app:cli}. - A malformed layer-key cell cannot be normalised into a valid key.
- The generated scope view no longer implies ownership alone determines the load set.
rules.lockbyte-identical againstmain.npm run rules:indexidempotent.
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.
|
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].
|
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.
|
Prose cleanup done in
|
| 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.
|
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.
|
Azure Static Web Apps: Your stage site is ready! Visit it here: https://gray-hill-09bb08b03-7.westeurope.7.azurestaticapps.net |
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 separateMap<ruleId, layer>. A consumer holding a rule did not know what the rule belonged to — it had to callparseRules,parseLayers,parseKernel,parseProfilesandclassifyRulesin the right order and carry the second map alongside..vitepress/config.mjsdid that,scripts/rules-index.mjsdid it again, andserializeIndex()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:[CLI-6]is kindapp-profile, profilecli.[MODEL-1]is kindkernelwith 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.classifiedThe 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: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()returnsunresolved— 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: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:layersregistry gained a Key column:Stated rather than derived, so the other columns can still move: renaming the layer
app profileis 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:
Reading it as "strip the backticks, then check what is left" repairs malformed syntax instead of refusing it —
`app`-profilenormalises toapp-profileand 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:
language-binding(it has zero rules, so no rule-level assertion can reach it, and renaming it alone would otherwise pass every test whilerules.mdregenerated cleanly);CONVENTIONS.mdand 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; readsrule.scope.checkContractScopes(srcDir, rules)— takes canonical rules; thelayersargument is gone rather than kept for compatibility.classifyRulesreturns{ 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.mjsdeliberately still usesparseRulesalone: 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.mdgained a## Rules by scopesection — 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-bindingprints 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:
A test holds that wording and guards against it regressing to a "what it has to load" claim.
The diff to
rules.mdis 262 insertions, 0 deletions — every existing statement and per-document table is byte-identical.Tests
scripts/model.test.mjsis new — 36 tests.scripts/layers.test.mjsis 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
profile: null, tag: null;classifyRules()names every rule it leaves unresolved, across all six failure modes;classifiedThe machine key
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 kindapp-profile; an empty cell is a malformed row;The generated view
serializeIndex()follows a mutated model scope, proving it no longer reparses ownership;{lang:go}resolves tolanguage-binding/go— the real documents have no language binding, and an untested layer is one that stops working unnoticed.The real-document tests pin
kindandprofileonly. Tags are deliberately absent: a consistent{baseline}→{foundation}migration must leaveproduction-baselinewhere 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:npm run check:rulesnpm run rules:indexnpm testnpm run check:lintnpm run rules:lockrules.lockbyte-identicalModel: 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.lockbyte-identical.VERSIONnot bumped; the change is recorded underCHANGELOG.md→ Unreleased as patch-level.