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 <noreply@anthropic.com>
This commit is contained in:
parent
be7f2632c3
commit
93e95f93e7
4 changed files with 229 additions and 19 deletions
|
|
@ -305,7 +305,13 @@ def _summarize_interface(
|
||||||
]
|
]
|
||||||
window = _counters(window_buckets)
|
window = _counters(window_buckets)
|
||||||
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
|
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(
|
return LegacyInterfaceSummary(
|
||||||
interface=LegacyInterfaceRead.model_validate(interface),
|
interface=LegacyInterfaceRead.model_validate(interface),
|
||||||
all_time=all_time,
|
all_time=all_time,
|
||||||
|
|
@ -340,7 +346,34 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str)
|
||||||
return counts
|
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":
|
if interface.status == "retired":
|
||||||
return False, "already retired"
|
return False, "already retired"
|
||||||
if interface.manual_hold:
|
if interface.manual_hold:
|
||||||
|
|
@ -351,6 +384,19 @@ def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bo
|
||||||
return False, "replacement not verified"
|
return False, "replacement not verified"
|
||||||
if window_calls > 0:
|
if window_calls > 0:
|
||||||
return False, f"{window_calls} call(s) in review window"
|
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"
|
return True, "no measured usage in review window"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,21 +213,43 @@ retirement evidence. Corrected capture:
|
||||||
`docs/evidence/legacy-meter-weekly-review-20260820.json` — 18 candidates of 20
|
`docs/evidence/legacy-meter-weekly-review-20260820.json` — 18 candidates of 20
|
||||||
interfaces, `GET /tasks/?workstream_id` correctly excluded.
|
interfaces, `GET /tasks/?workstream_id` correctly excluded.
|
||||||
|
|
||||||
**Still open — E2 remains gated.** A 7-day window is not sufficient for
|
**Candidate rule tightened (2026-08-20).** `_retirement_state` now requires a
|
||||||
high-volume interfaces, and two false positives survive it:
|
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
|
| All-time calls | Days since `last_seen_at` required |
|
||||||
before the window opened
|
| --- | --- |
|
||||||
- `GET /workstreams/` — last seen 2026-08-10, 9 days quiet, 144 086 all-time calls
|
| 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
|
Applied to the 2026-08-20 capture this splits the 19 legacy interfaces cleanly:
|
||||||
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.
|
|
||||||
|
|
||||||
Everything else on the list was last seen 2026-07-31 or earlier, most in early
|
**15 retirable** — all four `workstream` MCP tools, the `state://` resource, and
|
||||||
July, so the bulk of E2 is well-evidenced once the rule is tightened.
|
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
|
## Keep — 43 items
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -486,3 +486,137 @@ class TestWorkplanAliasesAndLegacyMeter:
|
||||||
assert items["rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}"]["window"]["components"] == {
|
assert items["rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}"]["window"]["components"] == {
|
||||||
"old-dep-archiver": 1
|
"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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,19 @@ live callers, including `GET /tasks/?workstream_id` (seen 2026-08-18).
|
||||||
Script default corrected to `days=7`; corrected capture
|
Script default corrected to `days=7`; corrected capture
|
||||||
`docs/evidence/legacy-meter-weekly-review-20260820.json`.
|
`docs/evidence/legacy-meter-weekly-review-20260820.json`.
|
||||||
|
|
||||||
**Remaining work for this task:** the candidate rule in
|
**Candidate rule tightened (2026-08-20):** `_retirement_state` now requires a
|
||||||
`api/services/legacy_meter.py` keys only on in-window calls and ignores
|
quiet period scaled to all-time call volume (`RETIREMENT_QUIET_LADDER`: 7d under
|
||||||
`last_seen_at`, so `GET /workstreams/` (144 086 all-time calls, quiet 9 days)
|
100 calls, 30d under 10 000, 60d above). On the 2026-08-20 capture this makes 15
|
||||||
still reports as a candidate. Require a minimum quiet period scaled to call
|
of 19 legacy interfaces retirable — all `workstream` MCP tools, the `state://`
|
||||||
volume before retiring. See `docs/retirement-cutover-slice-plan.md` § E2.
|
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
|
## Stabilization window and archive prep
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue