diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7f9e453..d07dfbb 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -126,3 +126,4 @@ | 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-T04 | wait | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | +| task | WARDEN-WP-0033-T05 | done | — | workplans/WARDEN-WP-0033-native-lane-handoff.md | diff --git a/registry/generated/high-risk-data-paths.yaml b/registry/generated/high-risk-data-paths.yaml index f69aece..d6f5808 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-21T11:24:43Z" +generated_at: "2026-08-21T11:28:33Z" source: ops-warden/registry/routing/catalog.yaml -catalog_revision: "675e04e8e67869f0e47a3ae55ab37b00112582f2" -catalog_revision_date: "2026-08-21T09:04:54+02:00" -catalog_dirty: true +catalog_revision: "55f0f47a021375b8b25c924953d1b49a24e002c5" +catalog_revision_date: "2026-08-21T13:26:10+02:00" +catalog_dirty: false high_risk_lane_count: 19 concrete_path_count: 14 diff --git a/src/warden/cli.py b/src/warden/cli.py index 1ce906e..8a2a5df 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -710,7 +710,7 @@ def route_list( bool, typer.Option("--stale", help="Show entries past review cadence (see --stale-days)") ] = False, stale_days: Annotated[ - int, + Optional[int], typer.Option( "--stale-days", 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. 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 - if days_since_review(reviewed) > stale_days: + if days_since_review(reviewed) > blocker_stale_days(entry.risk, stale_days): return True return delegation.verified is not None and not delegation.is_verified @@ -797,11 +797,11 @@ def route_gaps( int, typer.Option( "--stale-days", - help="Days since a blocker was verified before an interim lane is stale " - "(default 14 — see DEFAULT_BLOCKER_STALE_DAYS)", + help="Override the risk-scaled blocker window (default: 14d high/ungraded, " + "30d standard, 60d low — matches risk-nexus stall windows)", min=1, ), - ] = DEFAULT_BLOCKER_STALE_DAYS, + ] = None, fail_on_stale: Annotated[ bool, typer.Option( @@ -834,7 +834,8 @@ def route_gaps( "verified": d.verified, "is_verified": d.is_verified, "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)) @@ -858,7 +859,7 @@ def route_gaps( d = e.effective_delegation reviewed = d.reviewed or e.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 days_styled = f"[yellow]{days}[/yellow]" if stale else str(days) verified_styled = ( @@ -879,6 +880,7 @@ def route_gaps( stale_entries = [ e for e in entries if _gap_is_stale( + e, e.effective_delegation, e.effective_delegation.reviewed or e.reviewed, stale_days, @@ -890,12 +892,13 @@ def route_gaps( # 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 + 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] if aged: 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]" ) if unverified: diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index 1e463f5..76abd5f 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -87,6 +87,29 @@ DEFAULT_STALE_DAYS = 90 # ~15 interim lanes, surfaces about one lane a day rather than a wall of them. 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: """Calendar days between reviewed date (YYYY-MM-DD) and today.""" @@ -229,12 +252,16 @@ class Catalog: def stale_gaps( self, include_draft: bool = False, - threshold_days: int = DEFAULT_BLOCKER_STALE_DAYS, + threshold_days: Optional[int] = None, *, today: Optional[date] = None, ) -> List[RouteEntry]: """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** 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 @@ -244,7 +271,8 @@ class Catalog: for e in self.gaps(include_draft=include_draft): d = e.effective_delegation 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) elif d.verified is not None and not d.is_verified: out.append(e) @@ -287,14 +315,12 @@ class Catalog: ) # 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=DEFAULT_BLOCKER_STALE_DAYS, today=today - )) + stale_interim = len(self.stale_gaps(include_draft=True, today=today)) if stale_interim: warnings.append( f"{stale_interim} interim delegation" 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( diff --git a/tests/test_routing.py b/tests/test_routing.py index 3b1ddd6..aedfa01 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -799,6 +799,25 @@ def test_blocker_cadence_is_separate_from_pointer_cadence(): 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(): """The failure this whole change exists to catch. diff --git a/wiki/AccessRouting.md b/wiki/AccessRouting.md index c46b0b1..682b211 100644 --- a/wiki/AccessRouting.md +++ b/wiki/AccessRouting.md @@ -194,7 +194,21 @@ 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` | +| Interim blocker | Has the intended owner answered / can they front this yet? | **risk-scaled, 14–60 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 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. ```bash -warden route gaps # 14-day blocker cadence, plus unverified lanes -warden route gaps --stale-days 30 # looser threshold +warden route gaps # risk-scaled cadence, plus unverified lanes +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 ``` diff --git a/workplans/WARDEN-WP-0033-native-lane-handoff.md b/workplans/WARDEN-WP-0033-native-lane-handoff.md index ca16250..a8d56eb 100644 --- a/workplans/WARDEN-WP-0033-native-lane-handoff.md +++ b/workplans/WARDEN-WP-0033-native-lane-handoff.md @@ -222,6 +222,15 @@ fire before November. `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. + **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 when someone touched the entry; nothing recorded whether the claim was re-established. `owner-confirmed` and `source-read` count;