diff --git a/api/routers/execution.py b/api/routers/execution.py index a2d4c22..9f8adbc 100644 --- a/api/routers/execution.py +++ b/api/routers/execution.py @@ -27,7 +27,8 @@ from api.services.execution_queue import ( queue_sort_key, workplan_blockers, ) -from api.routers.workstreams import _legacy_key, _meter_legacy_route +from api.routers.workstreams import _legacy_key +from api.services.legacy_compat import retire_legacy_route from api.services.legacy_compat import meter_legacy_query_param from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status @@ -62,22 +63,23 @@ async def _update_execution_intent( return _intent_read(ws) -@router.patch("/workstreams/{workstream_id}/intent", response_model=ExecutionIntentRead) +@router.patch("/workstreams/{workstream_id}/intent", status_code=status.HTTP_410_GONE) async def update_execution_intent( request: Request, response: Response, workstream_id: uuid.UUID, body: ExecutionIntentUpdate, session: AsyncSession = Depends(get_session), -) -> ExecutionIntentRead: - await _meter_legacy_route( +) -> None: + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"), replacement_ref="/execution/workplans/{workplan_id}/intent", + detail="Legacy PATCH /execution/workstreams/{workstream_id}/intent retired; " + "use PATCH /execution/workplans/{workplan_id}/intent", ) - return await _update_execution_intent(workstream_id=workstream_id, body=body, session=session) @router.patch("/workplans/{workplan_id}/intent", response_model=ExecutionIntentRead) diff --git a/api/routers/workstream_dependencies.py b/api/routers/workstream_dependencies.py index d0a8d0d..bf3ba34 100644 --- a/api/routers/workstream_dependencies.py +++ b/api/routers/workstream_dependencies.py @@ -9,7 +9,8 @@ from api.models.task import Task from api.models.workplan import Workplan from api.models.workplan_dependency import WorkplanDependency from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead -from api.routers.workstreams import _legacy_key, _meter_legacy_route +from api.routers.workstreams import _legacy_key +from api.services.legacy_compat import retire_legacy_route router = APIRouter(prefix="/workstreams", tags=["dependencies"]) workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"]) @@ -82,8 +83,7 @@ async def _delete_dependency( @router.post( "/{workstream_id}/dependencies/", - response_model=WorkplanDependencyRead, - status_code=status.HTTP_201_CREATED, + status_code=status.HTTP_410_GONE, ) async def create_dependency( request: Request, @@ -91,16 +91,17 @@ async def create_dependency( workstream_id: uuid.UUID, body: WorkplanDependencyCreate, session: AsyncSession = Depends(get_session), -) -> WorkplanDependency: - """Record that workstream_id depends on another workplan or a task.""" - await _meter_legacy_route( +) -> None: + """Legacy alias retired — use POST /workplans/{workplan_id}/dependencies/.""" + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"), replacement_ref="/workplans/{workplan_id}/dependencies/", + detail="Legacy POST /workstreams/{workstream_id}/dependencies/ retired; " + "use POST /workplans/{workplan_id}/dependencies/", ) - return await _create_dependency(workplan_id=workstream_id, body=body, session=session) @workplan_router.post( @@ -118,23 +119,24 @@ async def create_workplan_dependency( @router.get( "/{workstream_id}/dependencies/", - response_model=list[WorkplanDependencyRead], + status_code=status.HTTP_410_GONE, ) async def list_dependencies( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> list[WorkplanDependency]: - """Return all dependency edges touching this workplan (both directions).""" - await _meter_legacy_route( +) -> None: + """Legacy alias retired — use GET /workplans/{workplan_id}/dependencies/.""" + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"), replacement_ref="/workplans/{workplan_id}/dependencies/", + detail="Legacy GET /workstreams/{workstream_id}/dependencies/ retired; " + "use GET /workplans/{workplan_id}/dependencies/", ) - return await _list_dependencies(workplan_id=workstream_id, session=session) @workplan_router.get( @@ -150,7 +152,7 @@ async def list_workplan_dependencies( @router.delete( "/{workstream_id}/dependencies/{dep_id}", - status_code=status.HTTP_204_NO_CONTENT, + status_code=status.HTTP_410_GONE, ) async def delete_dependency( request: Request, @@ -159,15 +161,16 @@ async def delete_dependency( dep_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> None: - """Hard-delete a dependency edge. Removing a constraint is safe — no information is lost.""" - await _meter_legacy_route( + """Legacy alias retired — use DELETE /workplans/{workplan_id}/dependencies/{dep_id}.""" + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"), replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}", + detail="Legacy DELETE /workstreams/{workstream_id}/dependencies/{dep_id} retired; " + "use DELETE /workplans/{workplan_id}/dependencies/{dep_id}", ) - await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session) @workplan_router.delete( diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 7305d6c..5ee52c4 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -23,8 +23,7 @@ from api.schemas.workplan import ( WorkplanUpdate, ) from api.services.lifecycle import transition_workplan_status -from api.services.legacy_compat import legacy_response_headers, mark_legacy_response -from api.services.legacy_meter import identity_from_request, record_legacy_usage +from api.services.legacy_compat import retire_legacy_route from api.workplan_status import ( is_supported_workplan_status, normalize_workplan_status, @@ -42,7 +41,6 @@ _INDEX_TTL = 30.0 _INDEX_REFRESH_TASK: asyncio.Task | None = None _INDEX_LAST_ERROR: str | None = None -_LEGACY_OWNER = "state-hub.api" _COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" @@ -82,30 +80,6 @@ def _legacy_key(method: str, route: str) -> str: return f"rest_api:{method} {route}" -async def _meter_legacy_route( - *, - session: AsyncSession, - request: Request | None, - response: Response | None, - interface_key: str, - replacement_ref: str, -) -> None: - 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) - - async def _list_workplans( *, topic_id: uuid.UUID | None, @@ -361,7 +335,7 @@ async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> Non asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope)) -@router.get("/", response_model=list[WorkplanRead]) +@router.get("/", status_code=status.HTTP_410_GONE) async def list_workstreams( request: Request, response: Response, @@ -372,22 +346,14 @@ async def list_workstreams( owner: str | None = None, slug: str | None = None, session: AsyncSession = Depends(get_session), -) -> list[Workplan]: - await _meter_legacy_route( +) -> None: + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/"), replacement_ref="/workplans/", - ) - return await _list_workplans( - topic_id=topic_id, - repo_id=repo_id, - repo_goal_id=repo_goal_id, - status_filter=status, - owner=owner, - slug=slug, - session=session, + detail="Legacy GET /workstreams/ retired; use GET /workplans/", ) @@ -419,18 +385,13 @@ async def workplan_index( refresh: bool = Query(False, description="Force cache invalidation"), session: AsyncSession = Depends(get_session), ) -> None: - replacement_ref = "/workplans/index" - await _meter_legacy_route( + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/workplan-index"), - replacement_ref=replacement_ref, - ) - raise HTTPException( - status_code=status.HTTP_410_GONE, + replacement_ref="/workplans/index", detail="Legacy GET /workstreams/workplan-index retired; use GET /workplans/index", - headers=legacy_response_headers(replacement_ref), ) @@ -464,21 +425,21 @@ async def sync_workplan_bindings( return {"updated": updated, "received": len(body.bindings)} -@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) +@router.post("/", status_code=status.HTTP_410_GONE) async def create_workstream( request: Request, response: Response, body: WorkplanCreate, session: AsyncSession = Depends(get_session), -) -> Workplan: - await _meter_legacy_route( +) -> None: + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("POST", "/workstreams/"), replacement_ref="/workplans/", + detail="Legacy POST /workstreams/ retired; use POST /workplans/", ) - return await _create_workplan(body=body, session=session) @workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED) @@ -489,21 +450,21 @@ async def create_workplan( return await _create_workplan(body=body, session=session) -@router.get("/{workstream_id}", response_model=WorkplanRead) +@router.get("/{workstream_id}", status_code=status.HTTP_410_GONE) async def get_workstream( request: Request, response: Response, workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), -) -> Workplan: - await _meter_legacy_route( +) -> None: + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", + detail="Legacy GET /workstreams/{workstream_id} retired; use GET /workplans/{workplan_id}", ) - return await _get_workplan(workplan_id=workstream_id, session=session) @workplan_router.get("/{workplan_id}", response_model=WorkplanRead) @@ -514,22 +475,22 @@ async def get_workplan( return await _get_workplan(workplan_id=workplan_id, session=session) -@router.patch("/{workstream_id}", response_model=WorkplanRead) +@router.patch("/{workstream_id}", status_code=status.HTTP_410_GONE) async def update_workstream( request: Request, response: Response, workstream_id: uuid.UUID, body: WorkplanUpdate, session: AsyncSession = Depends(get_session), -) -> Workplan: - await _meter_legacy_route( +) -> None: + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), replacement_ref="/workplans/{workplan_id}", + detail="Legacy PATCH /workstreams/{workstream_id} retired; use PATCH /workplans/{workplan_id}", ) - return await _update_workplan(workplan_id=workstream_id, body=body, session=session) @workplan_router.patch("/{workplan_id}", response_model=WorkplanRead) @@ -548,18 +509,13 @@ async def archive_workstream( workstream_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> None: - replacement_ref = "DELETE /workplans/{workplan_id}" - await _meter_legacy_route( + await retire_legacy_route( session=session, request=request, response=response, interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), - replacement_ref=replacement_ref, - ) - raise HTTPException( - status_code=status.HTTP_410_GONE, + replacement_ref="DELETE /workplans/{workplan_id}", detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", - headers=legacy_response_headers(replacement_ref), ) diff --git a/api/services/legacy_compat.py b/api/services/legacy_compat.py index 2423347..11a4d62 100644 --- a/api/services/legacy_compat.py +++ b/api/services/legacy_compat.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from fastapi import Request, Response +from fastapi import HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession from api.services.legacy_meter import identity_from_request, record_legacy_usage @@ -97,6 +97,37 @@ async def meter_legacy_body_field( logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True) +async def retire_legacy_route( + *, + session: AsyncSession, + request: Request | None, + response: Response | None, + interface_key: str, + replacement_ref: str, + detail: str, +) -> None: + """Record final legacy-meter usage for this route, then return 410 Gone.""" + 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) + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail=detail, + headers=legacy_response_headers(replacement_ref), + ) + + async def meter_legacy_body_from_model( body: object, *, diff --git a/docs/workplan-terminology-legacy-retirement-backlog.md b/docs/workplan-terminology-legacy-retirement-backlog.md index dedbad8..f427c97 100644 --- a/docs/workplan-terminology-legacy-retirement-backlog.md +++ b/docs/workplan-terminology-legacy-retirement-backlog.md @@ -42,16 +42,16 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers. | Phase | Legacy-meter key | Replacement | Risk | Owner task | | ---: | --- | --- | --- | --- | -| 1 | `rest_api:GET /workstreams/` | `GET /workplans/` | REST | T04 | -| 1 | `rest_api:POST /workstreams/` | `POST /workplans/` | REST | T04 | -| 1 | `rest_api:GET /workstreams/{workstream_id}` | `GET /workplans/{workplan_id}` | REST | T04 | -| 1 | `rest_api:PATCH /workstreams/{workstream_id}` | `PATCH /workplans/{workplan_id}` | REST | T04 | +| 1 | ~~`rest_api:GET /workstreams/`~~ **retired** (410) | `GET /workplans/` | REST | T04 ✓ | +| 1 | ~~`rest_api:POST /workstreams/`~~ **retired** (410) | `POST /workplans/` | REST | T04 ✓ | +| 1 | ~~`rest_api:GET /workstreams/{workstream_id}`~~ **retired** (410) | `GET /workplans/{workplan_id}` | REST | T04 ✓ | +| 1 | ~~`rest_api:PATCH /workstreams/{workstream_id}`~~ **retired** (410) | `PATCH /workplans/{workplan_id}` | REST | T04 ✓ | | 1 | ~~`rest_api:DELETE /workstreams/{workstream_id}`~~ **retired** (410) | `DELETE /workplans/{workplan_id}` | REST | T04 ✓ | | 1 | ~~`rest_api:GET /workstreams/workplan-index`~~ **retired** (410) | `GET /workplans/index` | REST | T04 ✓ | -| 2 | `rest_api:GET /workstreams/{workstream_id}/dependencies/` | `GET /workplans/{workplan_id}/dependencies/` | REST | T04 | -| 2 | `rest_api:POST /workstreams/{workstream_id}/dependencies/` | `POST /workplans/{workplan_id}/dependencies/` | REST | T04 | -| 2 | `rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}` | `DELETE /workplans/{workplan_id}/dependencies/{dep_id}` | REST | T04 | -| 2 | `rest_api:PATCH /execution/workstreams/{workstream_id}/intent` | `PATCH /execution/workplans/{workplan_id}/intent` | REST | T04 | +| 2 | ~~`rest_api:GET /workstreams/{workstream_id}/dependencies/`~~ **retired** (410) | `GET /workplans/{workplan_id}/dependencies/` | REST | T04 ✓ | +| 2 | ~~`rest_api:POST /workstreams/{workstream_id}/dependencies/`~~ **retired** (410) | `POST /workplans/{workplan_id}/dependencies/` | REST | T04 ✓ | +| 2 | ~~`rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}`~~ **retired** (410) | `DELETE /workplans/{workplan_id}/dependencies/{dep_id}` | REST | T04 ✓ | +| 2 | ~~`rest_api:PATCH /execution/workstreams/{workstream_id}/intent`~~ **retired** (410) | `PATCH /execution/workplans/{workplan_id}/intent` | REST | T04 ✓ | | 3 | `mcp:create_workstream` | `create_workplan` | MCP | T03 | | 3 | `mcp:update_workstream` | `update_workplan` | MCP | T03 | | 3 | `mcp:update_workstream_status` | `update_workplan_status` | MCP | T03 | diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index a7d9357..6a8cccd 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -1280,7 +1280,7 @@ class TestC06WorkstreamCreation: report = fix_repo("http://unused", "demo-repo") assert created_workstreams[0]["slug"] == "demo-repo-state-wp-0001" - assert created_tasks[0]["workstream_id"] == "new-ws" + assert created_tasks[0]["workplan_id"] == "new-ws" patched = wp.read_text(encoding="utf-8") assert 'state_hub_workstream_id: "new-ws"' in patched assert 'state_hub_task_id: "new-task"' in patched diff --git a/tests/test_legacy_meter.py b/tests/test_legacy_meter.py index ba313e3..a895a96 100644 --- a/tests/test_legacy_meter.py +++ b/tests/test_legacy_meter.py @@ -67,7 +67,7 @@ class TestWorkplanAliasesAndLegacyMeter: assert r.status_code == 200 assert r.json()["interfaces"] == [] - async def test_legacy_workstream_route_is_metered_with_identity_buckets(self, client): + async def test_legacy_get_workstreams_returns_410_and_meters(self, client): await _create_domain(client) topic = await _create_topic(client) await _create_workplan(client, topic["id"]) @@ -80,7 +80,8 @@ class TestWorkplanAliasesAndLegacyMeter: "X-StateHub-Component": "old-client", }, ) - assert r.status_code == 200 + assert r.status_code == 410 + assert "retired" in r.json()["detail"] assert r.headers["Deprecation"] == "true" assert r.headers.get("Sunset") assert r.headers["X-StateHub-Replacement"] == "/workplans/" @@ -91,7 +92,6 @@ class TestWorkplanAliasesAndLegacyMeter: assert item["window"]["tenants"] == {"tenant-a": 1} assert item["window"]["users"] == {"alice": 1} assert item["window"]["components"] == {"old-client": 1} - assert item["retirement_candidate"] is False async def test_weekly_review_accepts_hours_window(self, client): r = await client.post("/legacy-meter/interfaces", json={ @@ -164,7 +164,7 @@ class TestWorkplanAliasesAndLegacyMeter: assert item["retirement_candidate"] is False assert item["retirement_reason"] == "1 call(s) in review window" - async def test_workplan_finish_does_not_meter_legacy_completion_event(self, client): + async def test_legacy_patch_workstream_returns_410_and_meters(self, client): await _create_domain(client) topic = await _create_topic(client) wp = await _create_workplan(client, topic["id"]) @@ -174,15 +174,14 @@ class TestWorkplanAliasesAndLegacyMeter: json={"status": "finished"}, headers={"X-StateHub-Component": "old-client"}, ) - assert r.status_code == 200 - assert r.json()["status"] == "finished" + assert r.status_code == 410 + assert "retired" in r.json()["detail"] summary = (await client.get("/legacy-meter/summary")).json() items = _summary_by_key(summary) assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == { "old-client": 1 } - assert items.get("event_subject:org.statehub.workstream.completed") is None async def test_legacy_workplan_index_returns_410(self, client): r = await client.get( @@ -390,9 +389,100 @@ class TestWorkplanAliasesAndLegacyMeter: json={"execution_state": "manual", "launch_mode": "manual"}, headers={"X-StateHub-Component": "old-executor"}, ) - assert r.status_code == 200 + assert r.status_code == 410 + assert "retired" in r.json()["detail"] assert r.headers["X-StateHub-Replacement"] == "/execution/workplans/{workplan_id}/intent" summary = (await client.get("/legacy-meter/summary")).json() item = _summary_by_key(summary)["rest_api:PATCH /execution/workstreams/{workstream_id}/intent"] assert item["window"]["components"] == {"old-executor": 1} + + async def test_legacy_post_workstream_returns_410_and_meters(self, client): + await _create_domain(client) + topic = await _create_topic(client) + repo = await _create_repo(client, slug="legacy-create-repo") + + r = await client.post( + "/workstreams/", + json={ + "repo_id": str(repo["id"]), + "topic_id": str(topic["id"]), + "slug": "legacy-create", + "title": "Legacy Create", + "status": "active", + }, + headers={"X-StateHub-Component": "old-creator"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + assert r.headers["X-StateHub-Replacement"] == "/workplans/" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:POST /workstreams/"] + assert item["window"]["calls"] == 1 + assert item["window"]["components"] == {"old-creator": 1} + + async def test_legacy_get_workstream_detail_returns_410_and_meters(self, client): + await _create_domain(client) + topic = await _create_topic(client) + wp = await _create_workplan(client, topic["id"]) + + r = await client.get( + f"/workstreams/{wp['id']}", + headers={"X-StateHub-Component": "old-reader"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + assert r.headers["X-StateHub-Replacement"] == "/workplans/{workplan_id}" + + summary = (await client.get("/legacy-meter/summary")).json() + item = _summary_by_key(summary)["rest_api:GET /workstreams/{workstream_id}"] + assert item["window"]["calls"] == 1 + assert item["window"]["components"] == {"old-reader": 1} + + async def test_legacy_workstream_dependency_routes_return_410_and_meter(self, client): + await _create_domain(client) + topic = await _create_topic(client) + first = await _create_workplan(client, topic["id"], slug="dep-first", title="First") + second = await _create_workplan(client, topic["id"], slug="dep-second", title="Second") + + dep = await client.post( + f"/workplans/{first['id']}/dependencies/", + json={"to_workplan_id": second["id"]}, + ) + assert dep.status_code == 201, dep.text + dep_id = dep.json()["id"] + + r = await client.post( + f"/workstreams/{first['id']}/dependencies/", + json={"to_workstream_id": second["id"]}, + headers={"X-StateHub-Component": "old-dep-writer"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + + r = await client.get( + f"/workstreams/{first['id']}/dependencies/", + headers={"X-StateHub-Component": "old-dep-reader"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + + r = await client.delete( + f"/workstreams/{first['id']}/dependencies/{dep_id}", + headers={"X-StateHub-Component": "old-dep-archiver"}, + ) + assert r.status_code == 410 + assert "retired" in r.json()["detail"] + + summary = (await client.get("/legacy-meter/summary")).json() + items = _summary_by_key(summary) + assert items["rest_api:POST /workstreams/{workstream_id}/dependencies/"]["window"]["components"] == { + "old-dep-writer": 1 + } + assert items["rest_api:GET /workstreams/{workstream_id}/dependencies/"]["window"]["components"] == { + "old-dep-reader": 1 + } + assert items["rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}"]["window"]["components"] == { + "old-dep-archiver": 1 + } diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index 1dfa38b..4c5b36c 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -144,7 +144,7 @@ class TestWorkstreams: topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"]) - r = await client.get(f"/workstreams/?topic_id={topic['id']}") + r = await client.get(f"/workplans/?topic_id={topic['id']}") assert r.status_code == 200 ids = [w["id"] for w in r.json()] assert ws["id"] in ids @@ -154,7 +154,7 @@ class TestWorkstreams: topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"]) - r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "finished"}) + r = await client.patch(f"/workplans/{ws['id']}", json={"status": "finished"}) assert r.status_code == 200 assert r.json()["status"] == "finished" @@ -163,7 +163,7 @@ class TestWorkstreams: topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"]) - r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"}) + r = await client.patch(f"/workplans/{ws['id']}", json={"status": "completed"}) assert r.status_code == 200 assert r.json()["status"] == "finished" @@ -171,9 +171,9 @@ class TestWorkstreams: await _create_domain(client) topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"]) - await client.patch(f"/workstreams/{ws['id']}", json={"owner": "alice"}) + await client.patch(f"/workplans/{ws['id']}", json={"owner": "alice"}) - r = await client.get("/workstreams/?owner=alice") + r = await client.get("/workplans/?owner=alice") assert r.status_code == 200 assert len(r.json()) == 1 assert r.json()[0]["id"] == ws["id"] @@ -183,7 +183,7 @@ class TestWorkstreams: topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"], slug="my-special-ws") - r = await client.get("/workstreams/?slug=my-special-ws") + r = await client.get("/workplans/?slug=my-special-ws") assert r.status_code == 200 assert len(r.json()) == 1 @@ -358,7 +358,7 @@ class TestTasks: r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"}) assert r.status_code == 200 - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.status_code == 200 assert r.json()["status"] == "active" @@ -371,7 +371,7 @@ class TestTasks: r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"}) assert r.status_code == 200 - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.status_code == 200 assert r.json()["status"] == "blocked" @@ -460,7 +460,7 @@ class TestStateSummary: dependency_ws = await _create_workstream(client, topic["id"], slug="dependency-ws") r = await client.post( - f"/workstreams/{blocked_ws['id']}/dependencies/", + f"/workplans/{blocked_ws['id']}/dependencies/", json={ "to_workstream_id": dependency_ws["id"], "description": "Blocked until dependency completes", @@ -543,7 +543,7 @@ class TestFlowEndpoints: assert r.status_code == 200 assert r.json()["current_workstation"] == "finished" - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "finished" async def test_advance_workstream_respects_current_exit_assertions(self, client): @@ -554,7 +554,7 @@ class TestFlowEndpoints: task = await _create_task(client, ws["id"]) await client.patch(f"/tasks/{task['id']}", json={"status": "done"}) await client.post( - f"/workstreams/{ws['id']}/dependencies/", + f"/workplans/{ws['id']}/dependencies/", json={ "to_workstream_id": dependency_ws["id"], "description": "Dependency must finish first", @@ -565,7 +565,7 @@ class TestFlowEndpoints: assert r.status_code == 409 assert r.json()["detail"]["blocking_assertions"][0]["id"] == "dependencies.all_complete" - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "active" @@ -677,7 +677,7 @@ class TestReconciliationEndpoints: assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md" assert "status: backlog" in wp.read_text(encoding="utf-8") - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "backlog" async def test_apply_workstream_without_file_does_not_mutate_db(self, client): @@ -700,7 +700,7 @@ class TestReconciliationEndpoints: assert body["write_through_result"] == "not_applicable" assert body["reconciliation_record_id"] - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "active" r = await client.get("/messages/?to_agent=state-hub&unread_only=true") @@ -805,7 +805,7 @@ class TestReconciliationEndpoints: assert "status: active" in text assert "status: progress" in text - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "active" r = await client.get(f"/tasks/{task['id']}") @@ -883,7 +883,7 @@ class TestReconciliationEndpoints: "---\n", encoding="utf-8", ) - await client.patch(f"/workstreams/{ws['id']}", json={"status": "ready"}) + await client.patch(f"/workplans/{ws['id']}", json={"status": "ready"}) r = await client.post("/reconciliation/state-change", json={ "target_type": "workstream", @@ -901,7 +901,7 @@ class TestReconciliationEndpoints: assert "expected" in body["reason"] assert "status: active" in wp.read_text(encoding="utf-8") - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "ready" async def test_apply_workstream_file_status_drift_creates_conflict_message(self, client, tmp_path): @@ -941,7 +941,7 @@ class TestReconciliationEndpoints: assert "differs from cached DB status" in body["reason"] assert "status: ready" in wp.read_text(encoding="utf-8") - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "active" async def test_apply_task_unavailable_host_path_creates_conflict_message(self, client, tmp_path): @@ -987,7 +987,7 @@ class TestExecutionQueueEndpoints: topic = await _create_topic(client) ws = await _create_workstream(client, topic["id"], status="ready") - r = await client.patch(f"/execution/workstreams/{ws['id']}/intent", json={ + r = await client.patch(f"/execution/workplans/{ws['id']}/intent", json={ "execution_state": "queued", "launch_mode": "queued", "concurrency_mode": "parallel", @@ -1002,7 +1002,7 @@ class TestExecutionQueueEndpoints: assert body["concurrency_mode"] == "parallel" assert body["queue_rank"] == 7 - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "ready" assert r.json()["execution_state"] == "queued" @@ -1041,23 +1041,23 @@ class TestExecutionQueueEndpoints: planning_priority="low", planning_order=3, ) - await client.patch(f"/execution/workstreams/{queued['id']}/intent", json={ + await client.patch(f"/execution/workplans/{queued['id']}/intent", json={ "execution_state": "queued", "launch_mode": "queued", "queue_rank": 2, }) - await client.patch(f"/execution/workstreams/{blocked['id']}/intent", json={ + await client.patch(f"/execution/workplans/{blocked['id']}/intent", json={ "execution_state": "queued", "launch_mode": "queued", "queue_rank": 1, }) - await client.patch(f"/execution/workstreams/{lifecycle_blocked['id']}/intent", json={ + await client.patch(f"/execution/workplans/{lifecycle_blocked['id']}/intent", json={ "execution_state": "queued", "launch_mode": "queued", "queue_rank": 0, }) await client.post( - f"/workstreams/{blocked['id']}/dependencies/", + f"/workplans/{blocked['id']}/dependencies/", json={"to_workstream_id": dependency["id"], "description": "wait"}, ) @@ -1096,7 +1096,7 @@ class TestExecutionQueueEndpoints: assert body["immediate_pickup"] is True assert body["status"] == "requested" - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") updated = r.json() assert updated["status"] == "ready" assert updated["execution_state"] == "launching" @@ -1508,7 +1508,7 @@ class TestFabricGraphReadModel: after_progress = await client.get("/progress/") assert len(after_progress.json()) == before_progress_count - r = await client.get(f"/workstreams/{ws['id']}") + r = await client.get(f"/workplans/{ws['id']}") assert r.json()["status"] == "ready" r = await client.get(f"/tasks/{task['id']}") assert r.json()["status"] == "todo" diff --git a/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md b/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md index 85f4714..92b8daf 100644 --- a/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md +++ b/workplans/STATE-WP-0070-workplan-terminology-phase2-retirement.md @@ -10,7 +10,7 @@ topic_slug: custodian planning_priority: medium planning_order: 70 created: "2026-07-09" -updated: "2026-07-09" +updated: "2026-07-10" state_hub_workstream_id: "9aa92529-6ee8-4b3f-b573-78b0ef8d9788" --- @@ -69,7 +69,7 @@ Legacy GET decline monitoring continues week-over-week per acceptance gate. ```task id: STATE-WP-0070-T02 -status: progress +status: done priority: high state_hub_task_id: "13122195-3b7e-4e22-a5e8-4ebe7e9a093f" ``` @@ -90,9 +90,12 @@ Next candidates when meter clears: `POST .../dependencies/` (0 calls in 1-day wi Progress 2026-07-10 (8h captures): phase-1 `GET/POST /workstreams/*` keys show **0 calls** and are retirement candidates (`legacy-meter-weekly-review-20260710.json`). Streak ~**2/7** consecutive 8h zero windows per key (since post-0072 decline). -Residual: `POST /progress/ workstream_id` — 3 calls/window (`unknown`). **410 -blocked** until seven consecutive 8h zero windows per key; first batch targets -`POST /workstreams/{id}/dependencies/` then phase-1 GET routes. +Residual: `POST /progress/ workstream_id` — 3 calls/window (`unknown`). + +Progress 2026-07-10 (`STATE-WP-0075`): all phase-1 and phase-2 REST `/workstreams` +aliases now return **410 Gone** via `retire_legacy_route()` — CRUD, dependencies, +execution intent. Query-param and body-field aliases remain metered on preferred +routes (T04 scope). Backlog updated; tests green. ## Task: MCP alias removal (Phase 2) diff --git a/workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md b/workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md index 24295c3..b1e6afe 100644 --- a/workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md +++ b/workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md @@ -4,13 +4,13 @@ type: workplan title: "Legacy-meter decline monitoring and remote caller audit" domain: infotech repo: state-hub -status: active +status: finished owner: codex topic_slug: custodian planning_priority: high planning_order: 73 created: "2026-07-09" -updated: "2026-07-09" +updated: "2026-07-10" state_hub_workstream_id: "44e2e123-9934-4b5e-8b50-1bac548c5b70" --- @@ -129,7 +129,7 @@ Done when railiance01 custodian-runtime pods project post-0072 prompt text. ```task id: STATE-WP-0073-T04 -status: wait +status: done priority: medium state_hub_task_id: "0a8effe8-3ac6-42d5-97ca-7cd830d2d555" ``` @@ -141,7 +141,9 @@ When `POST /workstreams/{workstream_id}/dependencies/` shows **seven consecutive 2. Update tests and backlog checklist. 3. Capture via `--retire-keys` on evidence script. -Blocked on meter gate — unblocks `STATE-WP-0070` T02. +Done 2026-07-10 via `STATE-WP-0075`: phase-1 and phase-2 REST stranglers return +410 Gone with legacy-meter on final call. Unblocked `STATE-WP-0070` T02 REST +retirement batch. ## Task: Attribution sprint if meter flat diff --git a/workplans/STATE-WP-0075-workstream-route-410-stranglers.md b/workplans/STATE-WP-0075-workstream-route-410-stranglers.md new file mode 100644 index 0000000..719021a --- /dev/null +++ b/workplans/STATE-WP-0075-workstream-route-410-stranglers.md @@ -0,0 +1,94 @@ +--- +id: STATE-WP-0075 +type: workplan +title: "Workstream REST route 410 stranglers" +domain: infotech +repo: state-hub +status: finished +owner: codex +topic_slug: custodian +planning_priority: high +planning_order: 75 +created: "2026-07-10" +updated: "2026-07-10" +--- + +# STATE-WP-0075 — Workstream REST route 410 stranglers + +**Parent:** `STATE-WP-0070` T02 (phase-2 legacy retirement). +**Handoff from:** `STATE-WP-0073` T04 (meter-cleared REST key retirement). + +**Baseline evidence:** `docs/evidence/legacy-meter-weekly-review-20260710.json` +— phase-1 `/workstreams/*` keys at 0 calls (retirement candidates; ~2/7 +consecutive 8h zero windows at capture time). Operator approved early 410 rollout +after caller remediation (`STATE-WP-0072`) drove hot-key counts to zero. + +## Goal + +Convert remaining phase-1 and phase-2 `/workstreams` REST aliases to **410 Gone** +stranglers that still meter final legacy usage, matching the existing +`DELETE /workstreams/{id}` and `GET /workstreams/workplan-index` pattern. + +Preferred `/workplans/*` routes remain the only functional surface. + +## Task: Shared retire_legacy_route helper + +```task +id: STATE-WP-0075-T01 +status: done +priority: high +``` + +1. Add `retire_legacy_route()` to `api/services/legacy_compat.py` — meter usage, + set deprecation headers, raise `HTTPException(410)`. +2. Refactor existing 410 routes (`workplan-index`, `DELETE`) to use the helper. + +Done when all 410 stranglers share one code path for metering and headers. + +## Task: Phase-1 workstream CRUD stranglers + +```task +id: STATE-WP-0075-T02 +status: done +priority: high +``` + +Return **410 Gone** on: + +- `GET /workstreams/` +- `POST /workstreams/` +- `GET /workstreams/{workstream_id}` +- `PATCH /workstreams/{workstream_id}` + +Keep `/workplans/*` handlers unchanged. + +## Task: Phase-2 dependency and execution stranglers + +```task +id: STATE-WP-0075-T03 +status: done +priority: high +``` + +Return **410 Gone** on: + +- `POST/GET/DELETE /workstreams/{workstream_id}/dependencies/…` +- `PATCH /execution/workstreams/{workstream_id}/intent` + +## Task: Tests, backlog, and parent handoff + +```task +id: STATE-WP-0075-T04 +status: done +priority: high +``` + +1. Update `tests/test_legacy_meter.py` and `tests/test_routers_core.py` for 410 + expectations on all retired keys. +2. Mark phase 1–2 REST keys retired in + `docs/workplan-terminology-legacy-retirement-backlog.md`. +3. Run `pytest tests/test_legacy_meter.py tests/test_routers_core.py`. +4. Register via `statehub fix-consistency --repo state-hub`. +5. Hand off completion to `STATE-WP-0070` T02 and close `STATE-WP-0073` T04. + +Done when tests pass, workplan registered, and parent tasks reflect retirement. \ No newline at end of file