diff --git a/history/2026-08-29-layer-model-v06-review.md b/history/2026-08-29-layer-model-v06-review.md new file mode 100644 index 0000000..857a78a --- /dev/null +++ b/history/2026-08-29-layer-model-v06-review.md @@ -0,0 +1,156 @@ +# Security Layer Model v0.6 — ops-warden's review + +**Date:** 2026-08-29 +**Reviewed:** `security-layer-model_v0.6.md` (proposed), plus v0.5 and the companion +**Prior positions:** `ADR-0010` (v0.1 assent); `history/2026-08-29-layer-model-v04-review.md` +**Outcome:** no objection; one conformance action taken, two findings, one accepted SHOULD. + +--- + +## Disposition of ops-warden's v0.4 findings + +All three were acted on, two of them exactly as recommended. + +| Finding | Outcome | +| --- | --- | +| §9.1 forces a false `pending` onto working capability | **Adopted** — v0.5 split it into `pending` and `declared-gap`, credited to ops-warden | +| §5 scope undefined for uncatalogued infrastructure | **Adopted** — "Tooling-layer system" now means a §4 Tooling row; the State Hub case is recorded, not policed | +| §9.6 atomicity lands on ops-warden's signing lane | **Ruled** — the load-bearing / attributive distinction, with ops-warden's `# audit must not block signing` named as the estate's live example | + +The §9.6 ruling deserves a note, because it went in ops-warden's favour and that is +a reason to check it rather than accept it. The test is *"no control branches on +its presence"*. Verified: the only consumer of `audit.jsonl` is `warden activity` +(`cli.py`), which displays. Nothing gates on a signing record — not the agent +read-boundary, not `warden plan`, not the scorecard. The lane is genuinely +attributive and the trade is legitimate on the standard's own terms. The two +obligations that attach — declare it, never claim completeness — were already met +in `wiki/AuditTrail.md`, now updated to record the ruling rather than the open +question. **If a future ops-warden control ever gates on this trail, the trade has +to be revisited before that ships**, and that is recorded there. + +`layer.yaml` is named in §11 as the estate's reference declaration form, including +the "record non-Tooling clients so the check is total" property. Offered again to +the repositories that have yet to declare. + +--- + +## Conformance action taken — the stance map was not published + +§6.4 obligation 3 requires a declared unreachable-engine stance that is total, per +zone, with no implicit default, *"published rather than held in code comments"* — +and §6.4 requires **every** PEP-shaped consumer to publish its map so the maps can +be inventoried. `ADR-0009` is named as the reference shape. + +ops-warden was not doing this. The map lived in `PolicyConfig.failure_modes`, a +dataclass default in `src/warden/config.py`. That is not a code *comment*, but it +is not published either — it is merely written down, and a consumer of the estate +had no way to read ops-warden's stance without reading ops-warden's source. + +Published as `pep-stance.yaml`, with the property that makes publishing worth +anything: `tests/test_layer_conformance.py` asserts the published map is **equal to +the shipped default**. A published map that may drift from the code is worse than +no map, because it invites reliance it cannot support. The file also records the +obligation-2 position (verdict never cached; input claims cached under their own +freshness rules) and the obligation-4 bound (§9.6 attributive). + +--- + +## Finding 1 — §6.4 obligation 1 contradicts obligation 3, and ops-warden is the instance + +> **1. No side effect without a decision record.** A PEP MUST NOT perform the +> protected action unless it holds a decision from `access-engine` identifying the +> request it was rendered for. + +> **3. A declared unreachable-engine stance (§9.3):** total, per zone... `ops-warden` +> `ADR-0009` is the reference shape. + +These cannot both be absolute. ops-warden's declared stance — blessed by §9.3 as +*"the only thing left"* when there is no engine to ask — is `fail_open` for `z0`–`z2` +and `unknown`. Applying it means issuing a certificate **without holding a +decision**, which obligation 1 forbids without qualification. + +So the same section names ops-warden as the reference shape for obligation 3 while +obligation 1 makes ops-warden's shipped behaviour a violation. §9.3 settled the +substance; §6.4 restates it in a form that takes it back. + +**Recommendation.** Bound obligation 1 by obligation 3: + +> A PEP MUST NOT perform the protected action unless it holds a decision from +> `access-engine` identifying the request it was rendered for, **or its declared +> §9.3 stance for the applicable scope permits proceeding without one and the +> application of that stance is recorded in place of the decision**. + +This is not a weakening. It is stricter than today's text in the case that matters: +it makes the *recorded application of the stance* mandatory, rather than leaving +"no decision record" as a silent state. ops-warden already does this — `ca.py` +writes `policy_zone`, `policy_failure_mode` and `policy_decision_id` (present only +where a decision was rendered) into both the signatures log and `audit.jsonl`, per +`ADR-0009` rule 4: *a fail-open signing result is metadata, not silence*. + +This is the same shape as the v0.4 §9.1 finding: a rule written for the clean case, +correct there, producing a false result on the adjacent case the standard has +already sanctioned elsewhere. + +--- + +## Finding 2 — §6.4 creates a register that §13 does not implement + +§6.4: *"Every PEP-shaped consumer MUST publish its stance map, and those maps MUST +be inventoried — in `maturity-engine` once it exists, **in §13 until then**."* + +§13 contains no stance-map rows. It records declared contacts and unowned +capabilities; there is no column, row, or section for a PEP stance. So the +obligation names a register that does not exist yet, and the failure mode §6.4 +itself warns about — *"`z0`–`z2` and unknown fail open" becoming the estate's real +policy without anyone having compiled it* — is exactly what the missing register +permits. + +**Recommendation.** Either add a stance-map table to §13 with the same +state/owner-status discipline the gap table has, or state that the inventory waits +for `maturity-engine` and mark the obligation pending under §9.1's own logic — a +requirement whose register does not exist is a capability catalogued without a +surface. ops-warden's row is ready to paste: + +| PEP | Protected action | Scope | Stance | Published | +| --- | --- | --- | --- | --- | +| `ops-warden` | SSH certificate issuance | security-zone | open `z0`–`z2`+unknown, closed `z3`/n-a | `ops-warden/pep-stance.yaml` | + +The second half matters more than the first: **ops-warden is currently the only +PEP that has published one**, so an inventory today would contain one row and that +is itself the finding. `ops-mason` is named PEP-shaped in the same paragraph. + +--- + +## Accepted, not yet done — §9.6 emission cadence + +§9.6: *"A source SHOULD declare an expected emission cadence, and a drop below it +SHOULD become a finding in its own right."* + +ops-warden declares none. This is a genuine SHOULD and the reasoning behind it is +sound — it converts the suppression blind spot into something detectable without +any Tooling contact, because the source publishes its own stream. It is not done +here because a cadence asserted without evidence is worse than none: ops-warden's +signing volume is operator-driven and bursty, and a fabricated baseline would +generate findings that mean nothing. Deriving one from the existing trail is +tractable and is recorded as ops-warden's to do, not gate-house's to chase. + +--- + +## On the pace + +Six versions in two days, with four repositories' findings absorbed and credited, +is the §12 loop working at a rate the estate has not seen before. Two cautions, +offered as an interested consumer rather than as objections: + +1. **§13 already says it should not be statute, and it is right.** The register has + grown every version. Moving it to `maturity-engine` is the stated plan; until + that exists, each version of the standard is also a snapshot of a backlog, and + the two have very different review intervals. +2. **The standard is `proposed` again at v0.6**, and the four repositories that + assented did so to v0.1. ops-warden's `ADR-0010` assent covers the three + boundary items, and nothing in v0.2–v0.6 has disturbed them — the layer, the + lane/rule demarcation, and doctrine-versus-runbook all stand. But the + `assented_by` list carries assent forward across five revisions, and a reader + could take it as assent to the current text. Worth distinguishing *assented to + the boundary* from *reviewed the current revision*; ops-warden has now done + both, and this note is the second. diff --git a/layer.yaml b/layer.yaml index 23d099a..be3ec97 100644 --- a/layer.yaml +++ b/layer.yaml @@ -20,6 +20,11 @@ layer: staff declared_by: docs/adr/ADR-0010 declared_at: "2026-08-29" +# §6.4 — ops-warden is PEP-shaped (it causes a protected side effect: issuing a +# certificate). Its unreachable-engine stance map is published separately, and +# asserted equal to shipped behaviour by tests/test_layer_conformance.py. +pep_stance: pep-stance.yaml + # Every direct contact with a Tooling-layer system (§4), one entry each. tooling_contacts: diff --git a/pep-stance.yaml b/pep-stance.yaml new file mode 100644 index 0000000..cf4e83f --- /dev/null +++ b/pep-stance.yaml @@ -0,0 +1,69 @@ +# ops-warden — PEP unreachable-engine stance map +# +# Framework: net-kingdom/canon/standards/security-layer-model_v0.6.md §6.4, §9.3 +# Rule of record: docs/adr/ADR-0009 +# Validate: pytest tests/test_layer_conformance.py -k stance +# +# §6.4 obligation 3 requires a declared unreachable-engine stance that is total, +# scoped per zone, carries no implicit default and no per-call discretion, and is +# "published rather than held in code comments". §6.4 further requires every +# PEP-shaped consumer to PUBLISH its map so the maps can be inventoried. This +# file is ops-warden's, published because a map that lives only in a dataclass +# default is not published — it is merely written down. +# +# The property that makes this worth reading: it is asserted equal to the shipped +# default in src/warden/config.py (PolicyConfig.failure_modes) by +# tests/test_layer_conformance.py. A published map that may drift from the code +# is worse than none, because it invites reliance it cannot support. + +schema_version: "0.1" +framework: netkingdom-security-layer-model +standard_version: "0.6" +repository: ops-warden +pep_shape: true +declared_by: docs/adr/ADR-0009 + +protected_action: "SSH certificate issuance (warden sign / cert_command)" +decision_engine: access-engine # flex-auth until the governed rename +scope: security-zone # security-zones_v0.1 membership of the TARGET workload + +# Total by construction: every zone in security-zones_v0.1, plus the two +# non-zone outcomes. No implicit default — an unlisted value is a config error, +# not a permissive fallback. +stance: + z0-experimental: fail_open + z1-operational: fail_open + z2-protected: fail_open + z2-continuity: fail_open + z3-critical: fail_closed + unknown: fail_open # versioned build profile (ADR-0009); explicit, never inferred + not-applicable: fail_closed + +# What happens when the stance is applied. §6.4 obligation 1 requires a decision +# record for a protected side effect; where the engine is unreachable there is no +# decision to hold, so ops-warden records the APPLICATION OF THE STANCE instead. +# See the assessment note: obligation 1 as written admits no such case. +on_apply: + recorded_fields: + - policy_zone + - policy_failure_mode + - policy_decision_id # present only where a decision was actually rendered + - outcome + written_to: + - "signatures log (src/warden/ca.py)" + - "audit.jsonl (src/warden/audit.py)" + never_recorded: "any secret material, any certificate private key" + +# §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. +verdict_caching: none +input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py" + +# §6.4 obligation 4 — reconstructability, bounded by §9.6. ops-warden's audit +# emission on this lane is deliberately non-atomic and therefore ATTRIBUTIVE, not +# load-bearing: no control branches on the presence of a signing record +# (`warden activity` displays it; nothing gates on it). Registered in §13. +reconstructability: + bound: "§9.6 attributive — completeness is not claimed" + declared_at: wiki/AuditTrail.md diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index 86b39a0..59284aa 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -96,3 +96,38 @@ class TestConduitSuppliesNoAuthority: f"proxy.py references {forbidden!r} — that is presenting or " f"minting authority, not conducting the caller's" ) + + +class TestPepStanceMap: + """§6.4: every PEP-shaped consumer MUST publish its unreachable-engine + stance map, total and per zone, 'published rather than held in code'. + ADR-0009 is named as the reference shape, so it should actually hold.""" + + def _stance(self) -> dict: + return yaml.safe_load((ROOT / "pep-stance.yaml").read_text()) + + def test_published_map_equals_shipped_behaviour(self): + """The whole point. A published map that may drift from the code is + worse than none, because it invites reliance it cannot support.""" + from warden.config import PolicyConfig + + assert self._stance()["stance"] == PolicyConfig().failure_modes + + def test_stance_is_total_over_the_zone_model(self): + """§6.4 obligation 3: total, no implicit default.""" + stance = self._stance()["stance"] + required = { + "z0-experimental", "z1-operational", "z2-protected", + "z2-continuity", "z3-critical", "unknown", "not-applicable", + } + assert required <= set(stance), f"stance not total; missing {required - set(stance)}" + assert set(stance.values()) <= {"fail_open", "fail_closed"} + + def test_critical_zone_fails_closed(self): + """ADR-0009's one non-negotiable row.""" + assert self._stance()["stance"]["z3-critical"] == "fail_closed" + + def test_verdict_is_never_cached(self): + """§6.4 obligation 2: caching an input claim is permitted; caching the + answer is a second decision point deciding early (§6.1).""" + assert self._stance()["verdict_caching"] == "none" diff --git a/wiki/AuditTrail.md b/wiki/AuditTrail.md index 7f0b22a..aba9036 100644 --- a/wiki/AuditTrail.md +++ b/wiki/AuditTrail.md @@ -38,8 +38,13 @@ never blocks the primary action — but tests prove values cannot be written. > not altered or truncated; it does **not** prove that every action produced one. > Do not reason from a missing entry. This is the estate-wide bound in > `security-layer-model_v0.4` §9.6 — completeness is the source's obligation, and -> whether ops-warden must make signing emission atomic is an open question with -> gate-house (`history/2026-08-29-layer-model-v04-review.md`, Finding 3). +> **Ruled 2026-08-29** (`security-layer-model_v0.6` §9.6): this trail is +> **attributive**, not load-bearing — no control branches on the presence of a +> signing record — so the non-atomic trade is legitimate, and the obligations are +> to declare it (this note) and never to claim completeness. Atomicity is required +> only where a control's soundness depends on an event being present or absent. +> Registered in the standard's §13 open-gap table as self-declared. If a future +> control ever gates on this trail, the trade must be revisited before it ships. ---