Scale the blocker window by lane risk, converging with risk-nexus
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

risk-nexus accepted the offer to match their convention rather than grow a second
one, and published it: 14d critical/high, 30d medium, 60d low, nothing auto-closing
on staleness alone. Their preference — point warden route gaps at those windows and
the two registers agree without a shared mechanism — is better than a joint tool.

blocker_stale_days() now maps lane risk onto those windows. A flat 14 would have
been wrong in both directions: too aggressive for a low-risk pointer, and it treated
an admin PAT lane the same as one.

ungraded takes the shortest window, not the longest. ADR-0007 makes an absent grade
a defect and ADR-0008 makes a grade cover the whole path, so a lane nobody graded is
the one whose blocker deserves least trust. Encoding that as 60 days would have been
the fail-open default this repo already fixed once.

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

View file

@ -126,3 +126,4 @@
| task | WARDEN-WP-0033-T02 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | WARDEN-WP-0033-T02 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md |
| task | WARDEN-WP-0033-T03 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | WARDEN-WP-0033-T03 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md |
| task | WARDEN-WP-0033-T04 | wait | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | | task | WARDEN-WP-0033-T04 | wait | — | workplans/WARDEN-WP-0033-native-lane-handoff.md |
| task | WARDEN-WP-0033-T05 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md |

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-21T11:24:43Z" generated_at: "2026-08-21T11:28:33Z"
source: ops-warden/registry/routing/catalog.yaml source: ops-warden/registry/routing/catalog.yaml
catalog_revision: "675e04e8e67869f0e47a3ae55ab37b00112582f2" catalog_revision: "55f0f47a021375b8b25c924953d1b49a24e002c5"
catalog_revision_date: "2026-08-21T09:04:54+02:00" catalog_revision_date: "2026-08-21T13:26:10+02:00"
catalog_dirty: true catalog_dirty: false
high_risk_lane_count: 19 high_risk_lane_count: 19
concrete_path_count: 14 concrete_path_count: 14

View file

@ -710,7 +710,7 @@ def route_list(
bool, typer.Option("--stale", help="Show entries past review cadence (see --stale-days)") bool, typer.Option("--stale", help="Show entries past review cadence (see --stale-days)")
] = False, ] = False,
stale_days: Annotated[ stale_days: Annotated[
int, Optional[int],
typer.Option( typer.Option(
"--stale-days", "--stale-days",
help="Days since reviewed before an entry is stale (default 90)", help="Days since reviewed before an entry is stale (default 90)",
@ -771,10 +771,10 @@ def route_list(
) )
from warden.routing.catalog import DEFAULT_BLOCKER_STALE_DAYS from warden.routing.catalog import blocker_stale_days
def _gap_is_stale(delegation, reviewed: str, stale_days: int) -> bool: def _gap_is_stale(entry, delegation, reviewed: str, stale_days) -> bool:
"""An interim lane needs attention on either of two independent grounds. """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 Age is the obvious one. The other is that the review was never a
@ -784,7 +784,7 @@ def _gap_is_stale(delegation, reviewed: str, stale_days: int) -> bool:
""" """
from warden.routing.catalog import days_since_review from warden.routing.catalog import days_since_review
if days_since_review(reviewed) > stale_days: if days_since_review(reviewed) > blocker_stale_days(entry.risk, stale_days):
return True return True
return delegation.verified is not None and not delegation.is_verified return delegation.verified is not None and not delegation.is_verified
@ -797,11 +797,11 @@ def route_gaps(
int, int,
typer.Option( typer.Option(
"--stale-days", "--stale-days",
help="Days since a blocker was verified before an interim lane is stale " help="Override the risk-scaled blocker window (default: 14d high/ungraded, "
"(default 14 — see DEFAULT_BLOCKER_STALE_DAYS)", "30d standard, 60d low — matches risk-nexus stall windows)",
min=1, min=1,
), ),
] = DEFAULT_BLOCKER_STALE_DAYS, ] = None,
fail_on_stale: Annotated[ fail_on_stale: Annotated[
bool, bool,
typer.Option( typer.Option(
@ -834,7 +834,8 @@ def route_gaps(
"verified": d.verified, "verified": d.verified,
"is_verified": d.is_verified, "is_verified": d.is_verified,
"implicit": d.implicit, "implicit": d.implicit,
"stale": _gap_is_stale(d, reviewed, stale_days), "window_days": blocker_stale_days(e.risk, stale_days),
"stale": _gap_is_stale(e, d, reviewed, stale_days),
} }
) )
print(json.dumps(payload, indent=2)) print(json.dumps(payload, indent=2))
@ -858,7 +859,7 @@ def route_gaps(
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)
stale = _gap_is_stale(d, reviewed, stale_days) stale = _gap_is_stale(e, d, reviewed, stale_days)
reviewed_styled = f"[yellow]{reviewed}[/yellow]" if stale else reviewed reviewed_styled = f"[yellow]{reviewed}[/yellow]" if stale else reviewed
days_styled = f"[yellow]{days}[/yellow]" if stale else str(days) days_styled = f"[yellow]{days}[/yellow]" if stale else str(days)
verified_styled = ( verified_styled = (
@ -879,6 +880,7 @@ def route_gaps(
stale_entries = [ stale_entries = [
e for e in entries e for e in entries
if _gap_is_stale( if _gap_is_stale(
e,
e.effective_delegation, e.effective_delegation,
e.effective_delegation.reviewed or e.reviewed, e.effective_delegation.reviewed or e.reviewed,
stale_days, stale_days,
@ -890,12 +892,13 @@ def route_gaps(
# asked-and-waiting lane reads as reviewed. # asked-and-waiting lane reads as reviewed.
aged = [ aged = [
e for e in stale_entries e for e in stale_entries
if days_since_review(e.effective_delegation.reviewed or e.reviewed) > stale_days if days_since_review(e.effective_delegation.reviewed or e.reviewed)
> blocker_stale_days(e.risk, stale_days)
] ]
unverified = [e for e in stale_entries if e not in aged] unverified = [e for e in stale_entries if e not in aged]
if aged: if aged:
console.print( console.print(
f"[yellow]{len(aged)} interim lane(s) past the {stale_days}d blocker " f"[yellow]{len(aged)} interim lane(s) past their blocker "
f"cadence — re-check the blocker, do not just bump the date.[/yellow]" f"cadence — re-check the blocker, do not just bump the date.[/yellow]"
) )
if unverified: if unverified:

View file

@ -87,6 +87,29 @@ DEFAULT_STALE_DAYS = 90
# ~15 interim lanes, surfaces about one lane a day rather than a wall of them. # ~15 interim lanes, surfaces about one lane a day rather than a wall of them.
DEFAULT_BLOCKER_STALE_DAYS = 14 DEFAULT_BLOCKER_STALE_DAYS = 14
# Scaled by the lane's own risk grade, matching risk-nexus's stall windows
# (14d critical/high, 30d medium, 60d low — docs/method/check-procedure.md).
# They offered the convention rather than a joint tool: point `warden route gaps`
# at the same windows and the two registers agree without a shared mechanism.
#
# `ungraded` gets the shortest window, not the longest. ADR-0007 already decided
# an absent grade is a defect and ADR-0008 that a grade covers the whole path;
# a lane nobody has graded is exactly the one whose blocker is least trustworthy.
BLOCKER_STALE_DAYS_BY_RISK = {
"high": 14,
"ungraded": 14,
"standard": 30,
"accepted": 60,
"low": 60,
}
def blocker_stale_days(risk: Optional[str], override: Optional[int] = None) -> int:
"""Days a lane's blocker may go unverified, scaled by what the lane holds."""
if override is not None:
return override
return BLOCKER_STALE_DAYS_BY_RISK.get(risk or "ungraded", DEFAULT_BLOCKER_STALE_DAYS)
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."""
@ -229,12 +252,16 @@ class Catalog:
def stale_gaps( def stale_gaps(
self, self,
include_draft: bool = False, include_draft: bool = False,
threshold_days: int = DEFAULT_BLOCKER_STALE_DAYS, threshold_days: Optional[int] = None,
*, *,
today: Optional[date] = None, today: Optional[date] = None,
) -> List[RouteEntry]: ) -> List[RouteEntry]:
"""Interim lanes whose blocker is due a re-check. """Interim lanes whose blocker is due a re-check.
The window scales with the lane's risk grade unless `threshold_days`
overrides it -- a blocker on a lane holding an admin PAT should not go
unverified as long as one on a low-risk pointer.
A lane counts as stale when its review date is past the threshold **or** 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` 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 entry is the case that motivated this: it looks freshly reviewed on the
@ -244,7 +271,8 @@ class Catalog:
for e in self.gaps(include_draft=include_draft): for e in self.gaps(include_draft=include_draft):
d = e.effective_delegation d = e.effective_delegation
reviewed = d.reviewed or e.reviewed reviewed = d.reviewed or e.reviewed
if is_review_stale(reviewed, threshold_days=threshold_days, today=today): window = blocker_stale_days(e.risk, threshold_days)
if is_review_stale(reviewed, threshold_days=window, today=today):
out.append(e) out.append(e)
elif d.verified is not None and not d.is_verified: elif d.verified is not None and not d.is_verified:
out.append(e) out.append(e)
@ -287,14 +315,12 @@ class Catalog:
) )
# Interim blockers run on their own, much shorter cadence -- a stale # Interim blockers run on their own, much shorter cadence -- a stale
# pointer and an unanswered blocker are not the same kind of drift. # 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, 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'} need re-verifying " f"{'' if stale_interim == 1 else 's'} need re-verifying "
f"({DEFAULT_BLOCKER_STALE_DAYS}d blocker cadence) — see `warden route gaps`" f"(risk-scaled blocker cadence) — see `warden route gaps`"
) )
return CatalogFreshness( return CatalogFreshness(

View file

@ -799,6 +799,25 @@ def test_blocker_cadence_is_separate_from_pointer_cadence():
assert DEFAULT_BLOCKER_STALE_DAYS < DEFAULT_STALE_DAYS assert DEFAULT_BLOCKER_STALE_DAYS < DEFAULT_STALE_DAYS
def test_blocker_window_scales_with_risk_and_matches_risk_nexus():
"""risk-nexus stall windows: 14d critical/high, 30d medium, 60d low.
They offered the convention instead of a joint tool, so the two registers
agree only for as long as these numbers do.
"""
from warden.routing.catalog import blocker_stale_days
assert blocker_stale_days("high") == 14
assert blocker_stale_days("standard") == 30
assert blocker_stale_days("low") == 60
# An ungraded lane gets the SHORTEST window, not the longest -- ADR-0007 makes
# an absent grade a defect, so its blocker is the least trustworthy of all.
assert blocker_stale_days("ungraded") == 14
assert blocker_stale_days(None) == 14
# An explicit --stale-days still wins.
assert blocker_stale_days("low", 7) == 7
def test_asked_and_waiting_is_not_verification(): def test_asked_and_waiting_is_not_verification():
"""The failure this whole change exists to catch. """The failure this whole change exists to catch.

View file

@ -194,7 +194,21 @@ while they shared one 90-day threshold. They should not.
| Claim | Question | Default | Where | | Claim | Question | Default | Where |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Pointer freshness | Is this still the right owner and page? | **90 days** | `warden route list --stale` | | 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` | | Interim blocker | Has the intended owner answered / can they front this yet? | **risk-scaled, 1460 days** | `warden route gaps` |
The blocker window scales with what the lane holds, matching `risk-nexus`'s stall
windows (`docs/method/check-procedure.md`) so the two registers agree without a
shared tool:
| Lane `risk` | Window | risk-nexus equivalent |
| --- | --- | --- |
| `high`, `ungraded` | **14 days** | critical / high |
| `standard` | **30 days** | medium |
| `low`, `accepted` | **60 days** | low |
`ungraded` takes the *shortest* window, not the longest. `ADR-0007` makes an
absent grade a defect and `ADR-0008` makes a grade cover the whole path — a lane
nobody has graded is the one whose blocker deserves least trust.
A pointer genuinely is a quarterly question. A blocker is not: it is a claim 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. about another repo's state at a date, and this estate invalidates those in days.
@ -230,8 +244,8 @@ 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. is cheap to avoid — most of these are answerable by reading the owner's repo.
```bash ```bash
warden route gaps # 14-day blocker cadence, plus unverified lanes warden route gaps # risk-scaled cadence, plus unverified lanes
warden route gaps --stale-days 30 # looser threshold warden route gaps --stale-days 30 # flat override for every lane
warden route gaps --fail-on-stale # exit 3 — for a cron job or a gate warden route gaps --fail-on-stale # exit 3 — for a cron job or a gate
``` ```

View file

@ -222,6 +222,15 @@ fire before November.
`RISK-F-0001`, ~50 for `FLEX-WP-0007`. At ~15 lanes it surfaces about one a `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. day rather than a wall, and it fires on zero lanes on age today.
**Then scaled by risk, after risk-nexus answered.** They accepted the offer to
converge and published their convention: 14 days critical/high, 30 medium, 60
low, with fix state *read* from the owning repo rather than remembered. Their
framing — "point `warden route gaps` at those windows and the two will agree
without either of us building a shared mechanism" — is better than a joint
tool, so `blocker_stale_days()` now maps lane `risk` onto exactly those
windows. `ungraded` takes the *shortest*, not the longest: `ADR-0007` makes an
absent grade a defect, so its blocker is the least trustworthy of all.
2. **`verified:` distinguishes re-checking from re-editing.** `reviewed` records 2. **`verified:` distinguishes re-checking from re-editing.** `reviewed` records
when someone touched the entry; nothing recorded whether the claim was when someone touched the entry; nothing recorded whether the claim was
re-established. `owner-confirmed` and `source-read` count; re-established. `owner-confirmed` and `source-read` count;