WARDEN-WP-0033-T05: split the stale cadences, and record how a blocker was verified
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The 90-day --stale-days default on `warden route gaps` was not a loose threshold,
it was an inert one: the delegation register was created 2026-08-15, so it could
not have fired before November. It was inherited from the catalog pointer cadence
and applied to a claim with a completely different half-life.

Two changes. DEFAULT_BLOCKER_STALE_DAYS = 14 now governs interim blockers, while
DEFAULT_STALE_DAYS = 90 keeps governing pointer freshness -- "is this the right
owner and page" is quarterly, "has the owner answered" is not. 14 is calibrated
on blockers that actually cost something: ten days for the secrets-engine lanes,
one for RISK-F-0001, roughly fifty for FLEX-WP-0007.

The second change matters more. `reviewed` records when someone touched an entry,
which is indistinguishable from re-checking it -- six lanes read as freshly
reviewed today because I typed in them. `verified:` now says how the claim was
established, and asked-and-waiting explicitly does NOT count: that is the state
the secrets-engine blocker sat in for ten days while looking current. A lane in
that state is stale at zero days old, and key-cape-oidc-login proves it works.

8 of 14 interim lanes are honestly marked unverified rather than given a fresh
date they did not earn.

--fail-on-stale exits 3 for a cron or gate. No CI test on age: a date-triggered
failure breaks the build for whoever commits next instead of whoever owns the
blocker. The CI test is structural -- every interim lane must record how it was
verified -- so it fails on the commit that introduces the omission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-21 13:26:10 +02:00
parent a565e62b2f
commit 55f0f47a02
8 changed files with 335 additions and 24 deletions

View file

@ -10,11 +10,11 @@
# declares it, and is null where the field set has not been established -- # declares it, and is null where the field set has not been established --
# null means unknown, never 'one field'. # 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 source: ops-warden/registry/routing/catalog.yaml
catalog_revision: "675e04e8e67869f0e47a3ae55ab37b00112582f2" catalog_revision: "675e04e8e67869f0e47a3ae55ab37b00112582f2"
catalog_revision_date: "2026-08-21T09:04:54+02:00" catalog_revision_date: "2026-08-21T09:04:54+02:00"
catalog_dirty: false catalog_dirty: true
high_risk_lane_count: 19 high_risk_lane_count: 19
concrete_path_count: 14 concrete_path_count: 14

View file

@ -73,6 +73,7 @@ entries:
mode: native mode: native
intended_owner: railiance-platform intended_owner: railiance-platform
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
# Concrete broker lane — RAILIANCE-WP-0005 pilot (live 2026-07-01): # Concrete broker lane — RAILIANCE-WP-0005 pilot (live 2026-07-01):
# credential exec injects VAULT_TOKEN only into the child process; ops-warden # credential exec injects VAULT_TOKEN only into the child process; ops-warden
# issues SSH certs and never mints or holds OpenBao tokens. # issues SSH certs and never mints or holds OpenBao tokens.
@ -109,6 +110,7 @@ entries:
intended_owner: railiance-platform 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 <domain>/<workload>/<bundle> 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." blocked_on: "NOT A DELEGABLE LANE. Refused by secrets-engine 2026-08-21: this is a generic routing template (path_template is a <domain>/<workload>/<bundle> 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" reviewed: "2026-08-21"
verified: unverified
# Structured handoff (WP-0014) — reference example. Templates only, no values. # Structured handoff (WP-0014) — reference example. Templates only, no values.
# ops-warden does not own this secret; it advises and (exec_capable) proxies the # 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. # fetch *as the caller* via `warden access`, never holding or persisting the value.
@ -143,6 +145,7 @@ entries:
mode: native mode: native
intended_owner: secrets-engine intended_owner: secrets-engine
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
# Concrete, owner-confirmed lane — railiance-platform CCR-2026-0001 (commit 8f617fc): # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0001 (commit 8f617fc):
# status=active, access_frontdoor.readiness=ready, resolvable=true; positive fetch # status=active, access_frontdoor.readiness=ready, resolvable=true; positive fetch
# passed and negative (non-whynot) login denied. Zero-placeholder fetch: an automated # passed and negative (non-whynot) login denied. Zero-placeholder fetch: an automated
@ -186,6 +189,7 @@ entries:
mode: native mode: native
intended_owner: flex-auth intended_owner: flex-auth
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: key-cape-oidc-login - id: key-cape-oidc-login
title: Interactive login, OIDC token, or MFA title: Interactive login, OIDC token, or MFA
@ -204,6 +208,7 @@ entries:
intended_owner: key-cape 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." 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" reviewed: "2026-08-21"
verified: asked-and-waiting
# Login lane (WP-0014 T4) — interactive auth bootstrap, not a secret read. No # 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 # 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 # point is to obtain one). warden runs it interactively as the caller and never
@ -229,6 +234,7 @@ entries:
mode: native mode: native
intended_owner: ops-bridge intended_owner: ops-bridge
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: railiance-infra-principals - id: railiance-infra-principals
title: Host SSH principal file or force-command deployment title: Host SSH principal file or force-command deployment
@ -246,6 +252,7 @@ entries:
mode: native mode: native
intended_owner: railiance-infra intended_owner: railiance-infra
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: inter-hub-bootstrap-ssh - id: inter-hub-bootstrap-ssh
title: Inter-Hub bootstrap SSH envelope title: Inter-Hub bootstrap SSH envelope
@ -263,6 +270,7 @@ entries:
mode: native mode: native
intended_owner: railiance-infra intended_owner: railiance-infra
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: activity-core-issue-sink - id: activity-core-issue-sink
title: activity-core IssueSink → issue-core REST emission title: activity-core IssueSink → issue-core REST emission
@ -280,6 +288,7 @@ entries:
mode: native mode: native
intended_owner: activity-core intended_owner: activity-core
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: issue-core-ingestion-api-key - id: issue-core-ingestion-api-key
title: issue-core ingestion API key (OpenBao KV + ESO) title: issue-core ingestion API key (OpenBao KV + ESO)
@ -306,6 +315,7 @@ entries:
intended_owner: secrets-engine 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." 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" reviewed: "2026-08-21"
verified: owner-confirmed
# Concrete, owner-confirmed lane — railiance-platform CCR-2026-0002 / RAILIANCE-WP-0009 # 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 # (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 # external-secrets-issue-core applied; ExternalSecret issue-core/issue-core-runtime
@ -353,6 +363,7 @@ entries:
intended_owner: secrets-engine 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." 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" reviewed: "2026-08-21"
verified: owner-confirmed
# Concrete, owner-confirmed lane — railiance-platform CCR-2026-0005 / RAILIANCE-WP-0011 # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0005 / RAILIANCE-WP-0011
# (promoted 2026-07-07): policy workload-kv-read-reuse-surface-runtime; ExternalSecret # (promoted 2026-07-07): policy workload-kv-read-reuse-surface-runtime; ExternalSecret
# reuse/reuse-surface-runtime SecretSynced to reuse-surface-env on Railiance01; # reuse/reuse-surface-runtime SecretSynced to reuse-surface-env on Railiance01;
@ -390,6 +401,7 @@ entries:
intended_owner: secrets-engine 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." 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" reviewed: "2026-08-21"
verified: owner-confirmed
# High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04). # High-risk: provider API key with spend impact + prompt-adjacent (WP-0026 T04).
risk: high risk: high
# Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010 # Concrete, owner-confirmed lane — railiance-platform CCR-2026-0003 / RAILIANCE-WP-0010
@ -430,6 +442,7 @@ entries:
intended_owner: railiance-platform intended_owner: railiance-platform
blocked_on: "Rotation is re-establish, a multi-step procedure ops-warden only describes" blocked_on: "Rotation is re-establish, a multi-step procedure ops-warden only describes"
reviewed: "2026-08-11" reviewed: "2026-08-11"
verified: unverified
# High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04). # High-risk: WebDAV upload token + AGE recovery escrow (WP-0026 T04).
risk: high risk: high
# CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07. # CCR-2026-0004: policy + OIDC role applied; values provisioned 2026-07-07.
@ -475,6 +488,7 @@ entries:
intended_owner: secrets-engine 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." 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" reviewed: "2026-08-21"
verified: owner-confirmed
# High-risk: site-admin PAT (WP-0026 T04). # High-risk: site-admin PAT (WP-0026 T04).
risk: high risk: high
# CCR-2026-0006: approved by platform-operator 2026-07-12; policy # CCR-2026-0006: approved by platform-operator 2026-07-12; policy
@ -517,6 +531,7 @@ entries:
intended_owner: tenant-engine intended_owner: tenant-engine
blocked_on: "Custody at tenants/binky/... but rotation owner is binky-control — split lifecycle, no front door reconciling it" blocked_on: "Custody at tenants/binky/... but rotation owner is binky-control — split lifecycle, no front door reconciling it"
reviewed: "2026-08-11" reviewed: "2026-08-11"
verified: unverified
risk: high risk: high
# CCR-2026-0007: tenants/ mount + policy + OIDC role applied; founder provisioned # CCR-2026-0007: tenants/ mount + policy + OIDC role applied; founder provisioned
# values via UI (version ≥2, not placeholder). Capabilities-safe verify 2026-07-17: # values via UI (version ≥2, not placeholder). Capabilities-safe verify 2026-07-17:
@ -551,6 +566,7 @@ entries:
intended_owner: tenant-engine intended_owner: tenant-engine
blocked_on: "Same split lifecycle as binky-company-email-imap; no tenant-engine front door" blocked_on: "Same split lifecycle as binky-company-email-imap; no tenant-engine front door"
reviewed: "2026-08-11" reviewed: "2026-08-11"
verified: unverified
risk: high risk: high
# CCR-2026-0008: policy + OIDC role applied; secret at tenants/binky/qonto-api # 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 # (fields API_KEY, API_USER). Map to QONTO_API_KEY / QONTO_ORGANIZATION_ID for
@ -586,6 +602,7 @@ entries:
intended_owner: key-cape 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)" 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" reviewed: "2026-08-17"
verified: unverified
risk: high risk: high
auth_method: "OpenBao platform workload lane; KeyCape client_secret_basic exchange" auth_method: "OpenBao platform workload lane; KeyCape client_secret_basic exchange"
path_template: "platform/workloads/rapp-qonto/keycape-client" path_template: "platform/workloads/rapp-qonto/keycape-client"
@ -616,6 +633,7 @@ entries:
intended_owner: railiance-platform intended_owner: railiance-platform
blocked_on: "re-establish plus an alternative host-local key path; two ways in, neither owner-fronted (also agent-harness)" blocked_on: "re-establish plus an alternative host-local key path; two ways in, neither owner-fronted (also agent-harness)"
reviewed: "2026-08-11" reviewed: "2026-08-11"
verified: unverified
risk: high risk: high
# Provisioned 2026-07-17 on railiance01: ed25519 keypair on host, OpenBao copy at # Provisioned 2026-07-17 on railiance01: ed25519 keypair on host, OpenBao copy at
# platform/workloads/agent-harness/forgejo-deploy-key, write deploy key on # platform/workloads/agent-harness/forgejo-deploy-key, write deploy key on
@ -651,6 +669,7 @@ entries:
intended_owner: railiance-platform intended_owner: railiance-platform
blocked_on: "AppRole is a host-standing credential; no owner front door for minting or rotating role_id+secret_id" blocked_on: "AppRole is a host-standing credential; no owner front door for minting or rotating role_id+secret_id"
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
risk: high risk: high
# Provisioned 2026-07-17: role agent-harness-binky-mail bound to existing policy # 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 # workload-kv-read-binky-company-email-imap; role_id/secret_id delivered to
@ -689,6 +708,7 @@ entries:
mode: native mode: native
intended_owner: net-kingdom intended_owner: net-kingdom
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
- id: database-dynamic-credentials - id: database-dynamic-credentials
title: Database dynamic credentials (OpenBao secrets engine) title: Database dynamic credentials (OpenBao secrets engine)
@ -704,6 +724,7 @@ entries:
mode: native mode: native
intended_owner: rapp-postgres intended_owner: rapp-postgres
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
risk: high risk: high
exec_capable: false exec_capable: false
@ -722,6 +743,7 @@ entries:
intended_owner: ops-mason intended_owner: ops-mason
blocked_on: "AppRole is a host-standing credential; no owner-fronted exec for the OpenRouter key read" blocked_on: "AppRole is a host-standing credential; no owner-fronted exec for the OpenRouter key read"
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
risk: standard risk: standard
# Built 2026-07-27 by ops-mason (MASON-WP-0001-T05), approved by Bernd # 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 # Worsch 2026-07-27. Policy + AppRole live; reins/ KV v2 mount created
@ -776,6 +798,7 @@ entries:
intended_owner: railiance-apps intended_owner: railiance-apps
blocked_on: "Runtime Secret applied via railiance-apps make target; OpenBao path planned but CCR not applied" blocked_on: "Runtime Secret applied via railiance-apps make target; OpenBao path planned but CCR not applied"
reviewed: "2026-08-17" reviewed: "2026-08-17"
verified: unverified
# USER_ENGINE_PROXY_SECRET ownership settled 2026-08-16 (Bernd; State Hub decision # 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 # 8fe22037-5bbb-4487-bb86-e4beccee454b, USER-WP-0021): it is infrastructure trust
# between ingress and workload, not a user-domain fact. intended_owner stays # between ingress and workload, not a user-domain fact. intended_owner stays
@ -816,6 +839,7 @@ entries:
mode: native mode: native
intended_owner: ops-mason intended_owner: ops-mason
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
auth_method: "in-cluster generated Secret audit-core-senders; later OpenBao KV via Mason wrap-migrate" auth_method: "in-cluster generated Secret audit-core-senders; later OpenBao KV via Mason wrap-migrate"
path_template: "platform/workloads/audit-core/senders" path_template: "platform/workloads/audit-core/senders"
policy_ref: "external-secrets-audit-core" policy_ref: "external-secrets-audit-core"
@ -838,6 +862,7 @@ entries:
intended_owner: secrets-engine 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." 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" reviewed: "2026-08-21"
verified: owner-confirmed
risk: high risk: high
# CCR-2026-0010 approved 2026-08-12; applied same day (EMAIL-WP-0004-T03): # 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, # policies external-secrets-email-connect + workload-kv-read-email-connect-transactional,
@ -879,6 +904,7 @@ entries:
intended_owner: railiance-platform intended_owner: railiance-platform
blocked_on: "Founder bootstrap API key; draft until provisioned; no owner-fronted exec" blocked_on: "Founder bootstrap API key; draft until provisioned; no owner-fronted exec"
reviewed: "2026-08-15" reviewed: "2026-08-15"
verified: unverified
risk: high risk: high
# CCR-2026-0011. Values via founder paste-once or local tfvars ingest. # CCR-2026-0011. Values via founder paste-once or local tfvars ingest.
# Not the Barman runtime key (platform-pg-backup-s3). # Not the Barman runtime key (platform-pg-backup-s3).

View file

@ -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") @route_app.command("gaps")
def route_gaps( def route_gaps(
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
@ -779,12 +797,20 @@ def route_gaps(
int, int,
typer.Option( typer.Option(
"--stale-days", "--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, 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: ) -> 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 from warden.routing.catalog import days_since_review
catalog = _load_catalog() catalog = _load_catalog()
@ -805,11 +831,15 @@ def route_gaps(
"blocked_on": d.blocked_on, "blocked_on": d.blocked_on,
"reviewed": reviewed, "reviewed": reviewed,
"days_since_review": days_since_review(reviewed), "days_since_review": days_since_review(reviewed),
"verified": d.verified,
"is_verified": d.is_verified,
"implicit": d.implicit, "implicit": d.implicit,
"stale": days_since_review(reviewed) > stale_days, "stale": _gap_is_stale(d, reviewed, stale_days),
} }
) )
print(json.dumps(payload, indent=2)) print(json.dumps(payload, indent=2))
if fail_on_stale and any(row["stale"] for row in payload):
raise typer.Exit(3)
return return
if not entries: if not entries:
@ -822,13 +852,18 @@ def route_gaps(
table.add_column("Blocked on") table.add_column("Blocked on")
table.add_column("Reviewed") table.add_column("Reviewed")
table.add_column("Days") table.add_column("Days")
table.add_column("Verified")
table.add_column("Status") table.add_column("Status")
for e in entries: for e in entries:
d = e.effective_delegation d = e.effective_delegation
reviewed = d.reviewed or e.reviewed reviewed = d.reviewed or e.reviewed
days = days_since_review(reviewed) days = days_since_review(reviewed)
reviewed_styled = f"[yellow]{reviewed}[/yellow]" if days > stale_days else reviewed stale = _gap_is_stale(d, reviewed, stale_days)
days_styled = f"[yellow]{days}[/yellow]" if days > stale_days else str(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]" status_styled = e.status if e.status == "active" else f"[yellow]{e.status}[/yellow]"
table.add_row( table.add_row(
e.id, e.id,
@ -836,18 +871,41 @@ def route_gaps(
d.blocked_on or "", d.blocked_on or "",
reviewed_styled, reviewed_styled,
days_styled, days_styled,
verified_styled,
status_styled, status_styled,
) )
console.print(table) console.print(table)
stale_n = sum(
1 stale_entries = [
for e in entries e for e in entries
if days_since_review(e.effective_delegation.reviewed or e.reviewed) > stale_days if _gap_is_stale(
) e.effective_delegation,
if stale_n: e.effective_delegation.reviewed or e.reviewed,
console.print( stale_days,
f"[yellow]{stale_n} interim lane(s) past {stale_days}d review cadence.[/yellow]"
) )
]
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") @route_app.command("show")

View file

@ -69,9 +69,24 @@ _VALID_STATUS = ("active", "draft")
_VALID_LANES = ("secret", "login") _VALID_LANES = ("secret", "login")
_VALID_ROTATION_METHODS = ("rotate", "re-establish") _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 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: def days_since_review(reviewed: str, *, today: Optional[date] = None) -> int:
"""Calendar days between reviewed date (YYYY-MM-DD) and today.""" """Calendar days between reviewed date (YYYY-MM-DD) and today."""
@ -214,16 +229,25 @@ class Catalog:
def stale_gaps( def stale_gaps(
self, self,
include_draft: bool = False, include_draft: bool = False,
threshold_days: int = DEFAULT_STALE_DAYS, threshold_days: int = DEFAULT_BLOCKER_STALE_DAYS,
*, *,
today: Optional[date] = None, today: Optional[date] = None,
) -> List[RouteEntry]: ) -> 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] = [] out: List[RouteEntry] = []
for e in self.gaps(include_draft=include_draft): 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): if is_review_stale(reviewed, threshold_days=threshold_days, today=today):
out.append(e) out.append(e)
elif d.verified is not None and not d.is_verified:
out.append(e)
return out return out
def freshness( def freshness(
@ -261,14 +285,16 @@ class Catalog:
f"{stale_count} catalog entr{'y' if stale_count == 1 else 'ies'} " f"{stale_count} catalog entr{'y' if stale_count == 1 else 'ies'} "
f"past {stale_threshold_days}d review cadence" 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( 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: if stale_interim:
warnings.append( warnings.append(
f"{stale_interim} interim delegation" f"{stale_interim} interim delegation"
f"{'' if stale_interim == 1 else 's'} past " f"{'' if stale_interim == 1 else 's'} need re-verifying "
f"{stale_threshold_days}d review — see `warden route gaps`" f"({DEFAULT_BLOCKER_STALE_DAYS}d blocker cadence) — see `warden route gaps`"
) )
return CatalogFreshness( 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 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( return Delegation(
mode=mode, mode=mode,
intended_owner=intended_owner, intended_owner=intended_owner,
blocked_on=blocked_on, blocked_on=blocked_on,
reviewed=reviewed, reviewed=reviewed,
verified=verified,
implicit=False, implicit=False,
) )

View file

@ -64,14 +64,38 @@ class Delegation:
intended_owner: Optional[str] = None intended_owner: Optional[str] = None
blocked_on: Optional[str] = None blocked_on: Optional[str] = None
reviewed: Optional[str] = None reviewed: Optional[str] = None
verified: Optional[str] = None
implicit: bool = False 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: def to_dict(self) -> dict:
return { return {
"mode": self.mode, "mode": self.mode,
"intended_owner": self.intended_owner, "intended_owner": self.intended_owner,
"blocked_on": self.blocked_on, "blocked_on": self.blocked_on,
"reviewed": self.reviewed, "reviewed": self.reviewed,
"verified": self.verified,
"is_verified": self.is_verified,
"implicit": self.implicit, "implicit": self.implicit,
} }

View file

@ -779,3 +779,78 @@ def test_low_risk_vocabulary_is_explicit():
entry = _bare_entry(risk=grade) entry = _bare_entry(risk=grade)
assert entry.is_high_risk is False, grade assert entry.is_high_risk is False, grade
assert entry.is_graded is True, 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)

View file

@ -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 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 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 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) ### Stale check (operators and agents)

View file

@ -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 ops-warden answered a question well and never routed it, and another repo ended
up filing it. 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 ## Related
- `secrets-engine` `SECRETS-WP-0006` — catalog admission, decision `ae676382` - `secrets-engine` `SECRETS-WP-0006` — catalog admission, decision `ae676382`