From df48ee96e012fd460e234aede63b1db41ae25070 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:40:05 +0200 Subject: [PATCH 1/5] feat: assert flex-auth ceiling keys are declared, not assumed flex-auth fixed enrichment so registry facts beat caller-supplied ones (FLEX-DEC-2026-012) and asked each consumer to confirm the ceiling and allowlist keys are actually declared -- the fix wins only where the registry HAS a value, and a manifest omitting max_ttl_hours hands that ceiling back to the caller. Confirmed, and made durable rather than read once. scripts/check_flex_auth_manifest_coverage.py audits both ways a ceiling gets handed back: an actor with no manifest resource at all (warden sign names ssh-cert:actor/ whether or not the snapshot was rebuilt -- an honour-system step in SCOPE.md), and a resource missing one of the seven keys. A null is treated as absent, because for enrichment it is. Also asserts the property their exploitability assessment rested on and nothing here held: ops-warden sends no resource.attributes. It was true when they read it, secrets-engine sends them on every request, and it was one refactor from silently stopping being true. Current state: no gap. 4 actors, 4 resources, all seven keys declared. 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 --- scripts/check_flex_auth_manifest_coverage.py | 161 +++++++++++++++++++ tests/test_flex_auth_registry.py | 83 ++++++++++ tests/test_policy.py | 44 +++++ 3 files changed, 288 insertions(+) create mode 100644 scripts/check_flex_auth_manifest_coverage.py diff --git a/scripts/check_flex_auth_manifest_coverage.py b/scripts/check_flex_auth_manifest_coverage.py new file mode 100644 index 0000000..755ba02 --- /dev/null +++ b/scripts/check_flex_auth_manifest_coverage.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Assert every ssh-certificate resource declares its ceilings in the manifest. + +Read-only. Reads `inventory.yaml` and the flex-auth registry snapshot; touches +no network, no OpenBao, and no secret material. + +Why this exists (FLEX-DEC-2026-012, 2026-09-07). flex-auth's enrichment used to +overlay registry facts additive-if-absent, so a caller-supplied value for a key +won and the registry's ceiling never applied. That is fixed on their side: +registry facts now win. But they win only where the registry HAS a value — + + "A resource whose manifest omits max_ttl_hours hands that ceiling back to + the caller, and registering the resource is not sufficient; the specific + key must be present." + +So there are two ways to hand a ceiling back, and this check covers both: + + 1. An actor with no manifest resource at all. `warden sign` names + `ssh-cert:actor/` for any actor in inventory, whether or not the + snapshot was regenerated. Adding an actor and forgetting to rebuild is an + honour-system step in `SCOPE.md`, and honour-system steps are what + `ADR-0004`'s WARDEN_AGENT_ID marker taught us to stop relying on. + 2. A manifest resource missing one of the ceiling or allowlist keys. The + builder emits all of them today; this asserts it stays true, including for + resources added by hand or by a future code path. + +ops-warden sends no `resource.attributes` on a CheckRequest (`src/warden/policy.py`), +which is why FLEX-DEC-2026-012 was defence-in-depth rather than a live hole for +this repo. That property is asserted separately in `tests/test_policy.py`; this +check covers the half that survives it — if anything ever does reach a request +field, the ceiling it would have to beat must actually exist. + +Usage: + python scripts/check_flex_auth_manifest_coverage.py \ + --inventory examples/inventory.seed.yaml \ + --registry registry/flex-auth/production_registry_snapshot.json [--json] + +Exit: 0 covered, 2 coverage gap, 1 usage/IO error. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + +#: Every key ops-warden's shipped policy package branches on as a ceiling or an +#: allowlist. Named by flex-auth in FLEX-DEC-2026-012; kept here rather than +#: derived from the package so a package edit that drops a branch does not +#: silently shrink what this check requires. +REQUIRED_CEILING_KEYS = ( + "actor_id", + "actor_type", + "allowed_principals", + "allowed_subjects", + "max_ttl_hours", + "security_zone", + "security_zone_admission", +) + +RESOURCE_TYPE = "ssh-certificate" + + +def _resources(registry: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for manifest in registry.get("resource_manifests") or []: + out.extend(manifest.get("resources") or []) + return out + + +def audit(inventory: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]: + actors = sorted((inventory.get("actors") or {}).keys()) + resources = _resources(registry) + by_id = {str(r.get("id")): r for r in resources} + + unregistered: list[str] = [] + missing_keys: list[dict[str, Any]] = [] + + for name in actors: + if f"ssh-cert:actor/{name}" not in by_id: + unregistered.append(name) + + # Check every ssh-certificate resource in the manifest, not only the ones an + # inventory actor maps to: a resource flex-auth can be asked about is one it + # holds, regardless of where it came from. + for resource in resources: + if str(resource.get("type")) != RESOURCE_TYPE: + continue + attributes = resource.get("attributes") or {} + absent = [ + key for key in REQUIRED_CEILING_KEYS + # A null is not a declaration: flex-auth overlays a registry value + # only where one exists, so `key: null` hands the ceiling back + # exactly as an absent key does. + if attributes.get(key) is None + ] + if absent: + missing_keys.append({"resource": str(resource.get("id")), "missing": absent}) + + # Not a security defect — a stale resource has ceilings, it just has no + # actor. Reported so drift is visible rather than accumulating silently. + orphaned = sorted( + rid for rid, r in by_id.items() + if str(r.get("type")) == RESOURCE_TYPE + and rid.removeprefix("ssh-cert:actor/") not in actors + ) + + return { + "actors": len(actors), + "resources": sum(1 for r in resources if str(r.get("type")) == RESOURCE_TYPE), + "required_keys": list(REQUIRED_CEILING_KEYS), + "unregistered_actors": unregistered, + "resources_missing_keys": missing_keys, + "orphaned_resources": orphaned, + "covered": not unregistered and not missing_keys, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", default="examples/inventory.seed.yaml", type=Path) + parser.add_argument( + "--registry", + default="registry/flex-auth/production_registry_snapshot.json", + type=Path, + ) + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args(argv) + + try: + inventory = yaml.safe_load(args.inventory.read_text()) or {} + registry = json.loads(args.registry.read_text()) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + report = audit(inventory, registry) + report["inventory"] = str(args.inventory) + report["registry"] = str(args.registry) + + if args.as_json: + print(json.dumps(report, indent=2)) + else: + print(f"inventory : {args.inventory} ({report['actors']} actors)") + print(f"registry : {args.registry} ({report['resources']} ssh-certificate resources)") + for name in report["unregistered_actors"]: + print(f" MISSING {name} — no manifest resource; every ceiling is caller-supplied") + for row in report["resources_missing_keys"]: + print(f" UNDECLARED {row['resource']} — {', '.join(row['missing'])}") + for rid in report["orphaned_resources"]: + print(f" orphaned {rid} — manifest resource with no inventory actor") + print("covered" if report["covered"] else "NOT COVERED") + + return 0 if report["covered"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_flex_auth_registry.py b/tests/test_flex_auth_registry.py index c329d27..70dd26f 100644 --- a/tests/test_flex_auth_registry.py +++ b/tests/test_flex_auth_registry.py @@ -81,3 +81,86 @@ def test_compiler_joins_explicit_workload_reference_to_resolved_zone(tmp_path): assert attrs["security_zone"] == "z2-continuity" assert attrs["security_zone_admission"] == "satisfied" assert attrs["security_zone_revision"] == "sha256:zone-revision" + + +# --- manifest ceiling coverage (FLEX-DEC-2026-012) ---------------------------- +# +# flex-auth fixed enrichment so registry facts beat caller-supplied ones, then +# asked every consumer to confirm the ceiling keys are actually declared: the +# fix wins only where the registry HAS a value. These assert the two ways a +# ceiling gets handed back to the caller. + +import importlib.util # noqa: E402 + +import pytest # noqa: E402 + +_REPO = ROOT +_spec = importlib.util.spec_from_file_location( + "check_flex_auth_manifest_coverage", + _REPO / "scripts" / "check_flex_auth_manifest_coverage.py", +) +_coverage = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_coverage) +REQUIRED_CEILING_KEYS = _coverage.REQUIRED_CEILING_KEYS +audit = _coverage.audit + +_build_spec = importlib.util.spec_from_file_location("build_flex_auth_registry", SCRIPT) +_builder = importlib.util.module_from_spec(_build_spec) +_build_spec.loader.exec_module(_builder) +build_registry = _builder.build_registry + + +def _repo_inventory_and_registry(): + inventory = yaml.safe_load((_REPO / "examples" / "inventory.seed.yaml").read_text()) + registry = json.loads( + (_REPO / "registry" / "flex-auth" / "production_registry_snapshot.json").read_text() + ) + return inventory, registry + + +def test_shipped_snapshot_declares_every_ceiling_key(): + """The confirmation flex-auth asked for, as a test rather than a reading.""" + report = audit(*_repo_inventory_and_registry()) + assert report["unregistered_actors"] == [] + assert report["resources_missing_keys"] == [] + assert report["covered"] is True + + +def test_actor_with_no_manifest_resource_is_a_coverage_gap(): + """`warden sign` names ssh-cert:actor/ whether or not the snapshot was rebuilt.""" + inventory, registry = _repo_inventory_and_registry() + inventory["actors"]["adm-added-after-the-last-build"] = { + "type": "adm", "principals": ["adm-full"], "ttl_hours": 48, + } + report = audit(inventory, registry) + assert report["unregistered_actors"] == ["adm-added-after-the-last-build"] + assert report["covered"] is False + + +@pytest.mark.parametrize("key", REQUIRED_CEILING_KEYS) +def test_dropping_any_single_ceiling_key_is_a_coverage_gap(key): + inventory, registry = _repo_inventory_and_registry() + resource = registry["resource_manifests"][0]["resources"][0] + resource["attributes"].pop(key) + report = audit(inventory, registry) + assert report["resources_missing_keys"] == [ + {"resource": resource["id"], "missing": [key]} + ] + assert report["covered"] is False + + +@pytest.mark.parametrize("key", REQUIRED_CEILING_KEYS) +def test_a_null_ceiling_is_not_a_declaration(key): + """flex-auth overlays a registry value only where one exists.""" + inventory, registry = _repo_inventory_and_registry() + resource = registry["resource_manifests"][0]["resources"][0] + resource["attributes"][key] = None + report = audit(inventory, registry) + assert report["covered"] is False + + +def test_builder_output_is_covered_by_construction(tmp_path): + """Every resource the builder emits carries the full ceiling set.""" + inventory = yaml.safe_load((_REPO / "examples" / "inventory.seed.yaml").read_text()) + report = audit(inventory, build_registry(inventory)) + assert report["covered"] is True diff --git a/tests/test_policy.py b/tests/test_policy.py index 0e2f948..8257e23 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -307,3 +307,47 @@ def test_advisory_decision_is_recorded_and_does_not_block(tmp_path): assert check_sign_policy(cfg, spec) == "decision:advisory" assert spec.policy_zone == "unknown" assert spec.policy_outcome == "audit_only" + + +def test_check_request_asserts_no_resource_attributes(tmp_path, monkeypatch): + """The property FLEX-DEC-2026-012 turned on, asserted rather than assumed. + + flex-auth's enrichment used to overlay registry facts additive-if-absent, so + a caller-supplied `resource.attributes` value won and the registry ceiling + never applied. Their exploitability assessment rested on ops-warden sending + no `resource.attributes` at all -- true when they read it, and nothing here + held it true. secrets-engine sends them on every request, so this is a + property of this code rather than of the protocol. + + Ceilings must arrive from the registry. If a future change ever needs to send + an attribute, this test is the place that argument gets made. + """ + from warden import policy as policy_mod + + pubkey = tmp_path / "id.pub" + pubkey.write_text("ssh-ed25519 AAAA test\n") + cfg = PolicyConfig(flex_auth_url="http://flex-auth.test") + seen = {} + + class _Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return {"effect": "allow", "id": "decision:49350f1064f674d7"} + + def fake_post(url, json=None, headers=None, timeout=None): + seen["body"] = json + return _Response() + + monkeypatch.setattr(policy_mod.httpx, "post", fake_post) + policy_mod.check_sign_policy(cfg, _spec(pubkey)) + + assert "attributes" not in seen["body"]["resource"] + # The requested TTL is a policy input (ttl_out_of_bounds is denied against + # the registry ceiling), so it must travel as context, never as a resource + # attribute that would be compared against itself. + assert seen["body"]["context"]["ttl_hours"] == 24 + assert set(seen["body"]["resource"]) == {"id", "type", "system", "tenant"} From 8e1b6211162c400184e8981b97a5ea39cc98d4cc Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:42:20 +0200 Subject: [PATCH 2/5] docs: assent to v0.8 obligation 3, and price its adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-house circulated security-layer-model v0.8, whose §6.4 obligation 3 makes our `unknown: fail_open` cell non-conformant, and asked to be argued with rather than obeyed. Finding 1 -- assent. They wrote the falsifier into GH-DEC-2026-009: a scope genuinely unknown AND genuinely low-consequence, expected to be a §5.1 read-only diagnostic. We looked and do not have one. The stance map governs `warden sign` -- a credential-issuing side effect -- so §5.1 does not reach it and the argument stands. Being unclassifiable must not buy permissiveness. Finding 2 -- adopting it today would be a global fail-closed flag in all but name. Of four signing targets, zero resolve to a zone and three are `unknown`, so the cell would fail closed on essentially every certificate during a flex-auth outage -- including the SSH certificate needed to reach the host and repair flex-auth. That is exactly ADR-0006's rejected configuration reached by another route. Asked for a dated transition gated on coverage, or failing that for §13.1 to record coverage alongside stance: a row reading `unknown: fail_closed` while every target is unknown is conformant and misleading. Not flipping the cell. It is a proposed standard, 18 of our 18 unknown lanes are unknown because another repo has not declared, and ADR-0009 rule 3 forbids closing that with inference -- a stricter stance is not a licence to manufacture the membership that makes it survivable. WARDEN-WP-0040 records the order: classify the continuity path, raise coverage by asking owners, then supersede ADR-0009's unknown row. 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 --- history/2026-09-09-layer-model-v08-review.md | 119 +++++++++++++++++ ...-0040-unknown-zone-fail-closed-adoption.md | 122 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 history/2026-09-09-layer-model-v08-review.md create mode 100644 workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md diff --git a/history/2026-09-09-layer-model-v08-review.md b/history/2026-09-09-layer-model-v08-review.md new file mode 100644 index 0000000..bc04aa6 --- /dev/null +++ b/history/2026-09-09-layer-model-v08-review.md @@ -0,0 +1,119 @@ +# security-layer-model v0.8 — ops-warden review + +**Date:** 2026-09-09 +**Reviewing:** `net-kingdom/canon/standards/security-layer-model_v0.8.md` (status +`proposed`, net-kingdom@66eeaba), circulated for assent by gate-house +**Prior reviews:** `2026-08-29-layer-model-v04-review.md`, `-v06-review.md`, +`-v07-scope-intent-assessment.md` + +## The cell in question + +v0.8 §6.4 obligation 3 adds: *unknown is not a zone and MUST resolve to +`fail_closed`.* `pep-stance.yaml` declares `unknown: fail_open` under `ADR-0009`. +§13.1 records the cell as non-conformant. + +## Finding 1 — assent. The rule is right and our falsifier check fails. + +gate-house wrote the falsifier into GH-DEC-2026-009's reversal: a scope that is +genuinely unknown **and** genuinely low-consequence, where failing closed costs +availability with no security gain. Their expectation is that any such path is a +§5.1 read-only diagnostic carrying no PEP obligation at all. + +We looked for one on our side and do not have it. The stance map governs exactly +one protected action — `warden sign`, SSH certificate issuance. That is a +credential-issuing side effect, not a diagnostic; §5.1 does not reach it. So the +predicted escape hatch is absent here, and the argument stands unrebutted on its +own terms: + +- §9.3 licenses trading availability for openness **per zone**, knowingly, for a + named scope. Where the scope is unknown that trade was never made for the + request in hand, so resolving permissively does not extend a considered + decision — it invents one. +- Unknown is the cheapest state to induce. Our own `unknown` reasons are + `workload_resolution_absent` and `catalog_applicability_absent` — an + unregistered workload or a missing declaration, neither of which requires a + credential to arrange. + +We accept that being unclassifiable must not buy permissiveness. The cell is +wrong as doctrine. + +## Finding 2 — the rule is right and adopting it today would be a global fail-closed flag in all but name + +This is the finding we would like recorded, and it is about adoption cost, not +about the rule. + +The stance map applies only when the engine is unreachable, so the cell's bite is +proportional to how much of the target population sits in `unknown`. Measured +today: + +| Population | Resolved to a zone | `unknown` | `not-applicable` | +| --- | --- | --- | --- | +| Signing targets (actor resources in the flex-auth snapshot) | **0** | **3** | 1 | +| Routing catalog lanes | 3 | 18 | 12 | + +Every signing target that resolves at all resolves to `unknown`. So +`unknown: fail_closed` adopted today does not fail closed on an edge case; it +fails closed on **essentially every certificate ops-warden issues** whenever +flex-auth is unreachable. + +That is the precise configuration `ADR-0006` rejected and `ADR-0009` replaced: one +value making the decision engine a uniform dependency of every signing path, +*including the continuity paths needed to repair that dependency*. If flex-auth is +unreachable and the operator needs an SSH certificate to reach the host and +restore it, `unknown: fail_closed` denies the certificate — and the target is +`unknown` precisely because nobody has classified the repair path. + +The zone model already answers this: classify continuity paths `z2-continuity`, +whose stance is `fail_open`, and the deadlock does not arise. We accept that as +the right answer. The point is that it is *work not yet done*, and the reason it +is not done is not ours to fix — 18 of our 18 unknown lanes are unknown because +another repo has not published an authoritative workload identity declaration. +`ADR-0009` rule 3 forbids us from closing that gap with inference, correctly. + +So as written, v0.8 §6.4 obligation 3 transfers an availability cost onto the +consumer for a classification gap the consumer cannot close alone, and it does so +at the moment of assent rather than at the moment the gap closes. + +**What we ask for**, in preference order: + +1. **§6.4 obligation 3 names a transition.** A consumer may declare + `unknown: fail_open` as a dated, published transitional state with a + classification-coverage figure attached, converting to `fail_closed` on + coverage rather than on calendar. This keeps the doctrine and prices the + adoption honestly. +2. Failing that, **§13.1 records classification coverage alongside stance.** A + register row reading `unknown: fail_closed` while 100% of that consumer's + targets are unknown is conformant and materially misleading; a reader cannot + tell a strict consumer from an unclassified one. This is the same defect + §11's *published map equals shipped behaviour* rule exists to prevent, one + level up: the map is now accurate about itself and inaccurate about its + effect. + +## Finding 3 — §12's derived-artifact rule, small and in agreement + +Six instances in one week matches our experience. `registry/generated/high-risk-data-paths.yaml` +carries `catalog_revision` and a `catalog_dirty` flag for exactly the reason §12 +now generalises: a derived artifact whose revision does not describe the file it +came from is worse than one with no revision, because the stale one is trusted. +No change requested; recording the agreement since the rule is being generalised +from other repos' incidents. + +## What ops-warden is doing about it + +Not flipping the cell on a `proposed` standard, and not flipping it before +coverage exists — that ordering is `ADR-0006`'s whole holding. `WARDEN-WP-0040` +records the adoption sequence: raise signing-target classification coverage +first, then convert the cell, with the deadlock case classified before rather +than after. If v0.8 is accepted with obligation 3 unchanged, ops-warden's cell +remains a **declared gap** under §11 — tracked non-conformance with a named +owner and a route, which is the mark §9.1 exists for and which we argued for in +the v0.6 round. + +## Axis declaration (second change touching us) + +`pep-stance.yaml` already carries `scope: security-zone`. No change needed. We +agree with rejecting a forced migration onto zones — secrets-engine scoping by +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. diff --git a/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md new file mode 100644 index 0000000..c20c5e8 --- /dev/null +++ b/workplans/WARDEN-WP-0040-unknown-zone-fail-closed-adoption.md @@ -0,0 +1,122 @@ +--- +id: WARDEN-WP-0040 +type: workplan +title: "Adopt unknown -> fail_closed behind signing-target classification coverage" +domain: infotech +repo: ops-warden +status: proposed +owner: ops-warden +topic_slug: netkingdom +planning_priority: P1 +depends_on_workplans: + - WARDEN-WP-0032 + - WARDEN-WP-0034 +created: "2026-09-09" +updated: "2026-09-09" +--- + +# WARDEN-WP-0040 — `unknown` → `fail_closed`, in the right order + +security-layer-model v0.8 §6.4 obligation 3 (proposed, 2026-09-06) rules that +`unknown` is not a zone and must resolve to `fail_closed`. ops-warden's +`pep-stance.yaml` declares `unknown: fail_open` under `ADR-0009`. + +**We agree with the rule.** `history/2026-09-09-layer-model-v08-review.md` records +the assent and why our falsifier check failed: the cell governs certificate +issuance, not a §5.1 read-only diagnostic, so nothing rescues it. + +**We disagree with adopting it first.** Measured 2026-09-09: of four signing +targets, zero resolve to a zone, three are `unknown`, one is `not-applicable`. +Flipping the cell today makes flex-auth a hard dependency of essentially every +certificate — `ADR-0006`'s rejected configuration, reached by a different route, +including the continuity path needed to repair flex-auth itself. + +So: coverage first, then the cell. That ordering is the whole holding of +`ADR-0006` and the reason `ADR-0009` replaced a global switch with a zone map. + +## Tasks + +```task +id: WARDEN-WP-0040-T01 +status: todo +priority: high +``` + +**Classify the continuity path before anything else changes.** + +The deadlock is specific and worth naming before it is designed around: flex-auth +unreachable → operator needs an SSH certificate to reach the host → target is +`unknown` because nobody classified the repair path → `fail_closed` denies it. + +`z2-continuity` exists for this and its stance is `fail_open`, so the fix is +classification, not an exception. Determine which actor(s) constitute the +repair path, establish whether the declaration is ops-warden's to make (our own +`tenancy.yaml` covers ops-warden as a workload; the actors' target workloads may +not be ours), and route what is not. + +Blocks T03. Converting the cell with the repair path unclassified is the one +outcome this workplan exists to prevent. + +```task +id: WARDEN-WP-0040-T02 +status: todo +priority: high +``` + +**Raise signing-target coverage, by asking owners — never by inferring.** + +Three actor resources report `workload_resolution_absent`: +`agt-codex-interhub-bootstrap`, `agt-state-hub-bridge`, `atm-backup-daily`. +Their owners (ops-bridge and the backup execution unit among them) have not +published an authoritative workload identity declaration. + +`ADR-0009` rule 3 forbids closing this with path or repository-name inference, +and that prohibition holds under pressure from this workplan specifically — +adopting a stricter stance is not a licence to manufacture the membership that +makes it survivable. + +Route to each owner with the concrete consequence: while the declaration is +absent their actor cannot be issued a certificate during a flex-auth outage once +the cell converts. That is a better ask than a generic request to publish a +declaration, and it is true. + +Report coverage with `scripts/report_workload_join.py`; this task is done when +coverage is stated, not when it reaches a threshold — the threshold is T03's +gate, and owners may legitimately decline. + +```task +id: WARDEN-WP-0040-T03 +status: wait +priority: high +``` + +**Convert the cell and supersede `ADR-0009`'s unknown row.** + +Gated on T01, and on v0.8 (or its successor) reaching `accepted`. Do not convert +against a `proposed` standard. + +`ADR-0009` is `owner: ops-warden`, so changing it means a superseding ADR, never +an in-place edit (`.claude/rules/architecture.md`). The superseding record should +carry the v0.8 argument rather than restate it: unknown is not a zone, so a §9.3 +per-zone trade was never made for that request. + +`pep-stance.yaml` is asserted equal to `PolicyConfig.failure_modes` by +`tests/test_layer_conformance.py`, so the map and the code convert in one commit +or the test fails — which is the property that makes the map worth publishing. + +```task +id: WARDEN-WP-0040-T04 +status: todo +priority: medium +``` + +**Return findings to gate-house and track the transition ask.** + +Sent 2026-09-09 with the review. Two asks, in preference order: obligation 3 +names a dated transitional state gated on coverage; or failing that, §13.1 +records classification coverage alongside stance, so a register row cannot read +`unknown: fail_closed` while every target is unknown. + +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. From 83fdd08f881ce6a5bd9c3fbcb4dc79d1ab62b4d5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:43:44 +0200 Subject: [PATCH 3/5] fix: name the npm KV field, not the env var it becomes secrets-engine corrected our claim (msg 15f0c0ca): `npm_token` is the KV field on the whynot-design publish lane; `NPM_AUTH_TOKEN` is the environment variable their publication-scope policy injects. Their doc lists the two as separate rows and we had copied the env var in as the field name, so our `fetch_command` named a field that does not exist -- `bao kv get -field=NPM_AUTH_TOKEN` could only ever have failed. This is ADR-0001's failure mode, not a typo: a pointer layer restating an owner's procedure and getting it wrong. Corrected from the owner's statement rather than re-derived here, and the catalog now records the distinction inline so the env var does not get copied back in. The path is a separate and still-open question. secrets-engine declined to resolve it unilaterally -- which location backs the lane for reads is railiance-platform's custody state -- so the path is unchanged and routed to them rather than moved on a coordination message. 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 --- registry/routing/catalog.yaml | 12 ++++++++++-- wiki/playbooks/whynot-design-npm-publish.md | 12 +++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index fdd76e0..7b9914a 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -241,7 +241,15 @@ entries: # form is superseded; do not reintroduce it. auth_method: "bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read" path_template: "platform/workloads/coulomb/whynot-design/npm-publish" - fetch_command: "bao kv get -field=NPM_AUTH_TOKEN platform/workloads/coulomb/whynot-design/npm-publish" + # Field corrected 2026-09-09 on the owner's statement (secrets-engine msg + # 15f0c0ca): `npm_token` is the KV FIELD; `NPM_AUTH_TOKEN` is the environment + # variable the publication-scope policy injects, and ops-warden had copied the + # env var in as the field name. That is ADR-0001's failure mode exactly — a + # pointer layer restating an owner's procedure and getting it wrong — so it is + # corrected from the owner's doc (secrets-engine docs/whynot-design-real-publish-closeout.md), + # never re-derived here. The path is a separate question, open with + # railiance-platform; do not change it on this correction. + fetch_command: "bao kv get -field=npm_token platform/workloads/coulomb/whynot-design/npm-publish" policy_ref: "flex-auth check secret.read:whynot-design" exec_capable: true lane: secret @@ -257,7 +265,7 @@ entries: automatable: false steps: - "In the coulomb Forgejo registry, revoke the current @whynot/design publish token and generate a new one (scope: package read/write) for the whynot-design publish identity." - - "Write it back: `bao kv put platform/workloads/coulomb/whynot-design/npm-publish NPM_AUTH_TOKEN=@file` (value from a mode-0600 file)." + - "Write it back: `bao kv put platform/workloads/coulomb/whynot-design/npm-publish npm_token=@file` (value from a mode-0600 file). The field is `npm_token`; `NPM_AUTH_TOKEN` is the injected env var, not a KV key." - "Verify capabilities-safe, then publish a fresh version and confirm it with Forgejo-supported `npm view @` through the governed execution lane (value used, not printed)." - id: policy-nexus-forgejo-source-read diff --git a/wiki/playbooks/whynot-design-npm-publish.md b/wiki/playbooks/whynot-design-npm-publish.md index 4f26d05..4ffc934 100644 --- a/wiki/playbooks/whynot-design-npm-publish.md +++ b/wiki/playbooks/whynot-design-npm-publish.md @@ -10,7 +10,8 @@ Owner: `railiance-platform` (OpenBao) · provisioning CCR-2026-0001 (commit 8f61 > plan-authorized Forgejo admin recovery lane. Treat this dedicated lane as > unverified for writes until its package token is rotated and re-proven. -The `NPM_AUTH_TOKEN` that publishes `@whynot/design` to the coulomb Forgejo npm registry +The npm publish token for `@whynot/design` on the coulomb Forgejo npm registry +(KV field `npm_token`; reaching the publish command as `NPM_AUTH_TOKEN`) (`https://forgejo.coulomb.social/api/packages/coulomb/npm/`). ops-warden **does not hold this token** — it is the access front door: `warden access` proxies the read from OpenBao **as the caller** and never persists, caches, or logs the value. @@ -22,7 +23,8 @@ this token** — it is the access front door: `warden access` proxies the read f | Field | Value | | --- | --- | | OpenBao path | `platform/workloads/coulomb/whynot-design/npm-publish` | -| Field | `NPM_AUTH_TOKEN` | +| KV field | `npm_token` | +| Injected env var | `NPM_AUTH_TOKEN` (set by the publication-scope policy; not a KV key) | | KV mount | `platform` | | Read policy | `workload-kv-read-whynot-design-npm-publish` | | OIDC login | `bao login -method=oidc -path=netkingdom role=whynot-design-workload-kv-read` | @@ -59,12 +61,12 @@ this token** — it is the access front door: `warden access` proxies the read f token fragment at `forgejo.coulomb.social`: ```bash # --exec needs the env-var name. The zone-aware policy gate always runs first. - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN \ + warden access whynot-design-npm-publish --field npm_token \ --exec -- npm view @whynot/design@ version \ --registry=https://forgejo.coulomb.social/api/packages/coulomb/npm/ - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN \ + warden access whynot-design-npm-publish --field npm_token \ --exec -- npm publish - warden access whynot-design-npm-publish --field NPM_AUTH_TOKEN --fetch + warden access whynot-design-npm-publish --field npm_token --fetch ``` On either path the value transits to you (or the child env) and never enters ops-warden's memory, disk, or audit log. From 1e3eb8df2ac73677c971ce72506f4187e92c9b05 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:43:48 +0200 Subject: [PATCH 4/5] chore: refresh high-risk paths for committed catalog 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 --- registry/generated/high-risk-data-paths.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index aa3dd0e..2ad306a 100644 --- a/registry/generated/high-risk-data-paths.yaml +++ b/registry/generated/high-risk-data-paths.yaml @@ -10,10 +10,10 @@ # declares it, and is null where the field set has not been established -- # null means unknown, never 'one field'. -generated_at: "2026-09-08T12:55:03Z" +generated_at: "2026-09-09T12:43:48Z" source: ops-warden/registry/routing/catalog.yaml -catalog_revision: "c1330049d9535f701676aa955d56f2138bb70d19" -catalog_revision_date: "2026-09-08T14:55:02+02:00" +catalog_revision: "83fdd08f881ce6a5bd9c3fbcb4dc79d1ab62b4d5" +catalog_revision_date: "2026-09-09T14:43:44+02:00" catalog_dirty: false high_risk_lane_count: 24 concrete_path_count: 15 From 3aedd8f8441decc3fdf1aa6484f0818c1498aae2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 16:40:46 +0200 Subject: [PATCH 5/5] docs: record the answers received and the questions routed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six inbound messages attended, three of them carrying real asks. flex-auth FLEX-DEC-2026-004 answers WARDEN-WP-0034-T05's decision-lifetime question: a decision lifetime shorter than the certificate TTL is meaningful, but only as authority to ISSUE, never to USE an already-issued certificate. The question had mistaken a decision lifetime for a credential lifetime. They declined to move the §9.7.2 revocation residue to their side; that refusal is right and the stance map is unchanged. T05 still waits on ops-mason and railiance-infra. WARDEN-WP-0039-T03 routed to flex-auth: is there an admitted contract for a delegated credential read where caller and resource owner differ? Three outcomes named as equally acceptable, including that there should be no such contract and the interim proxy transport is itself the defect -- which would shorten WP-0033 rather than block it. Two easy fixes ruled out in writing: broadening the caller binding, and relabelling resource.system as ops-warden so the binding matches. The second would make the audit trail assert we own credentials we deliberately do not, by editing a field instead of making an argument. WARDEN-WP-0037: npm path routed to railiance-platform, catalog unchanged pending their answer. secrets-engine refused to resolve it from a coordination message and was right; asserting our own pointer is authoritative because it is ours would route around that. The ask names a location only, and flags that a `bao kv get` answer would be the 2026-07-16 disclosure vector on a risk: high lane. 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 --- ...DEN-WP-0034-layer-model-v07-conformance.md | 30 +++++++++++++++++++ ...-WP-0037-whynot-design-forgejo-npm-lane.md | 30 +++++++++++++++++++ .../WARDEN-WP-0039-explicit-policy-refusal.md | 28 +++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md b/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md index 7d779ab..81a8f19 100644 --- a/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md +++ b/workplans/WARDEN-WP-0034-layer-model-v07-conformance.md @@ -198,6 +198,36 @@ current access-engine implementation, received the decision-lifetime semantics question (message `02c99a74`). No implementation or policy decision was assigned to another owner by these messages. +**One of three answered, 2026-09-09.** `flex-auth` answered the decision-lifetime +question as `FLEX-DEC-2026-004` (flex-auth commit `6a6464f`), and answered it +rather than declining it: a decision lifetime shorter than the certificate TTL is +meaningful, but only as authority to **issue**, never as authority to **use** an +already-issued certificate. The action is `sign` and it completes at issuance, so +the decision is consumed once the certificate exists and no field in the decision +record reaches the artifact. 15m against a 48h `adm` certificate was never an +inconsistency to reconcile — the question mistook a decision lifetime for a +credential lifetime. + +Two obligations land on this side and are accepted: no replay of an allow past +`expires_at`, and a fresh Check per sign. The second is where the value is, and it +is real rather than nominal because claims are not cached PDP-side (deadline 0 for +the claim class), so a revoked principal stops obtaining *new* certificates +immediately. + +`pep-stance.yaml` §9.7.2 is unchanged and stays here: flex-auth explicitly declined +to move the residue to their side, on the ground that a PDP recalling an issued +credential would be "the same shape of lie as fail-open". Declining it was the +right call even though absorbing it would have made their record look tidier. + +The TTL lever they named — capping requested TTL per actor class or zone, already a +policy input since `ttl_out_of_bounds` is denied before OpenBao is reached — is +noted and deliberately not requested. Shortening `adm` from 48h has an operator +cost, and issuance on this lane is too sparse (3 signature records across 79 days, +`pep-stance.yaml` `emission_cadence`) to price it from intuition. + +T05 stays `wait` on the remaining two: `ops-mason` (no stance map published) and +`railiance-infra` (KRL distribution versus TTL expiry). Neither has replied. + ## Related Review 2026-09-05: reviewed the pending implementation and fixed the identity diff --git a/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md b/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md index a1db405..38a4d5b 100644 --- a/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md +++ b/workplans/WARDEN-WP-0037-whynot-design-forgejo-npm-lane.md @@ -79,3 +79,33 @@ authenticated exact-version lookup proved that Forgejo's remote integrity matches the local dry-run. T03 waits only on rotating the dedicated package token and proving the next fresh version through `whynot-design-npm-publish`; the package migration itself is complete. + +**2026-09-09 — field claim corrected, path routed.** `secrets-engine` (msg +`15f0c0ca`) corrected two things and declined a third, all correctly. + +The KV field is `npm_token`. `NPM_AUTH_TOKEN` is the environment variable their +publication-scope policy injects, and ops-warden had copied the env var in as the +field name — so the catalog's `fetch_command` named a field that does not exist +and could only ever have failed. Corrected in `registry/routing/catalog.yaml` and +`wiki/playbooks/whynot-design-npm-publish.md` from the owner's statement rather +than re-derived here. This was `ADR-0001`'s failure mode rather than a typo: a +pointer layer restating an owner's procedure and getting a detail wrong. + +The endpoint claim already agreed; `delivery_config.npm.registry` has been the +Forgejo URL throughout. + +The **path** is routed to `railiance-platform` and the catalog is unchanged pending +their answer. Which location backs the lane for reads is custody state they own; +`secrets-engine` has no lane read authority to confirm it and refused to rewrite a +production lane pointer from a coordination message (`SECRETS-WP-0006-T06`). Their +reasoning is right, and asserting our own pointer is authoritative because it is +ours would route around it. The ask names a location only and flags that a +`bao kv get` answer would be the 2026-07-16 disclosure vector on a `risk: high` +lane. + +`ready: false` is not being read as path evidence: a source checkout with no +production authority reports not-ready regardless of which path the catalog names. + +T03 still waits on the human rotation of the dedicated Forgejo package token; the +path question does not block that, it determines whether the pointer is correct +once it rotates. diff --git a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md index bda2c13..5476108 100644 --- a/workplans/WARDEN-WP-0039-explicit-policy-refusal.md +++ b/workplans/WARDEN-WP-0039-explicit-policy-refusal.md @@ -74,3 +74,31 @@ authorized by this workplan alone. HFACT-WP-0001-T03 consumes this return. Validation: 429 tests passed (4 integration tests deselected by the repository default); Ruff passed for changed Python files. Full tests used the declared phase-memory source and an isolated temporary memory store. The focused policy/proxy suite passed 69 tests. The existing authenticated SSH policy probe still returns HTTP 200/ALLOW, decision:f3f7c88f9585582a; the credential-owner request returns 403. No CA issue or credential read was performed by these probes. Installed verification: source guard present after refreshed owner installation (`19bb75c`). `warden access forgejo-admin-api-token --exec --field API_TOKEN -- true` exits 4 with explicit HTTP 403 before credential transport or child execution. The previous fail-open warning is absent. The probe requested no output value; no credential was fetched. T01/T02 are complete; T03 owns the remaining exact policy-binding admission. + +**T03 routed 2026-09-09.** Asked `flex-auth` whether an admitted contract exists +for a delegated credential read where the caller (ops-warden) and the resource +owner differ, since that is exactly the shape drawing the live 403. Three outcomes +were named as equally acceptable answers, with no preference stated between the +first two: an existing contract to conform to; no contract yet, making this a +design question they should raise rather than one ops-warden invents a shape for; +or that there should be **no** such contract — the interim proxy transport is +itself the defect and the answer is to finish the native handoff +(`WARDEN-WP-0033` / `SECRETS-WP-0006`). + +The third is a real possibility and was not argued against. Eleven catalog lanes +are ops-warden proxies with a named intended owner precisely because no owner +front door exists; if delegated reads should not be made policy-admissible, that +shortens WP-0033 rather than blocking it. + +Two fixes were explicitly ruled out in the message rather than left unmentioned: +broadening the ops-warden caller binding so it may read other systems' resources, +and relabelling `resource.system` as ops-warden so the existing binding matches. +The second is worse — it would make the audit trail assert ops-warden owns +credentials it deliberately does not, which is the claim `ADR-0002` exists to +prevent, achieved by editing a field instead of making an argument. + +The refusal stays in place until an answer yields positive evidence plus +wrong-caller, wrong-owner and wrong-tenant negatives. No grant was requested. +`secrets-engine` was told this bears on SECRETS-WP-0007-T04 and on how many lanes +stay proxied; `gate-house` was told it may reach how the signing lane's +approval-consume is built (GH-DEC-2026-005).