diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index 092f6a6..f69aece 100644 --- a/registry/generated/high-risk-data-paths.yaml +++ b/registry/generated/high-risk-data-paths.yaml @@ -10,11 +10,11 @@ # declares it, and is null where the field set has not been established -- # null means unknown, never 'one field'. -generated_at: "2026-08-21T07:04:54Z" +generated_at: "2026-08-21T11:24:43Z" source: ops-warden/registry/routing/catalog.yaml catalog_revision: "675e04e8e67869f0e47a3ae55ab37b00112582f2" catalog_revision_date: "2026-08-21T09:04:54+02:00" -catalog_dirty: false +catalog_dirty: true high_risk_lane_count: 19 concrete_path_count: 14 diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 8d30d40..8f189cc 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -73,6 +73,7 @@ entries: mode: native intended_owner: railiance-platform reviewed: "2026-08-15" + verified: unverified # Concrete broker lane — RAILIANCE-WP-0005 pilot (live 2026-07-01): # credential exec injects VAULT_TOKEN only into the child process; ops-warden # issues SSH certs and never mints or holds OpenBao tokens. @@ -109,6 +110,7 @@ entries: intended_owner: railiance-platform blocked_on: "NOT A DELEGABLE LANE. Refused by secrets-engine 2026-08-21: this is a generic routing template (path_template is a // pattern), not one secret lane, so there is no front door for anyone to own. ops-warden agrees. The concrete lanes it resolves to are delegated individually; this entry stays a pointer and should not be counted as an interim cover." reviewed: "2026-08-21" + verified: unverified # Structured handoff (WP-0014) — reference example. Templates only, no values. # ops-warden does not own this secret; it advises and (exec_capable) proxies the # fetch *as the caller* via `warden access`, never holding or persisting the value. @@ -143,6 +145,7 @@ entries: mode: native intended_owner: secrets-engine reviewed: "2026-08-15" + verified: unverified # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0001 (commit 8f617fc): # status=active, access_frontdoor.readiness=ready, resolvable=true; positive fetch # passed and negative (non-whynot) login denied. Zero-placeholder fetch: an automated @@ -186,6 +189,7 @@ entries: mode: native intended_owner: flex-auth reviewed: "2026-08-15" + verified: unverified - id: key-cape-oidc-login title: Interactive login, OIDC token, or MFA @@ -204,6 +208,7 @@ entries: intended_owner: key-cape blocked_on: "REFUSED by secrets-engine 2026-08-21: login, MFA and identity-token issuance are key-cape/Keycloak's; secrets-engine may consume OIDC for OpenBao auth but does not own the login capability. ops-warden agrees — intended_owner corrected from secrets-engine to key-cape. Not yet asked of key-cape." reviewed: "2026-08-21" + verified: asked-and-waiting # Login lane (WP-0014 T4) — interactive auth bootstrap, not a secret read. No # secret-read gate (you have no identity yet) and no caller-auth precheck (the # point is to obtain one). warden runs it interactively as the caller and never @@ -229,6 +234,7 @@ entries: mode: native intended_owner: ops-bridge reviewed: "2026-08-15" + verified: unverified - id: railiance-infra-principals title: Host SSH principal file or force-command deployment @@ -246,6 +252,7 @@ entries: mode: native intended_owner: railiance-infra reviewed: "2026-08-15" + verified: unverified - id: inter-hub-bootstrap-ssh title: Inter-Hub bootstrap SSH envelope @@ -263,6 +270,7 @@ entries: mode: native intended_owner: railiance-infra reviewed: "2026-08-15" + verified: unverified - id: activity-core-issue-sink title: activity-core IssueSink → issue-core REST emission @@ -280,6 +288,7 @@ entries: mode: native intended_owner: activity-core reviewed: "2026-08-15" + verified: unverified - id: issue-core-ingestion-api-key title: issue-core ingestion API key (OpenBao KV + ESO) @@ -306,6 +315,7 @@ entries: intended_owner: secrets-engine blocked_on: "ACCEPTED by secrets-engine 2026-08-21 (SECRETS-WP-0006, decision ae676382). They drafted and hold the catalog entry; ops-warden reviewed it and both sides agree. Interim proxy remains with ops-warden until this lane passes approved native positive/negative verification (SECRETS-WP-0006-T05) — retire only then, not on acceptance." reviewed: "2026-08-21" + verified: owner-confirmed # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0002 / RAILIANCE-WP-0009 # (promoted 2026-07-02): policy workload-kv-read-issue-core-runtime and k8s auth role # external-secrets-issue-core applied; ExternalSecret issue-core/issue-core-runtime @@ -353,6 +363,7 @@ entries: intended_owner: secrets-engine blocked_on: "ACCEPTED by secrets-engine 2026-08-21 (SECRETS-WP-0006, decision ae676382). They drafted and hold the catalog entry; ops-warden reviewed it and both sides agree. Interim proxy remains with ops-warden until this lane passes approved native positive/negative verification (SECRETS-WP-0006-T05) — retire only then, not on acceptance." reviewed: "2026-08-21" + verified: owner-confirmed # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0005 / RAILIANCE-WP-0011 # (promoted 2026-07-07): policy workload-kv-read-reuse-surface-runtime; ExternalSecret # reuse/reuse-surface-runtime SecretSynced to reuse-surface-env on Railiance01; @@ -390,6 +401,7 @@ entries: intended_owner: secrets-engine blocked_on: "ACCEPTED by secrets-engine 2026-08-21 (SECRETS-WP-0006, decision ae676382). They drafted and hold the catalog entry; ops-warden reviewed it and both sides agree. Interim proxy remains with ops-warden until this lane passes approved native positive/negative verification (SECRETS-WP-0006-T05) — retire only then, not on acceptance." reviewed: "2026-08-21" + verified: owner-confirmed # High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04). risk: high # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010 @@ -430,6 +442,7 @@ entries: intended_owner: railiance-platform blocked_on: "Rotation is re-establish, a multi-step procedure ops-warden only describes" reviewed: "2026-08-11" + verified: unverified # High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04). risk: high # CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07. @@ -475,6 +488,7 @@ entries: intended_owner: secrets-engine blocked_on: "ACCEPTED by secrets-engine 2026-08-21 (SECRETS-WP-0006, decision ae676382). They drafted and hold the catalog entry; ops-warden reviewed it and both sides agree. Interim proxy remains with ops-warden until this lane passes approved native positive/negative verification (SECRETS-WP-0006-T05) — retire only then, not on acceptance." reviewed: "2026-08-21" + verified: owner-confirmed # High-risk: site-admin PAT (WP-0026 T04). risk: high # CCR-2026-0006: approved by platform-operator 2026-07-12; policy @@ -517,6 +531,7 @@ entries: intended_owner: tenant-engine blocked_on: "Custody at tenants/binky/... but rotation owner is binky-control — split lifecycle, no front door reconciling it" reviewed: "2026-08-11" + verified: unverified risk: high # CCR-2026-0007: tenants/ mount + policy + OIDC role applied; founder provisioned # values via UI (version ≥2, not placeholder). Capabilities-safe verify 2026-07-17: @@ -551,6 +566,7 @@ entries: intended_owner: tenant-engine blocked_on: "Same split lifecycle as binky-company-email-imap; no tenant-engine front door" reviewed: "2026-08-11" + verified: unverified risk: high # CCR-2026-0008: policy + OIDC role applied; secret at tenants/binky/qonto-api # (fields API_KEY, API_USER). Map to QONTO_API_KEY / QONTO_ORGANIZATION_ID for @@ -586,6 +602,7 @@ entries: intended_owner: key-cape blocked_on: "client_secret_basic exchange is a key-cape protocol procedure, not a KV read; KEY-WP-0008 finished 2026-08-15 without a key-cape-native exchange/rotation command, and the workplan that would add one is not yet opened (key-cape acked interim 2026-08-15)" reviewed: "2026-08-17" + verified: unverified risk: high auth_method: "OpenBao platform workload lane; KeyCape client_secret_basic exchange" path_template: "platform/workloads/rapp-qonto/keycape-client" @@ -616,6 +633,7 @@ entries: intended_owner: railiance-platform blocked_on: "re-establish plus an alternative host-local key path; two ways in, neither owner-fronted (also agent-harness)" reviewed: "2026-08-11" + verified: unverified risk: high # Provisioned 2026-07-17 on railiance01: ed25519 keypair on host, OpenBao copy at # platform/workloads/agent-harness/forgejo-deploy-key, write deploy key on @@ -651,6 +669,7 @@ entries: intended_owner: railiance-platform blocked_on: "AppRole is a host-standing credential; no owner front door for minting or rotating role_id+secret_id" reviewed: "2026-08-15" + verified: unverified risk: high # Provisioned 2026-07-17: role agent-harness-binky-mail bound to existing policy # workload-kv-read-binky-company-email-imap; role_id/secret_id delivered to @@ -689,6 +708,7 @@ entries: mode: native intended_owner: net-kingdom reviewed: "2026-08-15" + verified: unverified - id: database-dynamic-credentials title: Database dynamic credentials (OpenBao secrets engine) @@ -704,6 +724,7 @@ entries: mode: native intended_owner: rapp-postgres reviewed: "2026-08-15" + verified: unverified risk: high exec_capable: false @@ -722,6 +743,7 @@ entries: intended_owner: ops-mason blocked_on: "AppRole is a host-standing credential; no owner-fronted exec for the OpenRouter key read" reviewed: "2026-08-15" + verified: unverified risk: standard # Built 2026-07-27 by ops-mason (MASON-WP-0001-T05), approved by Bernd # Worsch 2026-07-27. Policy + AppRole live; reins/ KV v2 mount created @@ -776,6 +798,7 @@ entries: intended_owner: railiance-apps blocked_on: "Runtime Secret applied via railiance-apps make target; OpenBao path planned but CCR not applied" reviewed: "2026-08-17" + verified: unverified # USER_ENGINE_PROXY_SECRET ownership settled 2026-08-16 (Bernd; State Hub decision # 8fe22037-5bbb-4487-bb86-e4beccee454b, USER-WP-0021): it is infrastructure trust # between ingress and workload, not a user-domain fact. intended_owner stays @@ -816,6 +839,7 @@ entries: mode: native intended_owner: ops-mason reviewed: "2026-08-15" + verified: unverified auth_method: "in-cluster generated Secret audit-core-senders; later OpenBao KV via Mason wrap-migrate" path_template: "platform/workloads/audit-core/senders" policy_ref: "external-secrets-audit-core" @@ -838,6 +862,7 @@ entries: intended_owner: secrets-engine blocked_on: "ACCEPTED by secrets-engine 2026-08-21 (SECRETS-WP-0006, decision ae676382). They drafted and hold the catalog entry; ops-warden reviewed it and both sides agree. Interim proxy remains with ops-warden until this lane passes approved native positive/negative verification (SECRETS-WP-0006-T05) — retire only then, not on acceptance." reviewed: "2026-08-21" + verified: owner-confirmed risk: high # CCR-2026-0010 approved 2026-08-12; applied same day (EMAIL-WP-0004-T03): # policies external-secrets-email-connect + workload-kv-read-email-connect-transactional, @@ -879,6 +904,7 @@ entries: intended_owner: railiance-platform blocked_on: "Founder bootstrap API key; draft until provisioned; no owner-fronted exec" reviewed: "2026-08-15" + verified: unverified risk: high # CCR-2026-0011. Values via founder paste-once or local tfvars ingest. # Not the Barman runtime key (platform-pg-backup-s3). diff --git a/src/warden/cli.py b/src/warden/cli.py index 6419ce5..1ce906e 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -771,6 +771,24 @@ def route_list( ) +from warden.routing.catalog import DEFAULT_BLOCKER_STALE_DAYS + + +def _gap_is_stale(delegation, reviewed: str, stale_days: int) -> bool: + """An interim lane needs attention on either of two independent grounds. + + Age is the obvious one. The other is that the review was never a + verification: an `asked-and-waiting` lane is fresh on the day the question + goes out and stays fresh while nobody answers, which is exactly how the + secrets-engine blocker looked current for ten days (WARDEN-WP-0033-T05). + """ + from warden.routing.catalog import days_since_review + + if days_since_review(reviewed) > stale_days: + return True + return delegation.verified is not None and not delegation.is_verified + + @route_app.command("gaps") def route_gaps( output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, @@ -779,12 +797,20 @@ def route_gaps( int, typer.Option( "--stale-days", - help="Days since delegation review before an interim lane is stale (default 90)", + help="Days since a blocker was verified before an interim lane is stale " + "(default 14 — see DEFAULT_BLOCKER_STALE_DAYS)", min=1, ), - ] = 90, + ] = DEFAULT_BLOCKER_STALE_DAYS, + fail_on_stale: Annotated[ + bool, + typer.Option( + "--fail-on-stale", + help="Exit 3 if any interim lane needs re-verifying (for cron or a gate)", + ), + ] = False, ) -> None: - """List interim lanes: intended owner, blocker, and age since review.""" + """List interim lanes: intended owner, blocker, and age since verification.""" from warden.routing.catalog import days_since_review catalog = _load_catalog() @@ -805,11 +831,15 @@ def route_gaps( "blocked_on": d.blocked_on, "reviewed": reviewed, "days_since_review": days_since_review(reviewed), + "verified": d.verified, + "is_verified": d.is_verified, "implicit": d.implicit, - "stale": days_since_review(reviewed) > stale_days, + "stale": _gap_is_stale(d, reviewed, stale_days), } ) print(json.dumps(payload, indent=2)) + if fail_on_stale and any(row["stale"] for row in payload): + raise typer.Exit(3) return if not entries: @@ -822,13 +852,18 @@ def route_gaps( table.add_column("Blocked on") table.add_column("Reviewed") table.add_column("Days") + table.add_column("Verified") table.add_column("Status") for e in entries: d = e.effective_delegation reviewed = d.reviewed or e.reviewed days = days_since_review(reviewed) - reviewed_styled = f"[yellow]{reviewed}[/yellow]" if days > stale_days else reviewed - days_styled = f"[yellow]{days}[/yellow]" if days > stale_days else str(days) + stale = _gap_is_stale(d, reviewed, stale_days) + reviewed_styled = f"[yellow]{reviewed}[/yellow]" if stale else reviewed + days_styled = f"[yellow]{days}[/yellow]" if stale else str(days) + verified_styled = ( + d.verified if d.is_verified else f"[yellow]{d.verified or 'unrecorded'}[/yellow]" + ) status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]" table.add_row( e.id, @@ -836,18 +871,41 @@ def route_gaps( d.blocked_on or "", reviewed_styled, days_styled, + verified_styled, status_styled, ) console.print(table) - stale_n = sum( - 1 - for e in entries - if days_since_review(e.effective_delegation.reviewed or e.reviewed) > stale_days - ) - if stale_n: - console.print( - f"[yellow]{stale_n} interim lane(s) past {stale_days}d review cadence.[/yellow]" + + stale_entries = [ + e for e in entries + if _gap_is_stale( + e.effective_delegation, + e.effective_delegation.reviewed or e.reviewed, + stale_days, ) + ] + if stale_entries: + # Say which of the two reasons applies. "Past cadence" and "never actually + # checked" call for different actions, and collapsing them is how an + # asked-and-waiting lane reads as reviewed. + aged = [ + e for e in stale_entries + if days_since_review(e.effective_delegation.reviewed or e.reviewed) > stale_days + ] + unverified = [e for e in stale_entries if e not in aged] + if aged: + console.print( + f"[yellow]{len(aged)} interim lane(s) past the {stale_days}d blocker " + f"cadence — re-check the blocker, do not just bump the date.[/yellow]" + ) + if unverified: + console.print( + f"[yellow]{len(unverified)} interim lane(s) reviewed but not verified " + f"(asked-and-waiting or unverified) — the claim was never " + f"re-established.[/yellow]" + ) + if fail_on_stale and stale_entries: + raise typer.Exit(3) @route_app.command("show") diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index c9543fc..1e463f5 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -69,9 +69,24 @@ _VALID_STATUS = ("active", "draft") _VALID_LANES = ("secret", "login") _VALID_ROTATION_METHODS = ("rotate", "re-establish") -# Default review cadence — see wiki/AccessRouting.md#drift-review-cadence +# Default review cadence for a catalog pointer — "is this still the right owner +# and page?" That is a genuinely quarterly question, so 90 days is right for it. +# See wiki/AccessRouting.md#drift-review-cadence DEFAULT_STALE_DAYS = 90 +# Cadence for an interim lane's *blocker*, which is a different kind of claim +# with a much shorter half-life: "has the intended owner answered / can they +# front this yet?" (WARDEN-WP-0033-T05). +# +# 14 rather than 90 because 90 was never a loose default, it was an inert one -- +# the delegation register was created 2026-08-15, so a 90-day threshold could not +# fire before November and never had. Calibrated instead against blockers that +# actually went stale: the secrets-engine lanes cost ten days, RISK-F-0001 +# invalidated an ops-warden blocker in one, and the FLEX-WP-0007 claim was +# repeated by two repos for roughly fifty. 14 catches the ten-day cases and, at +# ~15 interim lanes, surfaces about one lane a day rather than a wall of them. +DEFAULT_BLOCKER_STALE_DAYS = 14 + def days_since_review(reviewed: str, *, today: Optional[date] = None) -> int: """Calendar days between reviewed date (YYYY-MM-DD) and today.""" @@ -214,16 +229,25 @@ class Catalog: def stale_gaps( self, include_draft: bool = False, - threshold_days: int = DEFAULT_STALE_DAYS, + threshold_days: int = DEFAULT_BLOCKER_STALE_DAYS, *, today: Optional[date] = None, ) -> List[RouteEntry]: - """Interim lanes whose delegation review is past the cadence threshold.""" + """Interim lanes whose blocker is due a re-check. + + A lane counts as stale when its review date is past the threshold **or** + when the review was never a verification at all. An `asked-and-waiting` + entry is the case that motivated this: it looks freshly reviewed on the + day the question is asked and stays that way while nobody answers. + """ out: List[RouteEntry] = [] for e in self.gaps(include_draft=include_draft): - reviewed = e.effective_delegation.reviewed or e.reviewed + d = e.effective_delegation + reviewed = d.reviewed or e.reviewed if is_review_stale(reviewed, threshold_days=threshold_days, today=today): out.append(e) + elif d.verified is not None and not d.is_verified: + out.append(e) return out def freshness( @@ -261,14 +285,16 @@ class Catalog: f"{stale_count} catalog entr{'y' if stale_count == 1 else 'ies'} " f"past {stale_threshold_days}d review cadence" ) + # Interim blockers run on their own, much shorter cadence -- a stale + # pointer and an unanswered blocker are not the same kind of drift. stale_interim = len(self.stale_gaps( - include_draft=True, threshold_days=stale_threshold_days, today=today + include_draft=True, threshold_days=DEFAULT_BLOCKER_STALE_DAYS, today=today )) if stale_interim: warnings.append( f"{stale_interim} interim delegation" - f"{'' if stale_interim == 1 else 's'} past " - f"{stale_threshold_days}d review — see `warden route gaps`" + f"{'' if stale_interim == 1 else 's'} need re-verifying " + f"({DEFAULT_BLOCKER_STALE_DAYS}d blocker cadence) — see `warden route gaps`" ) return CatalogFreshness( @@ -447,11 +473,19 @@ def _parse_delegation(entry_id: str, raw: Optional[dict]) -> Optional[Delegation entry_id, "delegation.blocked_on", blocked_on, prose=True ) + verified = str(raw.get("verified", "")).strip() or None + if verified is not None and verified not in Delegation.VERIFICATION_METHODS: + raise CatalogError( + f"entry {entry_id!r} delegation.verified {verified!r} invalid " + f"(expected one of {Delegation.VERIFICATION_METHODS})" + ) + return Delegation( mode=mode, intended_owner=intended_owner, blocked_on=blocked_on, reviewed=reviewed, + verified=verified, implicit=False, ) diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index 86695b8..9528e15 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -64,14 +64,38 @@ class Delegation: intended_owner: Optional[str] = None blocked_on: Optional[str] = None reviewed: Optional[str] = None + verified: Optional[str] = None implicit: bool = False + #: How `reviewed` was established. The distinction exists because a date + #: bumped by editing the entry looks identical to one bumped by re-checking + #: the blocker, and on 2026-08-21 six lanes read as freshly reviewed when + #: only some had actually been re-verified (WARDEN-WP-0033-T05). + #: + #: `asked-and-waiting` deliberately does NOT count as verification: it is the + #: state the secrets-engine lanes sat in for ten days while looking fresh. + VERIFICATION_METHODS = ( + "owner-confirmed", # the intended owner stated the blocker's status + "source-read", # re-derived from the owner's code, canon, or CCR + "asked-and-waiting", # a question is outstanding — NOT verification + "unverified", # carried forward without a check + ) + #: Methods that mean the claim was actually re-established. + VERIFYING_METHODS = ("owner-confirmed", "source-read") + + @property + def is_verified(self) -> bool: + """True only when the blocker was re-established, not merely re-edited.""" + return self.verified in self.VERIFYING_METHODS + def to_dict(self) -> dict: return { "mode": self.mode, "intended_owner": self.intended_owner, "blocked_on": self.blocked_on, "reviewed": self.reviewed, + "verified": self.verified, + "is_verified": self.is_verified, "implicit": self.implicit, } diff --git a/tests/test_routing.py b/tests/test_routing.py index 7892a89..3b1ddd6 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -779,3 +779,78 @@ def test_low_risk_vocabulary_is_explicit(): entry = _bare_entry(risk=grade) assert entry.is_high_risk is False, grade assert entry.is_graded is True, grade + + +# --------------------------------------------------------------------------- +# Blocker staleness cadence + verification (WARDEN-WP-0033-T05) +# --------------------------------------------------------------------------- + +def test_blocker_cadence_is_separate_from_pointer_cadence(): + """Two claims with different half-lives must not share one threshold. + + "Is this still the right owner and page?" is quarterly. "Has the owner + answered yet?" is not. Sharing 90 days made the second one inert -- the + register was six days old, so it could not have fired for months. + """ + from warden.routing.catalog import DEFAULT_BLOCKER_STALE_DAYS, DEFAULT_STALE_DAYS + + assert DEFAULT_STALE_DAYS == 90 + assert DEFAULT_BLOCKER_STALE_DAYS == 14 + assert DEFAULT_BLOCKER_STALE_DAYS < DEFAULT_STALE_DAYS + + +def test_asked_and_waiting_is_not_verification(): + """The failure this whole change exists to catch. + + A lane asked today reads as reviewed today. The secrets-engine blocker sat + in exactly that state for ten days while looking current. + """ + from warden.routing.models import Delegation + + asked = Delegation(mode="interim", intended_owner="x", blocked_on="y", + reviewed="2026-08-21", verified="asked-and-waiting") + assert asked.is_verified is False + + for method in ("owner-confirmed", "source-read"): + d = Delegation(mode="interim", intended_owner="x", blocked_on="y", + reviewed="2026-08-21", verified=method) + assert d.is_verified is True, method + + +def test_stale_gaps_flags_unverified_even_when_the_date_is_today(): + catalog = load_catalog(_repo_catalog()) + stale = {e.id for e in catalog.stale_gaps(include_draft=True, today=date(2026, 8, 21))} + # Asked of key-cape on 2026-08-21 and unanswered -- zero days old, still stale. + assert "key-cape-oidc-login" in stale + # Confirmed by the owner the same day -- fresh. + assert "issue-core-ingestion-api-key" not in stale + + +def test_invalid_verification_method_rejected(tmp_path): + entry = dict(ROUTED_ENTRY) + entry["delegation"] = { + "mode": "interim", "intended_owner": "secrets-engine", + "blocked_on": "pending", "reviewed": "2026-08-21", "verified": "probably-fine", + } + with pytest.raises(CatalogError, match="verified"): + load_catalog(_write_catalog(tmp_path, [SSH_ENTRY, entry])) + + +def test_every_interim_lane_records_how_it_was_verified(): + """Structural, not time-based, so it never fails on a calendar day alone.""" + catalog = load_catalog(_repo_catalog()) + missing = [ + e.id for e in catalog.gaps(include_draft=True) + if e.effective_delegation.verified is None + ] + assert not missing, f"interim lanes with no `verified`: {missing}" + + +def test_cli_route_gaps_fail_on_stale_exits_3(repo_catalog_env): + result = runner.invoke(app, ["route", "gaps", "--fail-on-stale", "--json"]) + assert result.exit_code == 3 + rows = json.loads(result.stdout) + assert any(r["stale"] for r in rows) + # A lane can be stale on age or on never having been verified; both must be + # expressible, or asked-and-waiting silently passes the gate. + assert any(r["stale"] and r["days_since_review"] == 0 for r in rows) diff --git a/wiki/AccessRouting.md b/wiki/AccessRouting.md index 6c99c83..c46b0b1 100644 --- a/wiki/AccessRouting.md +++ b/wiki/AccessRouting.md @@ -184,7 +184,56 @@ owner repo's shipped path. | **On canon change** | When net-kingdom security docs change, review affected `canon_ref` entries immediately | | **On owner ship** | When an owning repo merges a new OpenBao path or playbook, promote `draft` → `active` and bump `reviewed` | | **On agent confusion** | If `warden route find` misses a common query, add `need_keywords` or a playbook — do not restate owner procedure in the catalog | -| **On interim review** | Run `warden route gaps` — re-check each `blocked_on` against the intended owner; flip to `native` when their front door exists | +| **Fortnightly** (default 14 days) | Run `warden route gaps` — re-check each `blocked_on` against the intended owner; flip to `native` when their front door exists | + +### Two cadences, because they are two different claims + +A catalog pointer and an interim blocker both carry a `reviewed:` date, and for a +while they shared one 90-day threshold. They should not. + +| Claim | Question | Default | Where | +| --- | --- | --- | --- | +| Pointer freshness | Is this still the right owner and page? | **90 days** | `warden route list --stale` | +| Interim blocker | Has the intended owner answered / can they front this yet? | **14 days** | `warden route gaps` | + +A pointer genuinely is a quarterly question. A blocker is not: it is a claim +about another repo's state at a date, and this estate invalidates those in days. +`RISK-F-0001` invalidated an ops-warden blocker in one. The `secrets-engine` +lanes carried one for ten while it was answerable from that repo's source the +whole time. A `FLEX-WP-0007` blocker was repeated by two repos for about seven +weeks after that workplan read `finished`. + +Sharing 90 days did not make the blocker check loose, it made it **inert**: the +delegation register was created 2026-08-15, so the threshold could not have fired +before November and never had. + +### Reviewed is not verified + +The more important half. A `reviewed:` date records when someone last *touched* +the entry, which looks identical to re-checking it. So every interim lane also +carries `verified:`, saying how the claim was established: + +| Value | Meaning | Counts as verification | +| --- | --- | --- | +| `owner-confirmed` | the intended owner stated the blocker's status | **yes** | +| `source-read` | re-derived from the owner's code, canon, or a CCR | **yes** | +| `asked-and-waiting` | a question is outstanding | **no** | +| `unverified` | carried forward without a check | **no** | + +`asked-and-waiting` deliberately does not reset the clock. A lane asked today +reads as reviewed today, and that is precisely how a blocker stays fresh-looking +while nobody answers it. `warden route gaps` flags such a lane as stale at zero +days old. + +**Re-check the blocker; do not bump the date.** Bumping `reviewed` without +re-establishing the claim is the failure this section exists to prevent, and it +is cheap to avoid — most of these are answerable by reading the owner's repo. + +```bash +warden route gaps # 14-day blocker cadence, plus unverified lanes +warden route gaps --stale-days 30 # looser threshold +warden route gaps --fail-on-stale # exit 3 — for a cron job or a gate +``` ### Stale check (operators and agents) diff --git a/workplans/WARDEN-WP-0033-native-lane-handoff.md b/workplans/WARDEN-WP-0033-native-lane-handoff.md index 52eb47d..ca16250 100644 --- a/workplans/WARDEN-WP-0033-native-lane-handoff.md +++ b/workplans/WARDEN-WP-0033-native-lane-handoff.md @@ -194,6 +194,51 @@ avoid is the 2026-08-17 one recorded in `.claude/rules/finding-routing.md`: ops-warden answered a question well and never routed it, and another repo ended up filing it. +```task +id: WARDEN-WP-0033-T05 +status: done +priority: medium +``` + +**Make blocker staleness a mechanism instead of an intention.** + +Four stale blockers surfaced in twelve hours: an OpenBao token recorded expired +that was valid, a verification script recorded ready that had never been written, +a ten-day `secrets-engine` question answerable from their source, and +`FLEX-WP-0007` cited as blocking `policy.enabled` seven weeks after it read +`finished`. `.claude/rules/finding-routing.md` has said *re-read a blocker before +trusting it* since `RISK-F-0001`. Saying it has not worked. + +`warden route gaps` had a `--stale-days` threshold defaulting to 90, which was +not loose but **inert** — the register was created 2026-08-15, so it could not +fire before November. + +**Done 2026-08-21.** Two changes, and the second matters more: + +1. **Split the cadences.** `DEFAULT_STALE_DAYS` (90) still governs pointer + freshness — "is this the right owner and page" genuinely is quarterly. New + `DEFAULT_BLOCKER_STALE_DAYS` (14) governs interim blockers, calibrated on the + cases that cost something: ten days for the secrets-engine lanes, one day for + `RISK-F-0001`, ~50 for `FLEX-WP-0007`. At ~15 lanes it surfaces about one a + day rather than a wall, and it fires on zero lanes on age today. + +2. **`verified:` distinguishes re-checking from re-editing.** `reviewed` records + when someone touched the entry; nothing recorded whether the claim was + re-established. `owner-confirmed` and `source-read` count; + **`asked-and-waiting` and `unverified` do not**, and a lane in those states is + stale regardless of its date. `key-cape-oidc-login` is the proof: asked today, + zero days old, correctly flagged. + +Current state: 14 non-draft interim lanes — 5 `owner-confirmed`, 1 +`asked-and-waiting`, 8 `unverified`. The 8 are honest; they were carried forward +this session without a check and now say so instead of hiding behind a fresh date. + +`--fail-on-stale` exits 3 so a cron job or gate can act. **Deliberately not a CI +test on age**: a date-triggered failure breaks the build on a calendar day with +no code change, punishing whoever commits next rather than whoever owns the +blocker. The CI test is structural instead — every interim lane must record +*how* it was verified, which fails on the commit that introduces the omission. + ## Related - `secrets-engine` `SECRETS-WP-0006` — catalog admission, decision `ae676382`