feat(api): retire phase-1/2 /workstreams REST aliases with 410 stranglers
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled
Build and Publish Multi-Context Image / build-and-push (push) Successful in 2m18s

Add retire_legacy_route() helper and convert CRUD, dependency, and
execution intent legacy routes to 410 Gone while preserving final-call
legacy-meter attribution. Update tests, retirement backlog, and close
STATE-WP-0075 with handoff to STATE-WP-0070 T02 and STATE-WP-0073 T04.
This commit is contained in:
tegwick 2026-07-10 13:31:57 +02:00
parent c4ac853e35
commit 7036e2aa6b
11 changed files with 320 additions and 139 deletions

View file

@ -27,7 +27,8 @@ from api.services.execution_queue import (
queue_sort_key, queue_sort_key,
workplan_blockers, 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.services.legacy_compat import meter_legacy_query_param
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
@ -62,22 +63,23 @@ async def _update_execution_intent(
return _intent_read(ws) 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( async def update_execution_intent(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: ExecutionIntentUpdate, body: ExecutionIntentUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ExecutionIntentRead: ) -> None:
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"), interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"),
replacement_ref="/execution/workplans/{workplan_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) @router.patch("/workplans/{workplan_id}/intent", response_model=ExecutionIntentRead)

View file

@ -9,7 +9,8 @@ from api.models.task import Task
from api.models.workplan import Workplan from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency from api.models.workplan_dependency import WorkplanDependency
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead 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"]) router = APIRouter(prefix="/workstreams", tags=["dependencies"])
workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"]) workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
@ -82,8 +83,7 @@ async def _delete_dependency(
@router.post( @router.post(
"/{workstream_id}/dependencies/", "/{workstream_id}/dependencies/",
response_model=WorkplanDependencyRead, status_code=status.HTTP_410_GONE,
status_code=status.HTTP_201_CREATED,
) )
async def create_dependency( async def create_dependency(
request: Request, request: Request,
@ -91,16 +91,17 @@ async def create_dependency(
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: WorkplanDependencyCreate, body: WorkplanDependencyCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> WorkplanDependency: ) -> None:
"""Record that workstream_id depends on another workplan or a task.""" """Legacy alias retired — use POST /workplans/{workplan_id}/dependencies/."""
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"), interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_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( @workplan_router.post(
@ -118,23 +119,24 @@ async def create_workplan_dependency(
@router.get( @router.get(
"/{workstream_id}/dependencies/", "/{workstream_id}/dependencies/",
response_model=list[WorkplanDependencyRead], status_code=status.HTTP_410_GONE,
) )
async def list_dependencies( async def list_dependencies(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[WorkplanDependency]: ) -> None:
"""Return all dependency edges touching this workplan (both directions).""" """Legacy alias retired — use GET /workplans/{workplan_id}/dependencies/."""
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"), interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
replacement_ref="/workplans/{workplan_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( @workplan_router.get(
@ -150,7 +152,7 @@ async def list_workplan_dependencies(
@router.delete( @router.delete(
"/{workstream_id}/dependencies/{dep_id}", "/{workstream_id}/dependencies/{dep_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_410_GONE,
) )
async def delete_dependency( async def delete_dependency(
request: Request, request: Request,
@ -159,15 +161,16 @@ async def delete_dependency(
dep_id: uuid.UUID, dep_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> None: ) -> None:
"""Hard-delete a dependency edge. Removing a constraint is safe — no information is lost.""" """Legacy alias retired — use DELETE /workplans/{workplan_id}/dependencies/{dep_id}."""
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"), interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
replacement_ref="/workplans/{workplan_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( @workplan_router.delete(

View file

@ -23,8 +23,7 @@ from api.schemas.workplan import (
WorkplanUpdate, WorkplanUpdate,
) )
from api.services.lifecycle import transition_workplan_status from api.services.lifecycle import transition_workplan_status
from api.services.legacy_compat import legacy_response_headers, mark_legacy_response from api.services.legacy_compat import retire_legacy_route
from api.services.legacy_meter import identity_from_request, record_legacy_usage
from api.workplan_status import ( from api.workplan_status import (
is_supported_workplan_status, is_supported_workplan_status,
normalize_workplan_status, normalize_workplan_status,
@ -42,7 +41,6 @@ _INDEX_TTL = 30.0
_INDEX_REFRESH_TASK: asyncio.Task | None = None _INDEX_REFRESH_TASK: asyncio.Task | None = None
_INDEX_LAST_ERROR: str | None = None _INDEX_LAST_ERROR: str | None = None
_LEGACY_OWNER = "state-hub.api"
_COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed" _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}" 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( async def _list_workplans(
*, *,
topic_id: uuid.UUID | None, 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)) 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( async def list_workstreams(
request: Request, request: Request,
response: Response, response: Response,
@ -372,22 +346,14 @@ async def list_workstreams(
owner: str | None = None, owner: str | None = None,
slug: str | None = None, slug: str | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Workplan]: ) -> None:
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("GET", "/workstreams/"), interface_key=_legacy_key("GET", "/workstreams/"),
replacement_ref="/workplans/", replacement_ref="/workplans/",
) detail="Legacy GET /workstreams/ retired; use GET /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,
) )
@ -419,18 +385,13 @@ async def workplan_index(
refresh: bool = Query(False, description="Force cache invalidation"), refresh: bool = Query(False, description="Force cache invalidation"),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> None: ) -> None:
replacement_ref = "/workplans/index" await retire_legacy_route(
await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("GET", "/workstreams/workplan-index"), interface_key=_legacy_key("GET", "/workstreams/workplan-index"),
replacement_ref=replacement_ref, replacement_ref="/workplans/index",
)
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Legacy GET /workstreams/workplan-index retired; use GET /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)} 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( async def create_workstream(
request: Request, request: Request,
response: Response, response: Response,
body: WorkplanCreate, body: WorkplanCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workplan: ) -> None:
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("POST", "/workstreams/"), interface_key=_legacy_key("POST", "/workstreams/"),
replacement_ref="/workplans/", 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) @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) 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( async def get_workstream(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workplan: ) -> None:
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"), interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_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) @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) 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( async def update_workstream(
request: Request, request: Request,
response: Response, response: Response,
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
body: WorkplanUpdate, body: WorkplanUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workplan: ) -> None:
await _meter_legacy_route( await retire_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"), interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_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) @workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
@ -548,18 +509,13 @@ async def archive_workstream(
workstream_id: uuid.UUID, workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> None: ) -> None:
replacement_ref = "DELETE /workplans/{workplan_id}" await retire_legacy_route(
await _meter_legacy_route(
session=session, session=session,
request=request, request=request,
response=response, response=response,
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"), interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref=replacement_ref, replacement_ref="DELETE /workplans/{workplan_id}",
)
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}", detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}",
headers=legacy_response_headers(replacement_ref),
) )

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
from fastapi import Request, Response from fastapi import HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.services.legacy_meter import identity_from_request, record_legacy_usage 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) 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( async def meter_legacy_body_from_model(
body: object, body: object,
*, *,

View file

@ -42,16 +42,16 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers.
| Phase | Legacy-meter key | Replacement | Risk | Owner task | | Phase | Legacy-meter key | Replacement | Risk | Owner task |
| ---: | --- | --- | --- | --- | | ---: | --- | --- | --- | --- |
| 1 | `rest_api:GET /workstreams/` | `GET /workplans/` | REST | T04 | | 1 | ~~`rest_api:GET /workstreams/`~~ **retired** (410) | `GET /workplans/` | REST | T04 ✓ |
| 1 | `rest_api:POST /workstreams/` | `POST /workplans/` | REST | T04 | | 1 | ~~`rest_api:POST /workstreams/`~~ **retired** (410) | `POST /workplans/` | REST | T04 ✓ |
| 1 | `rest_api:GET /workstreams/{workstream_id}` | `GET /workplans/{workplan_id}` | REST | T04 | | 1 | ~~`rest_api:GET /workstreams/{workstream_id}`~~ **retired** (410) | `GET /workplans/{workplan_id}` | REST | T04 |
| 1 | `rest_api:PATCH /workstreams/{workstream_id}` | `PATCH /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: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 ✓ | | 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:GET /workstreams/{workstream_id}/dependencies/`~~ **retired** (410) | `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:POST /workstreams/{workstream_id}/dependencies/`~~ **retired** (410) | `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: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` | `PATCH /execution/workplans/{workplan_id}/intent` | 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:create_workstream` | `create_workplan` | MCP | T03 |
| 3 | `mcp:update_workstream` | `update_workplan` | MCP | T03 | | 3 | `mcp:update_workstream` | `update_workplan` | MCP | T03 |
| 3 | `mcp:update_workstream_status` | `update_workplan_status` | MCP | T03 | | 3 | `mcp:update_workstream_status` | `update_workplan_status` | MCP | T03 |

View file

@ -1280,7 +1280,7 @@ class TestC06WorkstreamCreation:
report = fix_repo("http://unused", "demo-repo") report = fix_repo("http://unused", "demo-repo")
assert created_workstreams[0]["slug"] == "demo-repo-state-wp-0001" 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") patched = wp.read_text(encoding="utf-8")
assert 'state_hub_workstream_id: "new-ws"' in patched assert 'state_hub_workstream_id: "new-ws"' in patched
assert 'state_hub_task_id: "new-task"' in patched assert 'state_hub_task_id: "new-task"' in patched

View file

@ -67,7 +67,7 @@ class TestWorkplanAliasesAndLegacyMeter:
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["interfaces"] == [] 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) await _create_domain(client)
topic = await _create_topic(client) topic = await _create_topic(client)
await _create_workplan(client, topic["id"]) await _create_workplan(client, topic["id"])
@ -80,7 +80,8 @@ class TestWorkplanAliasesAndLegacyMeter:
"X-StateHub-Component": "old-client", "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["Deprecation"] == "true"
assert r.headers.get("Sunset") assert r.headers.get("Sunset")
assert r.headers["X-StateHub-Replacement"] == "/workplans/" assert r.headers["X-StateHub-Replacement"] == "/workplans/"
@ -91,7 +92,6 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["window"]["tenants"] == {"tenant-a": 1} assert item["window"]["tenants"] == {"tenant-a": 1}
assert item["window"]["users"] == {"alice": 1} assert item["window"]["users"] == {"alice": 1}
assert item["window"]["components"] == {"old-client": 1} assert item["window"]["components"] == {"old-client": 1}
assert item["retirement_candidate"] is False
async def test_weekly_review_accepts_hours_window(self, client): async def test_weekly_review_accepts_hours_window(self, client):
r = await client.post("/legacy-meter/interfaces", json={ r = await client.post("/legacy-meter/interfaces", json={
@ -164,7 +164,7 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["retirement_candidate"] is False assert item["retirement_candidate"] is False
assert item["retirement_reason"] == "1 call(s) in review window" 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) await _create_domain(client)
topic = await _create_topic(client) topic = await _create_topic(client)
wp = await _create_workplan(client, topic["id"]) wp = await _create_workplan(client, topic["id"])
@ -174,15 +174,14 @@ class TestWorkplanAliasesAndLegacyMeter:
json={"status": "finished"}, json={"status": "finished"},
headers={"X-StateHub-Component": "old-client"}, headers={"X-StateHub-Component": "old-client"},
) )
assert r.status_code == 200 assert r.status_code == 410
assert r.json()["status"] == "finished" assert "retired" in r.json()["detail"]
summary = (await client.get("/legacy-meter/summary")).json() summary = (await client.get("/legacy-meter/summary")).json()
items = _summary_by_key(summary) items = _summary_by_key(summary)
assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == { assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == {
"old-client": 1 "old-client": 1
} }
assert items.get("event_subject:org.statehub.workstream.completed") is None
async def test_legacy_workplan_index_returns_410(self, client): async def test_legacy_workplan_index_returns_410(self, client):
r = await client.get( r = await client.get(
@ -390,9 +389,100 @@ class TestWorkplanAliasesAndLegacyMeter:
json={"execution_state": "manual", "launch_mode": "manual"}, json={"execution_state": "manual", "launch_mode": "manual"},
headers={"X-StateHub-Component": "old-executor"}, 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" assert r.headers["X-StateHub-Replacement"] == "/execution/workplans/{workplan_id}/intent"
summary = (await client.get("/legacy-meter/summary")).json() summary = (await client.get("/legacy-meter/summary")).json()
item = _summary_by_key(summary)["rest_api:PATCH /execution/workstreams/{workstream_id}/intent"] item = _summary_by_key(summary)["rest_api:PATCH /execution/workstreams/{workstream_id}/intent"]
assert item["window"]["components"] == {"old-executor": 1} 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
}

View file

@ -144,7 +144,7 @@ class TestWorkstreams:
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) 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 assert r.status_code == 200
ids = [w["id"] for w in r.json()] ids = [w["id"] for w in r.json()]
assert ws["id"] in ids assert ws["id"] in ids
@ -154,7 +154,7 @@ class TestWorkstreams:
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) 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.status_code == 200
assert r.json()["status"] == "finished" assert r.json()["status"] == "finished"
@ -163,7 +163,7 @@ class TestWorkstreams:
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) 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.status_code == 200
assert r.json()["status"] == "finished" assert r.json()["status"] == "finished"
@ -171,9 +171,9 @@ class TestWorkstreams:
await _create_domain(client) await _create_domain(client)
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) 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 r.status_code == 200
assert len(r.json()) == 1 assert len(r.json()) == 1
assert r.json()[0]["id"] == ws["id"] assert r.json()[0]["id"] == ws["id"]
@ -183,7 +183,7 @@ class TestWorkstreams:
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], slug="my-special-ws") 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 r.status_code == 200
assert len(r.json()) == 1 assert len(r.json()) == 1
@ -358,7 +358,7 @@ class TestTasks:
r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"}) r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
assert r.status_code == 200 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.status_code == 200
assert r.json()["status"] == "active" assert r.json()["status"] == "active"
@ -371,7 +371,7 @@ class TestTasks:
r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"}) r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
assert r.status_code == 200 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.status_code == 200
assert r.json()["status"] == "blocked" assert r.json()["status"] == "blocked"
@ -460,7 +460,7 @@ class TestStateSummary:
dependency_ws = await _create_workstream(client, topic["id"], slug="dependency-ws") dependency_ws = await _create_workstream(client, topic["id"], slug="dependency-ws")
r = await client.post( r = await client.post(
f"/workstreams/{blocked_ws['id']}/dependencies/", f"/workplans/{blocked_ws['id']}/dependencies/",
json={ json={
"to_workstream_id": dependency_ws["id"], "to_workstream_id": dependency_ws["id"],
"description": "Blocked until dependency completes", "description": "Blocked until dependency completes",
@ -543,7 +543,7 @@ class TestFlowEndpoints:
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["current_workstation"] == "finished" 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" assert r.json()["status"] == "finished"
async def test_advance_workstream_respects_current_exit_assertions(self, client): async def test_advance_workstream_respects_current_exit_assertions(self, client):
@ -554,7 +554,7 @@ class TestFlowEndpoints:
task = await _create_task(client, ws["id"]) task = await _create_task(client, ws["id"])
await client.patch(f"/tasks/{task['id']}", json={"status": "done"}) await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
await client.post( await client.post(
f"/workstreams/{ws['id']}/dependencies/", f"/workplans/{ws['id']}/dependencies/",
json={ json={
"to_workstream_id": dependency_ws["id"], "to_workstream_id": dependency_ws["id"],
"description": "Dependency must finish first", "description": "Dependency must finish first",
@ -565,7 +565,7 @@ class TestFlowEndpoints:
assert r.status_code == 409 assert r.status_code == 409
assert r.json()["detail"]["blocking_assertions"][0]["id"] == "dependencies.all_complete" 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" assert r.json()["status"] == "active"
@ -677,7 +677,7 @@ class TestReconciliationEndpoints:
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md" assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: backlog" in wp.read_text(encoding="utf-8") 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" assert r.json()["status"] == "backlog"
async def test_apply_workstream_without_file_does_not_mutate_db(self, client): 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["write_through_result"] == "not_applicable"
assert body["reconciliation_record_id"] 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" assert r.json()["status"] == "active"
r = await client.get("/messages/?to_agent=state-hub&unread_only=true") 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: active" in text
assert "status: progress" 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" assert r.json()["status"] == "active"
r = await client.get(f"/tasks/{task['id']}") r = await client.get(f"/tasks/{task['id']}")
@ -883,7 +883,7 @@ class TestReconciliationEndpoints:
"---\n", "---\n",
encoding="utf-8", 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={ r = await client.post("/reconciliation/state-change", json={
"target_type": "workstream", "target_type": "workstream",
@ -901,7 +901,7 @@ class TestReconciliationEndpoints:
assert "expected" in body["reason"] assert "expected" in body["reason"]
assert "status: active" in wp.read_text(encoding="utf-8") 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" assert r.json()["status"] == "ready"
async def test_apply_workstream_file_status_drift_creates_conflict_message(self, client, tmp_path): 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 "differs from cached DB status" in body["reason"]
assert "status: ready" in wp.read_text(encoding="utf-8") 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" assert r.json()["status"] == "active"
async def test_apply_task_unavailable_host_path_creates_conflict_message(self, client, tmp_path): 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) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"], status="ready") 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", "execution_state": "queued",
"launch_mode": "queued", "launch_mode": "queued",
"concurrency_mode": "parallel", "concurrency_mode": "parallel",
@ -1002,7 +1002,7 @@ class TestExecutionQueueEndpoints:
assert body["concurrency_mode"] == "parallel" assert body["concurrency_mode"] == "parallel"
assert body["queue_rank"] == 7 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()["status"] == "ready"
assert r.json()["execution_state"] == "queued" assert r.json()["execution_state"] == "queued"
@ -1041,23 +1041,23 @@ class TestExecutionQueueEndpoints:
planning_priority="low", planning_priority="low",
planning_order=3, 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", "execution_state": "queued",
"launch_mode": "queued", "launch_mode": "queued",
"queue_rank": 2, "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", "execution_state": "queued",
"launch_mode": "queued", "launch_mode": "queued",
"queue_rank": 1, "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", "execution_state": "queued",
"launch_mode": "queued", "launch_mode": "queued",
"queue_rank": 0, "queue_rank": 0,
}) })
await client.post( await client.post(
f"/workstreams/{blocked['id']}/dependencies/", f"/workplans/{blocked['id']}/dependencies/",
json={"to_workstream_id": dependency["id"], "description": "wait"}, json={"to_workstream_id": dependency["id"], "description": "wait"},
) )
@ -1096,7 +1096,7 @@ class TestExecutionQueueEndpoints:
assert body["immediate_pickup"] is True assert body["immediate_pickup"] is True
assert body["status"] == "requested" assert body["status"] == "requested"
r = await client.get(f"/workstreams/{ws['id']}") r = await client.get(f"/workplans/{ws['id']}")
updated = r.json() updated = r.json()
assert updated["status"] == "ready" assert updated["status"] == "ready"
assert updated["execution_state"] == "launching" assert updated["execution_state"] == "launching"
@ -1508,7 +1508,7 @@ class TestFabricGraphReadModel:
after_progress = await client.get("/progress/") after_progress = await client.get("/progress/")
assert len(after_progress.json()) == before_progress_count 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" assert r.json()["status"] == "ready"
r = await client.get(f"/tasks/{task['id']}") r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "todo" assert r.json()["status"] == "todo"

View file

@ -10,7 +10,7 @@ topic_slug: custodian
planning_priority: medium planning_priority: medium
planning_order: 70 planning_order: 70
created: "2026-07-09" created: "2026-07-09"
updated: "2026-07-09" updated: "2026-07-10"
state_hub_workstream_id: "9aa92529-6ee8-4b3f-b573-78b0ef8d9788" 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 ```task
id: STATE-WP-0070-T02 id: STATE-WP-0070-T02
status: progress status: done
priority: high priority: high
state_hub_task_id: "13122195-3b7e-4e22-a5e8-4ebe7e9a093f" 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 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`). calls** and are retirement candidates (`legacy-meter-weekly-review-20260710.json`).
Streak ~**2/7** consecutive 8h zero windows per key (since post-0072 decline). Streak ~**2/7** consecutive 8h zero windows per key (since post-0072 decline).
Residual: `POST /progress/ workstream_id` — 3 calls/window (`unknown`). **410 Residual: `POST /progress/ workstream_id` — 3 calls/window (`unknown`).
blocked** until seven consecutive 8h zero windows per key; first batch targets
`POST /workstreams/{id}/dependencies/` then phase-1 GET routes. 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) ## Task: MCP alias removal (Phase 2)

View file

@ -4,13 +4,13 @@ type: workplan
title: "Legacy-meter decline monitoring and remote caller audit" title: "Legacy-meter decline monitoring and remote caller audit"
domain: infotech domain: infotech
repo: state-hub repo: state-hub
status: active status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
planning_priority: high planning_priority: high
planning_order: 73 planning_order: 73
created: "2026-07-09" created: "2026-07-09"
updated: "2026-07-09" updated: "2026-07-10"
state_hub_workstream_id: "44e2e123-9934-4b5e-8b50-1bac548c5b70" 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 ```task
id: STATE-WP-0073-T04 id: STATE-WP-0073-T04
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "0a8effe8-3ac6-42d5-97ca-7cd830d2d555" 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. 2. Update tests and backlog checklist.
3. Capture via `--retire-keys` on evidence script. 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 ## Task: Attribution sprint if meter flat

View file

@ -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 12 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.