state-hub/api/services/legacy_meter.py
tegwick 93e95f93e7
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 28s
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>
2026-08-20 07:13:44 +02:00

438 lines
14 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.schemas.legacy_meter import (
LegacyInterfaceRead,
LegacyInterfaceSummary,
LegacyUsageCounters,
LegacyUsageSummary,
LegacyWeeklyReview,
)
UNKNOWN_BUCKET = "unknown"
VALID_INTERFACE_KINDS = {
"rest_api",
"mcp_tool",
"procedure_call",
"event_subject",
"cli",
"dashboard_route",
"schema_field",
}
VALID_INTERFACE_STATUSES = {"legacy", "retirement_candidate", "retired"}
@dataclass(frozen=True)
class LegacyUsageIdentity:
tenant_key: str = UNKNOWN_BUCKET
user_key: str = UNKNOWN_BUCKET
component_key: str = UNKNOWN_BUCKET
def identity_from_request(request: Request | None) -> LegacyUsageIdentity:
if request is None:
return LegacyUsageIdentity()
headers = request.headers
return LegacyUsageIdentity(
tenant_key=_clean_bucket(headers.get("x-statehub-tenant") or headers.get("x-tenant-id")),
user_key=_clean_bucket(headers.get("x-statehub-user") or headers.get("x-user-id")),
component_key=_clean_bucket(headers.get("x-statehub-component") or headers.get("x-component")),
)
async def register_legacy_interface(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
owner_component: str = "state-hub",
replacement_verified: bool = False,
manual_hold: bool = False,
hold_reason: str | None = None,
notes: str | None = None,
status: str = "legacy",
commit: bool = True,
preserve_controls: bool = False,
) -> LegacyInterface:
interface_key = _required(interface_key, "interface_key")
replacement_ref = _required(replacement_ref, "replacement_ref")
interface_kind = _validate_kind(interface_kind)
status = _validate_status(status)
existing = await _get_by_key(session, interface_key)
if existing is None:
existing = LegacyInterface(
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=_clean_value(owner_component, "state-hub"),
replacement_verified=replacement_verified,
manual_hold=manual_hold,
hold_reason=hold_reason,
notes=notes,
status=status,
)
session.add(existing)
else:
existing.interface_kind = interface_kind
existing.replacement_ref = replacement_ref
existing.owner_component = _clean_value(owner_component, "state-hub")
existing.replacement_verified = existing.replacement_verified or replacement_verified
if not preserve_controls:
existing.manual_hold = manual_hold
existing.hold_reason = hold_reason
existing.notes = notes if notes is not None else existing.notes
existing.status = status
if commit:
await session.commit()
await session.refresh(existing)
else:
await session.flush()
return existing
async def patch_legacy_interface(
session: AsyncSession,
interface: LegacyInterface,
updates: dict[str, Any],
) -> LegacyInterface:
if "interface_kind" in updates:
updates["interface_kind"] = _validate_kind(updates["interface_kind"])
if "status" in updates and updates["status"] is not None:
updates["status"] = _validate_status(updates["status"])
if updates["status"] == "retired" and interface.retired_at is None:
interface.retired_at = datetime.now(tz=timezone.utc)
for field, value in updates.items():
if value is not None or field in {"manual_hold", "replacement_verified", "hold_reason", "notes"}:
setattr(interface, field, value)
await session.commit()
await session.refresh(interface)
return interface
async def record_legacy_usage(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
identity: LegacyUsageIdentity | None = None,
owner_component: str = "state-hub",
replacement_verified: bool = False,
observed_at: datetime | None = None,
call_count: int = 1,
commit: bool = True,
) -> LegacyInterface:
if call_count < 1:
raise ValueError("call_count must be >= 1")
observed_at = observed_at or datetime.now(tz=timezone.utc)
if observed_at.tzinfo is None:
observed_at = observed_at.replace(tzinfo=timezone.utc)
identity = identity or LegacyUsageIdentity()
interface = await register_legacy_interface(
session,
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=owner_component,
replacement_verified=replacement_verified,
preserve_controls=True,
commit=False,
)
for bucket_kind, bucket_key in (
("call", "total"),
("tenant", identity.tenant_key),
("user", identity.user_key),
("component", identity.component_key),
):
await _increment_bucket(
session,
interface,
period_start=observed_at.date(),
bucket_kind=bucket_kind,
bucket_key=_clean_bucket(bucket_key),
observed_at=observed_at,
call_count=call_count,
)
if commit:
await session.commit()
await session.refresh(interface)
else:
await session.flush()
return interface
async def get_legacy_interface_by_key(
session: AsyncSession,
interface_key: str,
) -> LegacyInterface | None:
return await _get_by_key(session, interface_key)
async def list_legacy_interfaces(session: AsyncSession) -> list[LegacyInterface]:
result = await session.execute(
select(LegacyInterface).order_by(LegacyInterface.interface_kind, LegacyInterface.interface_key)
)
return list(result.scalars().all())
async def legacy_usage_summary(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyUsageSummary:
window_end = _ensure_datetime(window_end) or datetime.now(tz=timezone.utc)
window_start = _ensure_datetime(window_start) or (window_end - timedelta(days=7))
interfaces = await list_legacy_interfaces(session)
buckets = await _usage_buckets(session)
by_interface: dict[Any, list[LegacyInterfaceUsageBucket]] = {}
for bucket in buckets:
by_interface.setdefault(bucket.legacy_interface_id, []).append(bucket)
summaries = [
_summarize_interface(interface, by_interface.get(interface.id, []), window_start, window_end)
for interface in interfaces
]
return LegacyUsageSummary(
generated_at=datetime.now(tz=timezone.utc),
window_start=window_start,
window_end=window_end,
interfaces=summaries,
)
async def legacy_weekly_review(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyWeeklyReview:
summary = await legacy_usage_summary(
session,
window_start=window_start,
window_end=window_end,
)
candidates = [item for item in summary.interfaces if item.retirement_candidate]
return LegacyWeeklyReview(
generated_at=summary.generated_at,
window_start=summary.window_start,
window_end=summary.window_end,
activity_core_handoff={
"activity_id": "statehub-legacy-interface-review",
"cadence": "weekly",
"source_endpoint": "/legacy-meter/weekly-review",
"state_owner": "state-hub",
"scheduler_owner": "activity-core",
},
interfaces=summary.interfaces,
retirement_candidates=candidates,
)
async def _increment_bucket(
session: AsyncSession,
interface: LegacyInterface,
*,
period_start: date,
bucket_kind: str,
bucket_key: str,
observed_at: datetime,
call_count: int,
) -> LegacyInterfaceUsageBucket:
result = await session.execute(
select(LegacyInterfaceUsageBucket).where(
LegacyInterfaceUsageBucket.legacy_interface_id == interface.id,
LegacyInterfaceUsageBucket.period_start == period_start,
LegacyInterfaceUsageBucket.bucket_kind == bucket_kind,
LegacyInterfaceUsageBucket.bucket_key == bucket_key,
)
)
bucket = result.scalar_one_or_none()
if bucket is None:
bucket = LegacyInterfaceUsageBucket(
legacy_interface_id=interface.id,
period_start=period_start,
bucket_kind=bucket_kind,
bucket_key=bucket_key,
call_count=call_count,
first_seen_at=observed_at,
last_seen_at=observed_at,
)
session.add(bucket)
return bucket
bucket.call_count += call_count
if observed_at < bucket.first_seen_at:
bucket.first_seen_at = observed_at
if observed_at > bucket.last_seen_at:
bucket.last_seen_at = observed_at
return bucket
async def _get_by_key(session: AsyncSession, interface_key: str) -> LegacyInterface | None:
result = await session.execute(
select(LegacyInterface).where(LegacyInterface.interface_key == interface_key)
)
return result.scalar_one_or_none()
async def _usage_buckets(session: AsyncSession) -> list[LegacyInterfaceUsageBucket]:
result = await session.execute(select(LegacyInterfaceUsageBucket))
return list(result.scalars().all())
def _summarize_interface(
interface: LegacyInterface,
buckets: list[LegacyInterfaceUsageBucket],
window_start: datetime,
window_end: datetime,
) -> LegacyInterfaceSummary:
all_time = _counters(buckets)
window_buckets = [
bucket for bucket in buckets
if bucket.last_seen_at >= window_start and bucket.first_seen_at < window_end
]
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,
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,
window=window,
last_seen_at=last_seen,
retirement_candidate=retirement_candidate,
retirement_reason=reason,
)
def _counters(buckets: list[LegacyInterfaceUsageBucket]) -> LegacyUsageCounters:
calls = sum(bucket.call_count for bucket in buckets if bucket.bucket_kind == "call")
tenants = _bucket_counts(buckets, "tenant")
users = _bucket_counts(buckets, "user")
components = _bucket_counts(buckets, "component")
return LegacyUsageCounters(
calls=calls,
tenant_count=len(tenants),
user_count=len(users),
component_count=len(components),
tenants=tenants,
users=users,
components=components,
)
def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) -> dict[str, int]:
counts: dict[str, int] = {}
for bucket in buckets:
if bucket.bucket_kind == bucket_kind:
counts[bucket.bucket_key] = counts.get(bucket.bucket_key, 0) + bucket.call_count
return counts
# 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:
return False, interface.hold_reason or "manual hold"
if not interface.replacement_ref.strip():
return False, "missing replacement reference"
if not interface.replacement_verified:
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"
def _ensure_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _required(value: str | None, field: str) -> str:
cleaned = _clean_value(value)
if not cleaned:
raise ValueError(f"{field} is required")
return cleaned
def _clean_value(value: str | None, default: str = "") -> str:
return str(value or default).strip()
def _clean_bucket(value: str | None) -> str:
cleaned = _clean_value(value)
return cleaned or UNKNOWN_BUCKET
def _validate_kind(kind: str) -> str:
cleaned = _required(kind, "interface_kind")
if cleaned not in VALID_INTERFACE_KINDS:
raise ValueError(f"interface_kind must be one of {sorted(VALID_INTERFACE_KINDS)}")
return cleaned
def _validate_status(status: str) -> str:
cleaned = _required(status, "status")
if cleaned not in VALID_INTERFACE_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_INTERFACE_STATUSES)}")
return cleaned