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,
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)

View file

@ -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(

View file

@ -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),
)

View file

@ -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,
*,

View file

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

View file

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

View file

@ -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
}

View file

@ -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"

View file

@ -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)

View file

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

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.