From 93e95f93e7aeaf0cabbd16f81a78df41a4cade34 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 20 Aug 2026 07:13:44 +0200 Subject: [PATCH] feat(legacy-meter): scale retirement quiet period to call volume A clean review window only proves an interface was silent for that window. For a heavily used interface that is weak evidence -- an infrequent caller can fall outside the window entirely, so the interface reads as retirable while still having consumers. _retirement_state now also requires silence since last_seen_at, scaled by all-time volume (RETIREMENT_QUIET_LADDER): 7d under 100 calls, 30d under 10k, 60d above. Existing guards (retired / manual hold / replacement ref / replacement verified / in-window traffic) still take precedence. On the 2026-08-20 capture this makes 15 of 19 legacy interfaces retirable and holds 4: the three six-figure /workstreams/ read paths, and GET /tasks/?workstream_id which still has live traffic. Unblocks the bulk of Wave E2 in the cutover slice plan. 7 new tests. Co-Authored-By: Claude Opus 5 --- api/services/legacy_meter.py | 50 ++++++- docs/retirement-cutover-slice-plan.md | 46 ++++-- tests/test_legacy_meter.py | 134 ++++++++++++++++++ .../STATE-WP-0079-retirement-strangler.md | 18 ++- 4 files changed, 229 insertions(+), 19 deletions(-) diff --git a/api/services/legacy_meter.py b/api/services/legacy_meter.py index 5528443..de8f1bd 100644 --- a/api/services/legacy_meter.py +++ b/api/services/legacy_meter.py @@ -305,7 +305,13 @@ def _summarize_interface( ] window = _counters(window_buckets) last_seen = max((bucket.last_seen_at for bucket in buckets), default=None) - retirement_candidate, reason = _retirement_state(interface, window.calls) + retirement_candidate, reason = _retirement_state( + interface, + window.calls, + all_time_calls=all_time.calls, + last_seen_at=last_seen, + window_end=window_end, + ) return LegacyInterfaceSummary( interface=LegacyInterfaceRead.model_validate(interface), all_time=all_time, @@ -340,7 +346,34 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) return counts -def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]: +# Minimum quiet period before an interface may be retired, scaled to how much +# traffic it ever carried. A review window only proves the interface was silent +# for that window; for a high-volume interface that is weak evidence, since a +# monthly or quarterly caller can easily fall outside it. Thresholds are +# (all-time calls, minimum days since last_seen_at). +RETIREMENT_QUIET_LADDER: tuple[tuple[int, int], ...] = ( + (10_000, 60), + (100, 30), + (1, 7), +) + + +def _required_quiet_days(all_time_calls: int) -> int: + """Days of silence required before retirement, by all-time call volume.""" + for threshold, days in RETIREMENT_QUIET_LADDER: + if all_time_calls >= threshold: + return days + return 0 + + +def _retirement_state( + interface: LegacyInterface, + window_calls: int, + *, + all_time_calls: int = 0, + last_seen_at: datetime | None = None, + window_end: datetime | None = None, +) -> tuple[bool, str]: if interface.status == "retired": return False, "already retired" if interface.manual_hold: @@ -351,6 +384,19 @@ def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bo return False, "replacement not verified" if window_calls > 0: return False, f"{window_calls} call(s) in review window" + + required_days = _required_quiet_days(all_time_calls) + if required_days and last_seen_at is not None and window_end is not None: + quiet_days = (window_end - _ensure_datetime(last_seen_at)).days + if quiet_days < required_days: + return False, ( + f"quiet {quiet_days}d of {required_days}d required " + f"for {all_time_calls} all-time call(s)" + ) + return True, ( + f"no usage in review window; quiet {quiet_days}d " + f"(>= {required_days}d required for {all_time_calls} all-time call(s))" + ) return True, "no measured usage in review window" diff --git a/docs/retirement-cutover-slice-plan.md b/docs/retirement-cutover-slice-plan.md index dd7234f..7409b0f 100644 --- a/docs/retirement-cutover-slice-plan.md +++ b/docs/retirement-cutover-slice-plan.md @@ -213,21 +213,43 @@ retirement evidence. Corrected capture: `docs/evidence/legacy-meter-weekly-review-20260820.json` — 18 candidates of 20 interfaces, `GET /tasks/?workstream_id` correctly excluded. -**Still open — E2 remains gated.** A 7-day window is not sufficient for -high-volume interfaces, and two false positives survive it: +**Candidate rule tightened (2026-08-20).** `_retirement_state` now requires a +quiet period scaled to all-time call volume, because a clean window only proves +silence for that window — for a heavily used interface an infrequent caller can +fall outside it entirely. `RETIREMENT_QUIET_LADDER` in +`api/services/legacy_meter.py`: -- `GET /progress/?workstream_id` — last seen 2026-08-12T08:55Z, roughly 14 hours - before the window opened -- `GET /workstreams/` — last seen 2026-08-10, 9 days quiet, 144 086 all-time calls +| All-time calls | Days since `last_seen_at` required | +| --- | --- | +| 0 | none — never used | +| 1 – 99 | 7 | +| 100 – 9 999 | 30 | +| 10 000+ | 60 | -`last_seen_at` is unbounded by the window and is the stronger signal; the -candidate rule currently ignores it. Before E2 proceeds, the rule should require -a minimum quiet period scaled to call volume — an interface with six figures of -traffic needs materially more than seven silent days. That is a service-side -change in `api/services/legacy_meter.py` and belongs to T05, not here. +Applied to the 2026-08-20 capture this splits the 19 legacy interfaces cleanly: -Everything else on the list was last seen 2026-07-31 or earlier, most in early -July, so the bulk of E2 is well-evidenced once the rule is tightened. +**15 retirable** — all four `workstream` MCP tools, the `state://` resource, and +the low-volume REST aliases, plus `PATCH /workstreams/{id}` (571 calls, quiet +42d), `POST /workstreams/` (824, quiet 42d) and +`POST /workstreams/{id}/dependencies/` (4 971, quiet 49d). + +**4 held:** + +| Interface | All-time calls | Reason | +| --- | --- | --- | +| `GET /tasks/?workstream_id` | 595 | traffic inside the window | +| `GET /workstreams/` | 144 086 | quiet 9d of 60d | +| `GET /workstreams/{id}` | 511 406 | quiet 19d of 60d | +| `GET /workstreams/{id}/dependencies/` | 255 865 | quiet 42d of 60d | + +E2 can therefore proceed for the 15, which is the bulk of the slice. The four +held are the three six-figure read paths and the one with live traffic — exactly +the set where a false retirement would hurt most. They clear on their own as the +quiet counter runs, provided no new traffic arrives; the earliest, +`GET /workstreams/{id}/dependencies/`, needs 18 more silent days. + +Re-check before executing E2: the ladder is evaluated at review time, so a +capture older than the retirement decision is not evidence for it. ## Keep — 43 items diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index a895a96..8d2a90b 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -486,3 +486,137 @@ class TestWorkplanAliasesAndLegacyMeter: assert items["rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}"]["window"]["components"] == { "old-dep-archiver": 1 } + + +# --- retirement quiet-period ladder (STATE-WP-0079-T05) --------------------- +# +# A clean review window only proves silence for that window. For an interface +# that carried heavy traffic, that is weak evidence: an infrequent caller can +# fall outside the window entirely. The ladder requires silence proportional to +# all-time volume before an interface becomes a retirement candidate. + + +def _iface(**kw): + from types import SimpleNamespace + + defaults = dict( + status="legacy", + manual_hold=False, + hold_reason=None, + replacement_ref="replacement", + replacement_verified=True, + ) + defaults.update(kw) + return SimpleNamespace(**defaults) + + +def test_required_quiet_days_scales_with_volume(): + from api.services.legacy_meter import _required_quiet_days + + assert _required_quiet_days(0) == 0 + assert _required_quiet_days(1) == 7 + assert _required_quiet_days(99) == 7 + assert _required_quiet_days(100) == 30 + assert _required_quiet_days(9_999) == 30 + assert _required_quiet_days(10_000) == 60 + assert _required_quiet_days(511_406) == 60 + + +def test_high_volume_interface_held_until_quiet_period_elapses(): + """A six-figure interface quiet for 9 days is not yet retirable.""" + from api.services.legacy_meter import _retirement_state + + window_end = datetime(2026, 8, 20, tzinfo=timezone.utc) + candidate, reason = _retirement_state( + _iface(), + 0, + all_time_calls=144_086, + last_seen_at=datetime(2026, 8, 11, tzinfo=timezone.utc), + window_end=window_end, + ) + assert candidate is False + assert "9d of 60d required" in reason + + +def test_high_volume_interface_retirable_once_quiet_long_enough(): + from api.services.legacy_meter import _retirement_state + + window_end = datetime(2026, 8, 20, tzinfo=timezone.utc) + candidate, reason = _retirement_state( + _iface(), + 0, + all_time_calls=144_086, + last_seen_at=datetime(2026, 5, 1, tzinfo=timezone.utc), + window_end=window_end, + ) + assert candidate is True + assert "60d required" in reason + + +def test_low_volume_interface_uses_short_quiet_period(): + from api.services.legacy_meter import _retirement_state + + window_end = datetime(2026, 8, 20, tzinfo=timezone.utc) + candidate, _ = _retirement_state( + _iface(), + 0, + all_time_calls=2, + last_seen_at=datetime(2026, 8, 12, tzinfo=timezone.utc), + window_end=window_end, + ) + assert candidate is True + + +def test_never_used_interface_needs_no_quiet_period(): + from api.services.legacy_meter import _retirement_state + + candidate, reason = _retirement_state( + _iface(), + 0, + all_time_calls=0, + last_seen_at=None, + window_end=datetime(2026, 8, 20, tzinfo=timezone.utc), + ) + assert candidate is True + assert reason == "no measured usage in review window" + + +def test_in_window_traffic_still_wins_over_quiet_ladder(): + """Traffic inside the window blocks retirement regardless of volume.""" + from api.services.legacy_meter import _retirement_state + + candidate, reason = _retirement_state( + _iface(), + 3, + all_time_calls=5, + last_seen_at=datetime(2026, 8, 19, tzinfo=timezone.utc), + window_end=datetime(2026, 8, 20, tzinfo=timezone.utc), + ) + assert candidate is False + assert "3 call(s) in review window" in reason + + +def test_existing_guards_precede_quiet_ladder(): + from api.services.legacy_meter import _retirement_state + + window_end = datetime(2026, 8, 20, tzinfo=timezone.utc) + long_quiet = dict( + all_time_calls=1, + last_seen_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + window_end=window_end, + ) + assert _retirement_state(_iface(status="retired"), 0, **long_quiet) == ( + False, + "already retired", + ) + assert _retirement_state( + _iface(manual_hold=True, hold_reason="pinned"), 0, **long_quiet + ) == (False, "pinned") + assert _retirement_state(_iface(replacement_ref=" "), 0, **long_quiet) == ( + False, + "missing replacement reference", + ) + assert _retirement_state(_iface(replacement_verified=False), 0, **long_quiet) == ( + False, + "replacement not verified", + ) diff --git a/workplans/STATE-WP-0079-retirement-strangler.md b/workplans/STATE-WP-0079-retirement-strangler.md index b654f4f..8d9416c 100644 --- a/workplans/STATE-WP-0079-retirement-strangler.md +++ b/workplans/STATE-WP-0079-retirement-strangler.md @@ -141,11 +141,19 @@ live callers, including `GET /tasks/?workstream_id` (seen 2026-08-18). Script default corrected to `days=7`; corrected capture `docs/evidence/legacy-meter-weekly-review-20260820.json`. -**Remaining work for this task:** the candidate rule in -`api/services/legacy_meter.py` keys only on in-window calls and ignores -`last_seen_at`, so `GET /workstreams/` (144 086 all-time calls, quiet 9 days) -still reports as a candidate. Require a minimum quiet period scaled to call -volume before retiring. See `docs/retirement-cutover-slice-plan.md` § E2. +**Candidate rule tightened (2026-08-20):** `_retirement_state` now requires a +quiet period scaled to all-time call volume (`RETIREMENT_QUIET_LADDER`: 7d under +100 calls, 30d under 10 000, 60d above). On the 2026-08-20 capture this makes 15 +of 19 legacy interfaces retirable — all `workstream` MCP tools, the `state://` +resource, and the low-volume REST aliases — while holding the three six-figure +read paths (`GET /workstreams/`, `GET /workstreams/{id}`, +`GET /workstreams/{id}/dependencies/`) and `GET /tasks/?workstream_id`, which +still has live traffic. Covered by 7 new tests in `tests/test_legacy_meter.py`. + +**Remaining work for this task:** execute the retirement of the 15 evidenced +interfaces, then re-review the 4 held ones as their quiet counters run. The +ladder is evaluated at review time, so re-capture before deciding. See +`docs/retirement-cutover-slice-plan.md` § E2. ## Stabilization window and archive prep