diff --git a/api/routers/progress.py b/api/routers/progress.py index 66956df..f959d50 100644 --- a/api/routers/progress.py +++ b/api/routers/progress.py @@ -1,14 +1,16 @@ +from typing import Any + from fastapi import Request, Response from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.progress_event import ProgressEvent from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead -from api.services.legacy_compat import meter_legacy_query_param +from api.services.legacy_compat import meter_legacy_body_field, meter_legacy_query_param from hub_core.routers.progress import create_progress_router -async def _meter_progress_workstream_id( +async def _meter_progress_workstream_id_query( session: AsyncSession, request: Request, response: Response, @@ -23,12 +25,34 @@ async def _meter_progress_workstream_id( ) +async def _meter_progress_workstream_id_body( + session: AsyncSession, + request: Request, + response: Response, +) -> None: + await meter_legacy_body_field( + session=session, + request=request, + response=response, + method="POST", + route="/progress/", + field="workstream_id", + replacement_ref="POST /progress/ with workplan_id", + ) + + +def _progress_body_uses_legacy_workstream_id(body: Any) -> bool: + return bool(getattr(body, "used_legacy_workstream_id", False)) + + router = create_progress_router( get_session, progress_model=ProgressEvent, progress_create_schema=ProgressEventCreate, progress_read_schema=ProgressEventRead, - meter_legacy_workstream_id=_meter_progress_workstream_id, + meter_legacy_workstream_id=_meter_progress_workstream_id_query, + progress_body_uses_legacy_workstream_id=_progress_body_uses_legacy_workstream_id, + meter_legacy_workstream_id_body=_meter_progress_workstream_id_body, ) __all__ = ["router"] \ No newline at end of file diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 8737648..443dbf8 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -23,6 +23,7 @@ from api.schemas.workplan import ( WorkplanUpdate, ) from api.services.lifecycle import transition_workplan_status +from api.services.legacy_compat import mark_legacy_response from api.services.legacy_meter import ( LegacyUsageIdentity, identity_from_request, @@ -86,14 +87,6 @@ def _legacy_key(method: str, route: str) -> str: return f"rest_api:{method} {route}" -def _mark_legacy_response(response: Response | None, replacement_ref: str) -> None: - if response is None: - return - response.headers["Deprecation"] = "true" - response.headers["X-StateHub-Replacement"] = replacement_ref - response.headers.append("Link", f"<{replacement_ref}>; rel=\"successor-version\"") - - async def _meter_legacy_route( *, session: AsyncSession, @@ -102,7 +95,7 @@ async def _meter_legacy_route( interface_key: str, replacement_ref: str, ) -> None: - _mark_legacy_response(response, replacement_ref) + mark_legacy_response(response, replacement_ref) try: await record_legacy_usage( session, diff --git a/api/schemas/progress_event.py b/api/schemas/progress_event.py index 3789aff..8086c5d 100644 --- a/api/schemas/progress_event.py +++ b/api/schemas/progress_event.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from typing import Any -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from api.schemas.compat import OptionalWorkplanIdCompatMixin @@ -13,6 +13,7 @@ class ProgressEventCreate(BaseModel): default=None, validation_alias=AliasChoices("workplan_id", "workstream_id"), ) + used_legacy_workstream_id: bool = Field(default=False, exclude=True) task_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None event_type: str @@ -21,6 +22,14 @@ class ProgressEventCreate(BaseModel): author: str | None = None session_id: str | None = None + @model_validator(mode="before") + @classmethod + def _detect_legacy_workstream_id(cls, data: Any) -> Any: + if isinstance(data, dict): + if data.get("workstream_id") is not None and data.get("workplan_id") is None: + return {**data, "used_legacy_workstream_id": True} + return data + class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/api/services/legacy_compat.py b/api/services/legacy_compat.py index e7e3a06..66436c1 100644 --- a/api/services/legacy_compat.py +++ b/api/services/legacy_compat.py @@ -11,12 +11,16 @@ from api.services.legacy_meter import identity_from_request, record_legacy_usage logger = logging.getLogger(__name__) _LEGACY_OWNER = "state-hub.api" +# Planning horizon for legacy workstream interfaces; actual removal remains +# legacy-meter-gated per docs/workplan-terminology-transition.md. +_LEGACY_WORKSTREAM_SUNSET = "Wed, 30 Jun 2027 23:59:59 GMT" def mark_legacy_response(response: Response | None, replacement_ref: str) -> None: if response is None: return response.headers["Deprecation"] = "true" + response.headers["Sunset"] = _LEGACY_WORKSTREAM_SUNSET response.headers["X-StateHub-Replacement"] = replacement_ref response.headers.append("Link", f'<{replacement_ref}>; rel="successor-version"') @@ -37,6 +41,37 @@ async def meter_legacy_query_param( ) -> None: interface_key = legacy_query_param_key(method, route, param) mark_legacy_response(response, replacement_ref) + try: + await record_legacy_usage( + session, + interface_key=interface_key, + interface_kind="rest_api", + replacement_ref=replacement_ref, + owner_component=_LEGACY_OWNER, + replacement_verified=True, + identity=identity_from_request(request), + ) + except Exception: + await session.rollback() + logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True) + + +def legacy_body_field_key(method: str, route: str, field: str = "workstream_id") -> str: + return f"rest_api:{method} {route} {field}" + + +async def meter_legacy_body_field( + *, + session: AsyncSession, + request: Request | None, + response: Response | None, + method: str, + route: str, + replacement_ref: str, + field: str = "workstream_id", +) -> None: + interface_key = legacy_body_field_key(method, route, field) + mark_legacy_response(response, replacement_ref) try: await record_legacy_usage( session, diff --git a/docs/workplan-terminology-legacy-retirement-backlog.md b/docs/workplan-terminology-legacy-retirement-backlog.md index 3c566cc..13074d7 100644 --- a/docs/workplan-terminology-legacy-retirement-backlog.md +++ b/docs/workplan-terminology-legacy-retirement-backlog.md @@ -75,6 +75,7 @@ These accept `workstream_id` alongside `workplan_id` on preferred routes: | `rest_api:GET /token-events/?workstream_id` | `GET /token-events/` | `GET /token-events/?workplan_id=` | | `rest_api:GET /execution/launch-requests?workstream_id` | `GET /execution/launch-requests` | `GET /execution/launch-requests?workplan_id=` | | `rest_api:GET /progress/?workstream_id` | `GET /progress/` | `GET /progress/?workplan_id=` | +| `rest_api:POST /progress/ workstream_id` | `POST /progress/` | `POST /progress/` with `workplan_id` body field | Retire param aliases in T04 after zero-usage windows; callers surface via `X-StateHub-Component` in weekly review. diff --git a/docs/workplan-terminology-transition.md b/docs/workplan-terminology-transition.md index d4b646b..2d72337 100644 --- a/docs/workplan-terminology-transition.md +++ b/docs/workplan-terminology-transition.md @@ -39,6 +39,7 @@ Physical database renames are intentionally out of scope for this workplan. Legacy REST responses include: - `Deprecation: true` +- `Sunset: Wed, 30 Jun 2027 23:59:59 GMT` (planning horizon; removal remains legacy-meter-gated) - `X-StateHub-Replacement: ` - `Link: <>; rel="successor-version"` diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index 7e74dac..2108c3f 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -77,6 +77,7 @@ class TestWorkplanAliasesAndLegacyMeter: ) assert r.status_code == 200 assert r.headers["Deprecation"] == "true" + assert r.headers.get("Sunset") assert r.headers["X-StateHub-Replacement"] == "/workplans/" summary = (await client.get("/legacy-meter/summary")).json() @@ -189,6 +190,50 @@ class TestWorkplanAliasesAndLegacyMeter: assert item["window"]["calls"] == 1 assert item["window"]["components"] == {"old-progress-client": 1} + async def test_legacy_workstream_id_body_on_progress_post_is_metered(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.post( + "/progress/", + json={ + "workstream_id": str(wp["id"]), + "event_type": "note", + "summary": "legacy body field", + "author": "test", + }, + headers={"X-StateHub-Component": "old-progress-writer"}, + ) + assert r.status_code == 201 + assert r.headers["Deprecation"] == "true" + assert r.headers["X-StateHub-Replacement"] == "POST /progress/ with workplan_id" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:POST /progress/ workstream_id"] + assert item["window"]["calls"] == 1 + assert item["window"]["components"] == {"old-progress-writer": 1} + + async def test_workplan_id_body_on_progress_post_is_not_metered(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.post( + "/progress/", + json={ + "workplan_id": str(wp["id"]), + "event_type": "note", + "summary": "preferred body field", + "author": "test", + }, + ) + assert r.status_code == 201 + assert r.headers.get("Deprecation") != "true" + + summary = (await client.get("/legacy-meter/summary")).json() + assert _summary_by_key(summary).get("rest_api:POST /progress/ workstream_id") is None + async def test_workplan_id_query_param_on_tasks_is_not_metered(self, client): await _create_domain(client) topic = await _create_topic(client) diff --git a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md index 0d03e8b..da5e3d9 100644 --- a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md +++ b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md @@ -10,7 +10,7 @@ topic_slug: custodian planning_priority: medium planning_order: 69 created: "2026-07-08" -updated: "2026-07-12" +updated: "2026-07-13" state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c" --- @@ -185,6 +185,10 @@ Progress 2026-07-10 (T04 prep): `api/services/legacy_compat.py` meters headers and legacy-meter keys. MCP task list tools now call REST with `workplan_id` only. Route-level `/workstreams` removal remains gated on zero usage. +Progress 2026-07-13 (T04): legacy responses now include `Sunset` (Jun 2027 planning +horizon). POST `/progress/` meters `workstream_id` request bodies; hub-core progress +router accepts optional body-meter hook. + ## Task: Legacy completion event — stop dual-publish ```task