diff --git a/decisions/decisions.md b/decisions/decisions.md index 61c2659..db728ab 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -363,3 +363,91 @@ dereferencing a judgment. Argument: `docs/StatuteV08Review.md` F1. *Superseded 2026-09-21:* B4 was ruled in `GH-DEC-2026-017` §5 and the field was removed under `KG-IN-0007`; the validated-against version now lives in `scripts/check_layer_conformance.py` (`VALIDATED_AGAINST`). + +## KG-DEC-2026-005 — Apply GH-DEC-2026-020 to the checker; assent to A9, A10, A11 and A13, and return A12 r2 with one finding + +```yaml +id: KG-DEC-2026-005 +kind: decision +title: Apply GH-DEC-2026-020 to the checker; assent to A9, A10, A11 and A13, and + return A12 r2 with one finding +status: resolved +owner: codex +repo: kings-guard +standard: net-kingdom/canon/standards/security-layer-model_v0.8.md +source: gate-house docs/amendments/v0.8-section-11-declaration-amendments.md v0.2 (gate-house@104f3fc) +related: +- gate-house GH-DEC-2026-020 +- gate-house GH-DEC-2026-017 +- gate-house GH-WP-0004-T09 +- KG-DEC-2026-004 +affects: +- kings-guard +- gate-house +created: '2026-09-21' +updated: '2026-09-21' +decided_by: codex +disposition: 'A9 approved; A10 approved; A11 approved; A12 r2 revised; A13 approved' +``` + +## Context + +`gate-house` circulated A9–A13 for assent (message `09a2d1cb`), then ruled what A12 +reaches in `GH-DEC-2026-020` and re-circulated A12 as A12 r2 (message `54080f87`), +asking again because assent to A12 as first circulated does not carry over. Both were +read from gate-house's committed files at `104f3fc`, not from the messages; the ruling +and the message agree. + +`GH-DEC-2026-020` §4 adopts this repository's `VALIDATED_AGAINST` constant as the +reference pattern and requires two changes of us: print the run's scope, and widen A12 +detection from the key name `standard_version` to any version in any key or value. + +## Applied + +`scripts/check_layer_conformance.py` now prints `checking against:` and `scope:` as the +first two lines of **every** run, before anything can exit — including a malformed- +declaration failure — and the OK line carries both. A12 is enforced over every key and +value of the `INTENT.md` frontmatter and `layer.yaml`: any version key other than +`schema_version`, a versioned standard or companion path or file name, and any version +token in `standard:`, `companion:` or `framework:`. Comments are not read. Stance, +claims and classification maps are never read for A12; this repository has none. +Tests in `tests/test_layer_conformance.py` fail if a versioned `standard:` path or a +`companion_version` comes back. + +Neither declaration form changed. Both were already clean under r2: `standard:` in +`INTENT.md` was de-versioned under `GH-DEC-2026-017`, and there is no companion version. + +## Dispositions + +- **A9 — approved.** The four-token closed vocabulary with a mandatory fold is what + `KG-IN-0007` needed and what `GH-DEC-2026-017` §2 already governs here. +- **A10 — approved.** A marking rather than a reading is the right fix. Under it our §4 + row is *unassessed* until marked, and a run reports it so — which is the answer + `KG-IN-0008` asked for: the repository that benefits from the narrow reading does not + settle whether published posture is evidence. +- **A11 — approved.** One finding on wording, not substance: *"A run over §4 and a run + over every repository carrying a declaration"* names two estate scopes. Our checker's + run is a third, one repository, and prints so. The scope sentence should admit a + single-repository run rather than leave it to read as neither. +- **A13 — approved.** +- **A12 r2 — revised, one finding.** *"No key or value of either carries a version of + this standard"* reaches a revision **citation** in prose as literally as it reaches a + pin. `layer.yaml` carries three: `v0.5` in a non-Tooling note, and `v0.6` twice in gap + records (`owner_status: access-engine declined (v0.6 §13)`). They are provenance — + where a ruling was made, in section numbering that has since moved — and are not + readable as a validity condition, which is §5's ground. But the text as written reaches + them, and an implementer who reads "any value" literally must fail them. Proposed + addition: *"A version reached is one carried as a pin: a version key, or a version in a + path, file name, or identity-bearing value naming this standard or its companion. A + citation of an earlier revision in a record's prose is provenance and is not reached."* + **kings-guard benefits from the narrower reading**, so it is not ours to settle. Until + ruled, the checker reports each citation on every run as `NOTE (A12 r2 reach unruled)` + and does not fail on it; if the wider reading is ruled, we reword the three values and + the checker fails on them. We did not measure other repositories for the same shape. + +## Consequences + +- `VALIDATED_AGAINST` still names v0.7, the accepted text the checker was validated + against. It moves when the checker is re-validated after the v0.8 flip, not before. +- The pre-existing ruff E501 at the no_standing_credential message in the checker is + outside `make lint`'s scope and is not touched here. diff --git a/scripts/check_layer_conformance.py b/scripts/check_layer_conformance.py index c172e4e..6e9d5b6 100644 --- a/scripts/check_layer_conformance.py +++ b/scripts/check_layer_conformance.py @@ -21,10 +21,24 @@ frontmatter `layer:` key governs; layer.yaml is a derived artifact that must be marked derived, name INTENT.md, and agree with it. Layer comparison ASCII-folds case against §3's closed four-token vocabulary, so `Staff` and `staff` agree and neither file is re-spelled. A disagreement that survives the fold is reported as -a finding, not resolved by precedence. Neither form may carry a -`standard_version`; the version this check was built and validated against is -the checker's own, VALIDATED_AGAINST below, and is printed in every run's output -— this script's output is the derived conformance record A12 points to. +a finding, not resolved by precedence. Neither form may carry a version of the +standard or its companion in any key or value (A12 r2, GH-DEC-2026-020 §1–§2): +not only a key named `standard_version`, but `companion_version`, any other +version key, and a version-bearing path or file name such as a `standard:` of +`…security-layer-model_v0.7.md`. Comments are not read (YAML drops them) and +`schema_version` is the sidecar's own schema, so neither is reached. Only +INTENT.md frontmatter and layer.yaml are read for A12: stance, claims and +classification maps are not declarations and this run never applies A12 to them +(GH-DEC-2026-020 §3). + +A bare revision citation in a prose value — `layer.yaml` cites "v0.6 §13" as +provenance in a gap record — is reported on every run, not failed: whether A12 r2's +"any value" reaches a citation rather than a pin is raised with gate-house by +kings-guard (see decisions/decisions.md KG-DEC-2026-005), not settled here. + +Every run prints, first, the standard version this check was built and validated +against (VALIDATED_AGAINST) and the scope it ranges over (SCOPE), including runs +that fail (GH-DEC-2026-020 §4). A run is sufficient; nothing durable is emitted. Review dates are reported, never enforced: a date-triggered failure breaks the build on a calendar day with no code change. @@ -55,6 +69,29 @@ INTENT = ROOT / "INTENT.md" # the checker is re-validated against a newer accepted text. VALIDATED_AGAINST = "net-kingdom/canon/standards/security-layer-model_v0.7.md" +# The scope every run ranges over, printed on every run (GH-DEC-2026-020 §4, A11). +# This is a single-repository run, not an estate run: it grades kings-guard, a §4 +# row, and nothing else. +SCOPE = ( + "kings-guard only (§4 row, Staff): INTENT.md frontmatter, layer.yaml, " + "src/kings_guard, repository tree for credential files" +) + +# A12 r2 detection. A key naming a version is a pin, except the sidecar's own +# schema_version. A value naming the standard or companion with a version, or any +# versioned file name, is a pin. In identity-bearing keys any version token is a +# pin. A bare "vN.N" elsewhere in a value is a citation: reported, not failed. +A12_EXEMPT_KEYS = {"schema_version"} +A12_IDENTITY_KEYS = {"standard", "companion", "framework"} +A12_PIN_VALUE = re.compile( + r"""(?ix) + (?:security[-_ ]?layer[-_ ]?model|security[-_ ]?companion)[^\s'"]*?[-_.]v?\d+(?:\.\d+)+ + | [-_]v\d+(?:\.\d+)*\.(?:md|ya?ml|json)\b + """ +) +A12_VERSION_TOKEN = re.compile(r"\bv?\d+\.\d+(?:\.\d+)*\b") +A12_CITATION = re.compile(r"\bv\d+\.\d+\b") + # §3's closed layer vocabulary (A9): four tokens, compared ASCII case-insensitively. LAYER_VOCABULARY = {"taxonomy", "tooling", "engine", "staff"} EXPECTED_LAYER = "staff" # kings-guard's own §4 row, folded @@ -105,13 +142,60 @@ def fold(value: object) -> str: return str(value).encode("ascii", "replace").decode("ascii").lower() +def a12_findings(node: object, where: str) -> tuple[list[str], list[str]]: + """Every key and value of a declaration form, walked (A12 r2). + + Returns (pins, citations). Pins fail the run; citations are reported. + """ + pins: list[str] = [] + citations: list[str] = [] + + def walk(value: object, path: str, key: str | None) -> None: + if isinstance(value, dict): + for k, v in value.items(): + k_str = str(k) + sub = f"{path}.{k_str}" if path else k_str + if "version" in k_str.lower() and k_str not in A12_EXEMPT_KEYS: + pins.append(f"{where}: key '{sub}' carries a version") + if k_str in A12_EXEMPT_KEYS: + continue + walk(v, sub, k_str) + return + if isinstance(value, list): + for i, v in enumerate(value): + walk(v, f"{path}[{i}]", key) + return + if value is None or isinstance(value, bool): + return + text = str(value) + if A12_PIN_VALUE.search(text) or ( + key in A12_IDENTITY_KEYS and A12_VERSION_TOKEN.search(text) + ): + pins.append(f"{where}: '{path}' = {text!r} names a versioned standard") + elif A12_CITATION.search(text): + match = A12_CITATION.search(text).group(0) + citations.append(f"{where}: '{path}' cites {match} in prose") + + walk(node, "", None) + return pins, citations + + +def _refuse_pins(pins: list[str]) -> None: + if pins: + _malformed( + "a layer declaration MUST NOT carry a standard or companion version " + "in any key or value (§11 as amended by A12 r2, GH-DEC-2026-020 §1–§2):\n " + + "\n ".join(pins) + ) + + def _malformed(message: str) -> None: print(f"FAIL: {message}", file=sys.stderr) raise SystemExit(2) -def load_governing_layer() -> str: - """The declaration: INTENT.md frontmatter `layer:` (§11, GH-DEC-2026-017 §1).""" +def load_intent_frontmatter() -> dict: + """INTENT.md's frontmatter, parsed; the governing declaration form (§11).""" if not INTENT.exists(): _malformed(f"no {INTENT.name} — §11's governing declaration form") lines = INTENT.read_text().splitlines() @@ -125,13 +209,17 @@ def load_governing_layer() -> str: front = yaml.safe_load("\n".join(lines[1:end])) or {} except yaml.YAMLError as exc: _malformed(f"{INTENT.name} frontmatter is not parseable: {exc}") + if not isinstance(front, dict): + _malformed(f"{INTENT.name} frontmatter is not a mapping") + return front + + +def load_governing_layer() -> str: + """The declaration: INTENT.md frontmatter `layer:` (§11, GH-DEC-2026-017 §1).""" + front = load_intent_frontmatter() if "layer" not in front: _malformed(f"{INTENT.name} frontmatter has no 'layer' key — §11's declaration") - if "standard_version" in front: - _malformed( - f"{INTENT.name} frontmatter carries 'standard_version' — a layer " - "declaration MUST NOT carry a standard version (§11 as amended by A12)" - ) + _refuse_pins(a12_findings(front, f"{INTENT.name} frontmatter")[0]) layer = front["layer"] if fold(layer) not in LAYER_VOCABULARY: _malformed( @@ -162,11 +250,7 @@ def load_declaration() -> dict: f"{DECL.name} derives from {data['derived_from']!r}; §11 names INTENT.md " "as the governing declaration" ) - if "standard_version" in data: - _malformed( - f"{DECL.name} carries 'standard_version' — a layer declaration MUST NOT " - "carry a standard version (§11 as amended by A12)" - ) + _refuse_pins(a12_findings(data, DECL.name)[0]) if fold(data["layer"]) not in LAYER_VOCABULARY: _malformed( f"{DECL.name} declares layer {data['layer']!r}, outside §3's closed " @@ -231,6 +315,11 @@ def main() -> int: parser.add_argument("--report", action="store_true", help="print the declaration summary") args = parser.parse_args() + # Every run, pass or fail, states what it checks against and over what + # (GH-DEC-2026-020 §4). Printed before anything can exit. + print(f"checking against: {VALIDATED_AGAINST}") + print(f"scope: {SCOPE}") + governing = load_governing_layer() decl = load_declaration() declared = {c.get("id") for c in decl.get("tooling_contacts") or []} @@ -260,6 +349,13 @@ def main() -> int: form = meta.get("form", "unspecified") if isinstance(meta, dict) else "unspecified" print(f" - {name}: {form} (claimed={rules.get(name)})") + citations = ( + a12_findings(load_intent_frontmatter(), f"{INTENT.name} frontmatter")[1] + + a12_findings(decl, DECL.name)[1] + ) + for citation in citations: + print(f"NOTE (A12 r2 reach unruled, reported not failed): {citation}") + if forms_disagree(governing, decl["layer"]): print("", file=sys.stderr) print("FAIL: the two declaration forms disagree (§11, A11)", file=sys.stderr) @@ -303,7 +399,8 @@ def main() -> int: print( f"OK: no direct Tooling client in {SRC.relative_to(ROOT)}; " "no standing credential (§5, §11, §3.4 rule 1); " - f"declaration forms agree; validated against {VALIDATED_AGAINST}" + "declaration forms agree; no standard or companion version in " + f"either form; validated against {VALIDATED_AGAINST}; scope: {SCOPE}" ) return 0 diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index c1a80fa..004be5a 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -75,6 +75,95 @@ def test_no_standard_version_in_either_form(): ) +def test_no_version_anywhere_in_either_form(): + """A12 r2 / GH-DEC-2026-020 §1–§2: content, not a key name. + + Fails if a versioned `standard:` path or a `companion_version` comes back, or + any other version pin in any key or value of either declaration form. + """ + module = _load_checker() + front = _intent_frontmatter() + assert "companion_version" not in front + assert "companion_version" not in yaml.safe_load(DECL.read_text()) + assert "_v0" not in str(front.get("standard", "")) + assert module.a12_findings(front, "INTENT.md")[0] == [] + assert module.a12_findings(yaml.safe_load(DECL.read_text()), "layer.yaml")[0] == [] + + +@pytest.mark.parametrize( + "front_extra", + [ + "standard: net-kingdom/canon/standards/security-layer-model_v0.7.md", + "standard: security-layer-model 0.8", + "companion: net-kingdom/SECURITY-COMPANION_v0.2.md", + 'companion_version: "0.2"', + 'standard_version: "0.7"', + 'spec_version: "0.7"', + ], +) +def test_checker_rejects_a_version_pin_in_intent_frontmatter(tmp_path, monkeypatch, front_extra): + module = _load_checker() + front = {k: v for k, v in _intent_frontmatter().items() if k != "standard"} + intent = tmp_path / "INTENT.md" + intent.write_text("---\n" + yaml.safe_dump(front) + front_extra + "\n---\n\n# INTENT\n") + monkeypatch.setattr(module, "INTENT", intent) + with pytest.raises(SystemExit) as exc: + module.load_governing_layer() + assert exc.value.code == 2 + + +def test_checker_rejects_companion_version_in_the_sidecar(tmp_path, monkeypatch): + module = _load_checker() + decl = tmp_path / "layer.yaml" + decl.write_text(DECL.read_text() + '\ncompanion_version: "0.2"\n') + monkeypatch.setattr(module, "DECL", decl) + monkeypatch.setattr(module, "ROOT", tmp_path) + with pytest.raises(SystemExit) as exc: + module.load_declaration() + assert exc.value.code == 2 + + +def test_schema_version_and_comments_are_not_reached(): + """GH-DEC-2026-020 §1: the sidecar's own schema version is not the standard's.""" + module = _load_checker() + pins, _ = module.a12_findings( + yaml.safe_load('# Framework: security-layer-model_v0.7.md\nschema_version: "0.1"\n'), + "x", + ) + assert pins == [] + + +def test_a_prose_citation_is_reported_not_failed(): + """A revision citation in a gap record is provenance; its reach is unruled.""" + module = _load_checker() + pins, citations = module.a12_findings({"owner_status": "declined (v0.6 §13)"}, "x") + assert pins == [] and citations + + +def test_every_run_prints_version_and_scope_even_when_it_fails(tmp_path): + """GH-DEC-2026-020 §4: the version belongs to the run, and so does the scope.""" + module = _load_checker() + ok = _run() + assert module.VALIDATED_AGAINST in ok.stdout and module.SCOPE in ok.stdout + ok_line = [line for line in ok.stdout.splitlines() if line.startswith("OK:")][0] + assert module.VALIDATED_AGAINST in ok_line and module.SCOPE in ok_line + report = _run("--report") + assert module.VALIDATED_AGAINST in report.stdout and module.SCOPE in report.stdout + # A failing run: copy the checker beside a declaration carrying a pin. + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts" / SCRIPT.name).write_text(SCRIPT.read_text()) + (tmp_path / "INTENT.md").write_text( + "---\nlayer: Staff\nstandard: security-layer-model_v0.7.md\n---\n" + ) + failed = subprocess.run( + [sys.executable, str(tmp_path / "scripts" / SCRIPT.name)], + capture_output=True, + text=True, + ) + assert failed.returncode == 2 + assert module.VALIDATED_AGAINST in failed.stdout and module.SCOPE in failed.stdout + + def test_the_two_forms_agree_after_folding_case(): """`Staff` in INTENT.md and `staff` in layer.yaml are one value, not a finding.""" module = _load_checker()