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

@ -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")