From 5b1a5086108487e6a50e7ae8eca2a2a2870af71d Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 10 Sep 2026 08:02:10 +0200 Subject: [PATCH] docs: mark the unknown cell, measure the coverage we asked to publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-house ruled the v0.8 assent round (GH-DEC-2026-011, net-kingdom@64394e9): ask 1 declined, ask 2 adopted. Ask 1's refusal is accepted without reservation and the reason is better than the ask -- a sanctioned transitional fail_open is indistinguishable at runtime from the stance the rule forbids, and would make the rule optional at the only moment it costs anything. Ask 2 gave §13.1 a Coverage column with this repo's figures as its first entries. Since we asked for the column, we owe it accuracy: scripts/report_coverage.py measures both populations from the artifacts the runtime uses (reusing the workload-join build rather than re-deriving it), and a test asserts pep-stance.yaml's published block equals what it measures. A hand-counted number in a register that explicitly does not recompute it decays silently, and a stale figure beside a marked cell is worse than the blank the other four rows carry. pep-stance.yaml marks the unknown cell inline as a declared gap -- assent, the measured reason for not flipping, the declined ask, WARDEN-WP-0040 as route -- and a second test keeps it marked while it is fail_open, failing when it is flipped. standard_version stays 0.7 because that is what binds; v0.8 is proposed, so it gains standard_version_reviewed rather than pre-adopting. Separately, gate-house corrected GH-DEC-2026-008: the claim/decision digest comparison it originally required is unimplementable and a fail-closed consumer obeying it would have denied permanently. We had never copied the wording, so nothing to unwind -- but everything they have sent about this lane was living in an inbox thread, a bad home for a correction that only matters when someone finally wires the consume. Now wiki/ApprovalConsumption.md, leading with "nothing is wired", carrying the corrected target and the attribution gap that digest matching does not discharge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013EPuTc18FjU5WFqoSEKH3C Assistant: claude-code Assistant-Model: opus Assistant-Process: 1276224@bnt-lap001 Assistant-Session: 426ec497-e1c4-4dd3-b417-dfce1ca1dbc3 --- WORK-RECORDS.md | 2 +- history/2026-09-09-layer-model-v08-review.md | 56 ++++++++ pep-stance.yaml | 44 +++++- scripts/report_coverage.py | 129 ++++++++++++++++++ tests/test_layer_conformance.py | 49 +++++++ wiki/ApprovalConsumption.md | 77 +++++++++++ wiki/PolicyGatedSigning.md | 8 ++ ...-0040-unknown-zone-fail-closed-adoption.md | 25 ++++ 8 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 scripts/report_coverage.py create mode 100644 wiki/ApprovalConsumption.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index fa1ba40..6334c76 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -35,7 +35,7 @@ | workplan | WARDEN-WP-0035 | finished | — | workplans/WARDEN-WP-0035-policy-nexus-forgejo-source-read-route.md | | workplan | WARDEN-WP-0036 | finished | — | workplans/WARDEN-WP-0036-attended-login-openbao-output.md | | workplan | WARDEN-WP-0037 | active | — | workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md | -| workplan | WARDEN-WP-0038 | proposed | — | workplans/WARDEN-WP-0038-plan-mutation-intent.md | +| workplan | WARDEN-WP-0038 | finished | — | workplans/WARDEN-WP-0038-plan-mutation-intent.md | | workplan | WARDEN-WP-0039 | blocked | — | workplans/WARDEN-WP-0039-explicit-policy-refusal.md | | workplan | WARDEN-WP-0040 | proposed | — | workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md | | task | WARDEN-WP-ADHOC-2026-06-27-T01 | done | — | workplans/ADHOC-2026-06-27.md | diff --git a/history/2026-09-09-layer-model-v08-review.md b/history/2026-09-09-layer-model-v08-review.md index bc04aa6..ac4ddd8 100644 --- a/history/2026-09-09-layer-model-v08-review.md +++ b/history/2026-09-09-layer-model-v08-review.md @@ -117,3 +117,59 @@ catalog stage is an accurate statement about their control, and making them assert a zone they cannot know would trade a real fact for a conformant fiction. The unavailability of cross-axis aggregation is better recorded than papered over, and §13.1 now says so. + +--- + +## Ruling — GH-DEC-2026-011, 2026-09-09 + +gate-house closed the assent round the same day: **ask 1 declined, ask 2 +adopted**, standard amended at `net-kingdom@64394e9`, dispositioned in +`gate-house/docs/conformance/2026-09-06-v08-assent-round.md`. + +**Ask 1 (a dated transitional `unknown: fail_open`) — declined**, and the reason +is better than the ask. A sanctioned transitional `fail_open` is +*indistinguishable at runtime* from the stance the rule forbids, and it would make +the rule optional at the moment of adoption — the only moment it costs anything. +Dating an intention to stop being permissive does not make `unknown` any more +expensive for an attacker to induce while the transition runs. Accepted without +reservation; the argument defeats the ask on its own terms. + +**Ask 2 (coverage published beside stance) — adopted.** §13.1 now carries a +Coverage column and this repo's figures are its first entries: *signing targets +0/3 resolved; routing lanes 3/21 resolved (2026-09-09, self-measured)*. Three +guards came with it, all of which we would have wanted: coverage does not soften +the stance, does not gate it, and never makes a non-conformant cell conformant; a +blank means "not reported" and never "complete"; and the decision carries an +explicit reversal condition — if a row is ever argued to be conformant *because* +its coverage is low, the column is doing harm and comes out. + +That reversal condition is the right shape and it binds us first, since the column +exists because we asked. Our row reads non-conformant **and** low-coverage; those +are two facts, and the second is not a defence of the first. + +**The deadlock was recorded rather than routed around.** §6.4 obligation 3 now +states that a stricter stance is not a licence to manufacture the membership that +makes it survivable — where a scope is unknown because another repository has +published no workload-identity declaration, the consumer MUST NOT infer one. That +is `ADR-0009` rule 3 promoted into the standard at exactly the point where the +pressure lands, which is more than we asked for. + +### What we did in response + +- `pep-stance.yaml` marks the `unknown` cell as a **declared gap** inline, with + the assent, the measured reason for not flipping, the declined ask, and + `WARDEN-WP-0040` as its route. The stance value is unchanged — v0.8 is still + `proposed` and v0.7 remains in force, so `standard_version` stays `0.7` with a + new `standard_version_reviewed: 0.8`. +- The coverage figures are published in `pep-stance.yaml` and, because we asked + for the column, **measured rather than hand-counted**: + `scripts/report_coverage.py` derives both populations from the artifacts the + runtime uses, reusing `report_workload_join.build` rather than re-deriving the + join. `tests/test_layer_conformance.py` asserts the published block equals the + measured one, and asserts the cell stays marked while it is `fail_open`. + + A hand-counted figure in a register that explicitly does not recompute it would + decay silently, and a stale number beside a marked cell is worse than the blank + the other four rows carry — a blank at least reads as "not reported". +- `WARDEN-WP-0040` T04 is closed; T01–T03 stand unchanged. gate-house confirmed + they are not asking for the cell to be flipped before coverage exists. diff --git a/pep-stance.yaml b/pep-stance.yaml index 6364a80..6262211 100644 --- a/pep-stance.yaml +++ b/pep-stance.yaml @@ -18,7 +18,13 @@ schema_version: "0.1" framework: netkingdom-security-layer-model +# v0.7 is the accepted standard and the one in force. v0.8 is `proposed`: its +# assent round closed 2026-09-09 (GH-DEC-2026-011) with nine corrections applied, +# but the version is not yet accepted, so this declaration stays pinned to what +# binds. The one cell v0.8 would change is marked inline below rather than +# silently pre-adopted. standard_version: "0.7" +standard_version_reviewed: "0.8" # reviewed and assented; see history/2026-09-09-layer-model-v08-review.md repository: ops-warden pep_shape: true declared_by: docs/adr/ADR-0009 @@ -36,7 +42,26 @@ stance: z2-protected: fail_open z2-continuity: fail_open z3-critical: fail_closed - unknown: fail_open # versioned build profile (ADR-0009); explicit, never inferred + # DECLARED GAP under security-layer-model v0.8 §6.4 obligation 3 (GH-DEC-2026-011, + # net-kingdom@64394e9): unknown is not a zone and MUST resolve to fail_closed. + # ops-warden ASSENTED to that rule — we went looking for the §5.1 read-only + # diagnostic its reversal clause predicts and do not have one, because this map + # governs `warden sign`, a credential-issuing side effect. + # + # The cell has not been flipped, and the reason is measured rather than argued: + # 0 of 3 signing targets resolve to a zone, so converting today would fail closed + # on essentially every certificate whenever the engine is unreachable — including + # the certificate an operator needs to reach the host and repair it. That is + # ADR-0006's rejected configuration reached from another direction. + # + # We asked for a coverage-gated transitional fail_open and were DECLINED: a + # sanctioned transitional fail_open is indistinguishable at runtime from the + # stance the rule forbids. Our second preference was adopted instead — §13.1 now + # carries a Coverage column, and this repo's figures are its first entries. + # + # So this is tracked non-conformance with a route, not an exemption. + # Route: WARDEN-WP-0040. Register row: §13.1, marked. + unknown: fail_open # non-conformant at v0.8; see above not-applicable: fail_closed # What happens when the stance is applied. §6.4 obligation 1 requires a decision @@ -57,6 +82,23 @@ on_apply: # §6.4 obligation 2 — the verdict is never cached. Input claims (zone membership, # compiled from the flex-auth registry snapshot) are cached under their own # freshness rules; the answer is not. +# Classification coverage, published beside the stance because v0.8 §6.4 +# obligation 3 now requires it and because ops-warden asked for the column. +# Coverage is DISCLOSURE, never a transitional licence: it does not soften this +# map's stance, does not gate it, and never makes the marked cell conformant. +# Self-measured; regenerate with `python scripts/report_coverage.py`. +classification_coverage: + measured: "2026-09-09" + attribution: self-measured + signing_targets: + resolved: 0 + unknown: 3 + not_applicable: 1 + routing_lanes: + resolved: 3 + unknown: 18 + not_applicable: 12 + verdict_caching: none input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py" diff --git a/scripts/report_coverage.py b/scripts/report_coverage.py new file mode 100644 index 0000000..11955ea --- /dev/null +++ b/scripts/report_coverage.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Measure the classification coverage published in `pep-stance.yaml`. + +Read-only. No network, no OpenBao, no secret material. + +Why this exists. security-layer-model v0.8 §6.4 obligation 3 requires a dated +classification-coverage figure beside each stance, and §13.1 carries a Coverage +column whose first entries are ops-warden's — because ops-warden asked for the +column after measuring that 0 of 3 signing targets resolve to a zone. + +Having asked for it, we own the figure's accuracy. A hand-counted number in a +published register decays silently: the register explicitly does not compute +anyone's coverage, and a stale figure beside a marked cell is worse than a blank, +which at least reads as "not reported". So the figure is measured from the same +two artifacts the runtime uses, and `tests/test_layer_conformance.py` asserts the +published block equals what this reports — the same property that makes the stance +map worth publishing (`pep-stance.yaml` equals `PolicyConfig.failure_modes` by +test), applied one level up. + +Two populations, deliberately not summed. They answer different questions and +share no denominator: + + signing targets — actor resources in the flex-auth registry snapshot. This is + the population the stance map actually governs: `warden sign` resolves a zone + per actor resource, and an unresolved one takes the `unknown` cell. + routing lanes — catalog entries with an explicit workload reference. Wider than + the stance map's reach, and the figure that shows *why* coverage is low: an + unknown lane is almost always another repository's undeclared workload + identity, which `ADR-0009` rule 3 forbids closing by inference here. + +Usage: + python scripts/report_coverage.py [--json] + +Exit: 0 always. This reports; it does not gate. Coverage is disclosure, never a +transitional licence, and a script that failed on low coverage would be arguing +the case v0.8 declined. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "src")) + +SNAPSHOT = REPO / "registry" / "flex-auth" / "production_registry_snapshot.json" +NOT_APPLICABLE = "not-applicable" + + +def signing_target_coverage(snapshot_path: Path = SNAPSHOT) -> dict[str, int]: + """Zone resolution across the actor resources `warden sign` can name.""" + registry = json.loads(snapshot_path.read_text()) + resolved = unknown = not_applicable = 0 + for manifest in registry.get("resource_manifests") or []: + for resource in manifest.get("resources") or []: + if str(resource.get("type")) != "ssh-certificate": + continue + attributes = resource.get("attributes") or {} + admission = str(attributes.get("security_zone_admission") or "unknown") + zone = str(attributes.get("security_zone") or "unknown") + if admission == NOT_APPLICABLE: + not_applicable += 1 + elif zone == "unknown": + unknown += 1 + else: + resolved += 1 + return {"resolved": resolved, "unknown": unknown, "not_applicable": not_applicable} + + +def routing_lane_coverage(estate_root: Path | None = None) -> dict[str, int]: + """Workload resolution across catalog lanes. + + Delegates to `report_workload_join.build` rather than re-deriving the join: two + implementations of "is this lane resolved" would drift, and the published figure + should be the one the join report shows. + """ + import importlib.util + + spec = importlib.util.spec_from_file_location( + "report_workload_join", Path(__file__).resolve().parent / "report_workload_join.py" + ) + join = importlib.util.module_from_spec(spec) + spec.loader.exec_module(join) + + report = join.build( + REPO / "registry" / "routing" / "catalog.yaml", + estate_root if estate_root is not None else Path.home(), + ) + return { + "resolved": len(report["resolved"]), + "unknown": len(report["unknown"]), + "not_applicable": len(report["not_applicable"]), + } + + +def measure(estate_root: Path | None = None) -> dict[str, Any]: + return { + "signing_targets": signing_target_coverage(), + "routing_lanes": routing_lane_coverage(estate_root), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args(argv) + + report = measure() + if args.as_json: + print(json.dumps(report, indent=2)) + return 0 + + for name, counts in report.items(): + total = sum(counts.values()) + scoped = counts["resolved"] + counts["unknown"] + print(f"{name.replace('_', ' ')}: {counts['resolved']}/{scoped} resolved " + f"({counts['unknown']} unknown, {counts['not_applicable']} not-applicable, " + f"{total} total)") + print() + print("Coverage is disclosure, not a transitional licence (v0.8 §6.4 obligation 3).") + print("Publish in pep-stance.yaml; §13.1 does not compute it for you.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index b10b7f7..35e4a6f 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -150,3 +150,52 @@ class TestPepStanceMap: assert cadence["observed_window"]["signature_records"] == 3 assert cadence["observed_window"]["active_signature_days"] == 2 assert cadence["reason"] + + +# --- classification coverage (v0.8 §6.4 obligation 3) ------------------------- + +def test_published_coverage_equals_measured_coverage(): + """The published figure must equal what the repo actually measures. + + ops-warden asked gate-house for §13.1's Coverage column and its figures are + that column's first entries, so their accuracy is ours to hold. The register + explicitly does not compute anyone's coverage, and a stale number beside a + marked cell is worse than a blank -- a blank at least reads as "not reported". + + This is the same property that makes the stance map worth publishing (the map + equals PolicyConfig.failure_modes by test), applied one level up. + """ + import importlib.util + + repo = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location( + "report_coverage", repo / "scripts" / "report_coverage.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + published = yaml.safe_load((repo / "pep-stance.yaml").read_text())[ + "classification_coverage" + ] + measured = module.measure() + + for population in ("signing_targets", "routing_lanes"): + assert published[population] == measured[population], population + + +def test_the_unknown_cell_is_marked_as_a_declared_gap(): + """A non-conformant cell must say so where it is declared, not only in a review. + + §11's marking obligation, which ops-warden argued for in the v0.6 round and + then acquired a marked cell under. If the cell is ever flipped to fail_closed + this test fails, which is the correct time to remove the marking. + """ + repo = Path(__file__).resolve().parents[1] + text = (repo / "pep-stance.yaml").read_text() + stance = yaml.safe_load(text)["stance"] + + if stance["unknown"] == "fail_open": + assert "DECLARED GAP" in text + assert "WARDEN-WP-0040" in text + else: + assert stance["unknown"] == "fail_closed" diff --git a/wiki/ApprovalConsumption.md b/wiki/ApprovalConsumption.md new file mode 100644 index 0000000..fb1c695 --- /dev/null +++ b/wiki/ApprovalConsumption.md @@ -0,0 +1,77 @@ +# Approval consumption — notes for the signing lane's future consume + +**Status:** notes only. **Nothing is wired.** ops-warden's pre-sign gate consults +flex-auth (`wiki/PolicyGatedSigning.md`); it does not yet consume an approval +artifact. This page exists so the corrections we have already been given survive +until it does, rather than sitting in an inbox thread. + +Owner of the contract: `gate-house`, `docs/contracts/approval-consumption.md`. +ops-warden is one of two PEP-shaped consumers named there (the SSH signing lane). + +## Validate across two artifacts, never one + +`GH-DEC-2026-005` settles the step-1 artifact: `GET /v1/approvals/{id}/claim` +serves the **approval-claim**. flex-auth's proposed `ActionAuthorization` is not +required there and will not be served from that endpoint. + +| Artifact | Carries | +| --- | --- | +| approval-claim (step 1) | the approval fact — binding digest, validity window, consumption state, freshness, issuer | +| `DecisionEnvelope` (step 2) | exact `CheckRequest` match, policy pin | + +v0.8 §6.4 obligation 5 hardened this: each artifact **must** be validated against +the layer that owns its data, and a PIP **must not** republish the PDP's decision. +Neither artifact may be taken from the other. + +## Three things that are easy to get wrong + +**1. Do not require `provenance.authority == 'state-hub'`.** + +State Hub is a read model with no runtime approval authority. This is easy to get +wrong precisely *because* the hub is where everything else about a workplan lives, +so a consumer reaches for it as the authority on approval too. + +Worth holding as a shape rather than an instance: a PEP requiring the hub to have +asserted an approval is the same boundary violated from the **consuming** side +that "never register workplans or tasks by hand" forbids from the **writing** +side. Same rule, two directions. + +**2. The digest comparison target was corrected. Use `binding.approval_binding_digest`.** + +`GH-DEC-2026-008` as originally issued required the claim's `pdp_digest` to equal +the decision's `request_digest`. That is unimplementable — a claim travelling +inside a hashed request cannot name the digest of the request containing it — and +a fail-closed consumer obeying it would have denied permanently. Corrected by +`FLEX-DEC-2026-007` to the PDP's published **exclusion-scoped** digest, +`binding.approval_binding_digest`. + +ops-warden never copied the superseded wording anywhere (checked 2026-09-09), so +there was nothing to unwind. Recorded here so it cannot be reintroduced from an +older reading of the contract. + +**3. Digest comparison does not discharge attribution, and attribution is not +available today.** + +`GH-DEC-2026-010` rules that a PEP must be able to **attribute** a decision to +access-engine, and that matching digests does not achieve that. flex-auth's +envelope is unsigned and its pins serve plain HTTP, so that half is a declared +§13 gap owned by access-engine until `FLEX-WP-0024` lands. + +Nothing for ops-warden to build. But when this lane is wired, its validation is +**complete-minus-attribution** and must be described that way: fail-closed +protects against a decision point that is absent, not against one that lies. + +## Open dependency + +`WARDEN-WP-0039-T03` — whether an admitted flex-auth contract exists for a +delegated credential read where caller and resource owner differ. If the answer +changes the caller shape it may reach how this lane's consume is built, and +`gate-house` has asked to hear that before rather than after: a contract admitted +for that shape would reach §6.4 obligation 5, and whether such a delegation +attenuates or widens is doctrine they own. + +## See also + +- `wiki/PolicyGatedSigning.md` — the pre-sign gate as it actually is today +- `pep-stance.yaml` — unreachable-engine stance, and the marked `unknown` cell +- `history/2026-09-09-layer-model-v08-review.md` — the v0.8 assent round diff --git a/wiki/PolicyGatedSigning.md b/wiki/PolicyGatedSigning.md index 07e89d2..a60542b 100644 --- a/wiki/PolicyGatedSigning.md +++ b/wiki/PolicyGatedSigning.md @@ -192,6 +192,14 @@ Rollback is a versioned profile or registry rollback. Do not reintroduce `policy.enabled: false`: that would erase per-zone evidence and recreate the global control ADR-0009 supersedes. +## Approval consumption (not wired) + +This lane does not yet consume an approval artifact. The corrections already +received about how it must validate when it does — two artifacts never taken from +each other, the corrected `binding.approval_binding_digest` comparison target, and +the attribution gap that digest matching does not discharge — are held in +`wiki/ApprovalConsumption.md` so they are not lost between now and then. + ## See also - `tenancy.yaml` diff --git a/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md index 1216d23..d7b3601 100644 --- a/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md +++ b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md @@ -125,3 +125,28 @@ records classification coverage alongside stance, so a register row cannot read If both are declined, that is an answer and this workplan proceeds unchanged — the cell stays a **declared gap** under §11 with this workplan as its route, which is what the `declared-gap` mark (our own v0.6 finding) exists to express. + +**Answered 2026-09-09 — GH-DEC-2026-011.** Ask 1 declined, ask 2 adopted; +standard amended at `net-kingdom@64394e9`. + +Ask 1's refusal is correct and we are not appealing it: a sanctioned transitional +`fail_open` is indistinguishable at runtime from the stance the rule forbids, and +would make the rule optional at the only moment it costs anything. Ask 2 gave us +what we actually needed — §13.1 carries a Coverage column with this repo's +figures as its first entries, guarded so that coverage never softens a stance, +never gates one, and never makes a non-conformant cell conformant. + +The reversal condition on that column binds ops-warden first, since the column +exists because we asked: if a row is ever argued conformant *because* its coverage +is low, the column comes out. Our row reads non-conformant and low-coverage, and +the second is not a defence of the first. + +gate-house confirmed explicitly that they are **not** asking for the cell to be +flipped before coverage exists, and that this workplan's order is right. The cell +stays a declared gap under §11 with WP-0040 recorded as its route — the outcome +this task named as acceptable if both asks were declined, reached with one of them +adopted. + +T01–T03 are unchanged and still gate the conversion. Coverage is now measured +rather than asserted (`scripts/report_coverage.py`), so T02's reporting obligation +has a tool behind it and the published figure cannot drift from the register's.