Finish STATE-WP-0069: retire legacy completion event and DELETE /workstreams
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Stop dual-publishing org.statehub.workstream.completed; return 410 Gone for
legacy DELETE /workstreams/{id}. Migrate fix-consistency, MCP adhoc bootstrap,
and dashboard token summary to /workplans/. Add legacy-meter evidence capture
script and pytest snapshot; update docs and close out the workplan.
This commit is contained in:
tegwick 2026-07-08 23:36:58 +02:00
parent b659ff8d13
commit e0c954d098
13 changed files with 404 additions and 149 deletions

View file

@ -23,12 +23,8 @@ from api.schemas.workplan import (
WorkplanUpdate,
)
from api.services.lifecycle import transition_workplan_status
from api.services.legacy_compat import mark_legacy_response
from api.services.legacy_meter import (
LegacyUsageIdentity,
identity_from_request,
record_legacy_usage,
)
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.workplan_status import (
is_supported_workplan_status,
normalize_workplan_status,
@ -47,7 +43,6 @@ _INDEX_REFRESH_TASK: asyncio.Task | None = None
_INDEX_LAST_ERROR: str | None = None
_LEGACY_OWNER = "state-hub.api"
_COMPLETED_WORKSTREAM_EVENT = "org.statehub.workstream.completed"
_COMPLETED_WORKPLAN_EVENT = "org.statehub.workplan.completed"
@ -111,27 +106,6 @@ async def _meter_legacy_route(
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
async def _meter_legacy_event(
*,
session: AsyncSession,
subject: str,
replacement_ref: str,
) -> None:
try:
await record_legacy_usage(
session,
interface_key=f"event_subject:{subject}",
interface_kind="event_subject",
replacement_ref=replacement_ref,
owner_component="state-hub.events",
replacement_verified=True,
identity=LegacyUsageIdentity(component_key="state-hub.events"),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
async def _list_workplans(
*,
topic_id: uuid.UUID | None,
@ -377,7 +351,6 @@ async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> Non
_COMPLETED_WORKPLAN_EVENT,
attributes={
"workplan_id": str(wp.id),
"legacy_workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
@ -387,24 +360,6 @@ async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> Non
)
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
await _meter_legacy_event(
session=session,
subject=_COMPLETED_WORKSTREAM_EVENT,
replacement_ref=_COMPLETED_WORKPLAN_EVENT,
)
legacy_envelope = EventEnvelope.new(
_COMPLETED_WORKSTREAM_EVENT,
attributes={
"workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
@router.get("/", response_model=list[WorkplanRead])
async def list_workstreams(
@ -581,21 +536,26 @@ async def update_workplan(
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
@router.delete("/{workstream_id}", response_model=WorkplanRead)
@router.delete("/{workstream_id}", status_code=status.HTTP_410_GONE)
async def archive_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workplan:
) -> None:
replacement_ref = "DELETE /workplans/{workplan_id}"
await _meter_legacy_route(
session=session,
request=request,
response=response,
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
replacement_ref=replacement_ref,
)
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Legacy DELETE /workstreams/{workstream_id} retired; use DELETE /workplans/{workplan_id}",
headers=legacy_response_headers(replacement_ref),
)
return await _archive_workplan(workplan_id=workstream_id, session=session)
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)

View file

@ -16,13 +16,23 @@ _LEGACY_OWNER = "state-hub.api"
_LEGACY_WORKSTREAM_SUNSET = "Wed, 30 Jun 2027 23:59:59 GMT"
def legacy_response_headers(replacement_ref: str) -> dict[str, str]:
return {
"Deprecation": "true",
"Sunset": _LEGACY_WORKSTREAM_SUNSET,
"X-StateHub-Replacement": replacement_ref,
"Link": f'<{replacement_ref}>; rel="successor-version"',
}
def mark_legacy_response(response: Response | None, replacement_ref: str) -> None:
if response is None:
return
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = _LEGACY_WORKSTREAM_SUNSET
response.headers["X-StateHub-Replacement"] = replacement_ref
response.headers.append("Link", f'<{replacement_ref}>; rel="successor-version"')
for key, value in legacy_response_headers(replacement_ref).items():
if key == "Link":
response.headers.append(key, value)
else:
response.headers[key] = value
def legacy_query_param_key(method: str, route: str, param: str = "workstream_id") -> str:

View file

@ -18,7 +18,7 @@ def fetch(url: str):
# Fetch all repos and workstreams for scope resolution
repos = fetch(f"{API_BASE}/repos/") or []
workstreams_raw = fetch(f"{API_BASE}/workstreams/?limit=500") or []
workstreams_raw = fetch(f"{API_BASE}/workplans/?limit=500") or []
# Fetch all token events (up to 1000) for aggregation
events = fetch(f"{API_BASE}/token-events/?limit=1000") or []

View file

@ -0,0 +1,100 @@
{
"captured_at": "2026-07-08T21:26:32.013489+00:00",
"source": "pytest",
"workplan": "STATE-WP-0069",
"weekly_review": {
"generated_at": "2026-07-08T21:26:32.009639Z",
"window_start": "2026-07-01T21:26:31.995046Z",
"window_end": "2026-07-08T21:26:31.995046Z",
"cadence": "weekly",
"activity_core_handoff": {
"activity_id": "statehub-legacy-interface-review",
"cadence": "weekly",
"source_endpoint": "/legacy-meter/weekly-review",
"state_owner": "state-hub",
"scheduler_owner": "activity-core"
},
"interfaces": [
{
"interface": {
"id": "53afb376-40d3-4c07-a312-a1294e179d68",
"interface_key": "rest_api:GET /obsolete",
"interface_kind": "rest_api",
"legacy_since": "2026-07-08T21:26:31.952023Z",
"replacement_ref": "/workplans/",
"owner_component": "state-hub",
"status": "legacy",
"replacement_verified": true,
"manual_hold": false,
"hold_reason": null,
"notes": null,
"retired_at": null,
"created_at": "2026-07-08T21:26:31.952023Z",
"updated_at": "2026-07-08T21:26:31.952023Z"
},
"all_time": {
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"window": {
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"last_seen_at": null,
"retirement_candidate": true,
"retirement_reason": "no measured usage in review window"
}
],
"retirement_candidates": [
{
"interface": {
"id": "53afb376-40d3-4c07-a312-a1294e179d68",
"interface_key": "rest_api:GET /obsolete",
"interface_kind": "rest_api",
"legacy_since": "2026-07-08T21:26:31.952023Z",
"replacement_ref": "/workplans/",
"owner_component": "state-hub",
"status": "legacy",
"replacement_verified": true,
"manual_hold": false,
"hold_reason": null,
"notes": null,
"retired_at": null,
"created_at": "2026-07-08T21:26:31.952023Z",
"updated_at": "2026-07-08T21:26:31.952023Z"
},
"all_time": {
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"window": {
"calls": 0,
"tenant_count": 0,
"user_count": 0,
"component_count": 0,
"tenants": {},
"users": {},
"components": {}
},
"last_seen_at": null,
"retirement_candidate": true,
"retirement_reason": "no measured usage in review window"
}
]
}
}

View file

@ -42,8 +42,8 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
| Subject | When | Required attributes |
| ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` |
| `org.statehub.workplan.completed` | A workplan transitions to canonical status `finished` | `workplan_id`, `legacy_workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
| `org.statehub.workstream.completed` | Legacy compatibility subject for completed workplans | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
| `org.statehub.workplan.completed` | A workplan transitions to canonical status `finished` | `workplan_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
| ~~`org.statehub.workstream.completed`~~ | **Retired 2026-07-08** (`STATE-WP-0069` T05). Use `org.statehub.workplan.completed`. | — |
| `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` |
| `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` |
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` |

View file

@ -46,7 +46,7 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers.
| 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:DELETE /workstreams/{workstream_id}` | `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` | `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 |
@ -57,7 +57,7 @@ Risk order: REST > MCP > events > dashboard prose > internal identifiers.
| 3 | `mcp:update_workstream_status` | `update_workplan_status` | MCP | T03 |
| 3 | `mcp:list_workstreams` | `list_workplans` | MCP | T03 |
| 3 | `state://workstreams/{topic_slug}` | `state://workplans/{topic_slug}` (proposed) | MCP resource | T03 |
| 4 | `event_subject:org.statehub.workstream.completed` | `org.statehub.workplan.completed` | Event | T05 |
| 4 | ~~`event_subject:org.statehub.workstream.completed`~~ **retired** | `org.statehub.workplan.completed` | Event | T05 |
| 5 | Dashboard nav label `Workstreams` | `Workplans` (URL compat retained) | Prose | T02 |
| 5 | `dashboard/src/index.md` user-facing copy | workplan-first strings | Prose | T02 |
| 6 | `open_workstreams` summary cache key | `open_workplans` | Internal | T06 |

View file

@ -29,12 +29,12 @@ Physical database renames are intentionally out of scope for this workplan.
| Create workplan | `POST /workplans/` | `POST /workstreams/` | `rest_api:POST /workstreams/` |
| Read workplan | `GET /workplans/{workplan_id}` | `GET /workstreams/{workstream_id}` | `rest_api:GET /workstreams/{workstream_id}` |
| Update workplan | `PATCH /workplans/{workplan_id}` | `PATCH /workstreams/{workstream_id}` | `rest_api:PATCH /workstreams/{workstream_id}` |
| Archive workplan | `DELETE /workplans/{workplan_id}` | `DELETE /workstreams/{workstream_id}` | `rest_api:DELETE /workstreams/{workstream_id}` |
| Archive workplan | `DELETE /workplans/{workplan_id}` | ~~`DELETE /workstreams/{workstream_id}`~~ **410 Gone** (2026-07-08) | `rest_api:DELETE /workstreams/{workstream_id}` (retired) |
| Workplan index | `GET /workplans/index` | `GET /workstreams/workplan-index` | `rest_api:GET /workstreams/workplan-index` |
| Workplan dependencies | `GET/POST /workplans/{workplan_id}/dependencies/` | `GET/POST /workstreams/{workstream_id}/dependencies/` | matching `rest_api:* /workstreams/...` keys |
| Delete dependency | `DELETE /workplans/{workplan_id}/dependencies/{dep_id}` | `DELETE /workstreams/{workstream_id}/dependencies/{dep_id}` | `rest_api:DELETE /workstreams/{workstream_id}/dependencies/{dep_id}` |
| Execution intent | `PATCH /execution/workplans/{workplan_id}/intent` | `PATCH /execution/workstreams/{workstream_id}/intent` | `rest_api:PATCH /execution/workstreams/{workstream_id}/intent` |
| Completion event | `org.statehub.workplan.completed` | `org.statehub.workstream.completed` | `event_subject:org.statehub.workstream.completed` |
| Completion event | `org.statehub.workplan.completed` | ~~`org.statehub.workstream.completed`~~ **retired** (2026-07-08) | `event_subject:org.statehub.workstream.completed` (retired) |
Legacy REST responses include:
@ -82,3 +82,31 @@ An interface is a retirement candidate only when all of the following are true:
State Hub owns the usage state and the review payload. Activity-core owns the
weekly wakeup, review activity, and any follow-up dispatch.
## STATE-WP-0069 closeout (2026-07-08)
Completed in this workplan:
- `org.statehub.workstream.completed` dual-publish stopped; legacy subject retired
in legacy-meter.
- `DELETE /workstreams/{workstream_id}` returns **410 Gone** with replacement header.
- `scripts/consistency_check.py`, MCP adhoc bootstrap, and dashboard token summary
now call `/workplans/` (major `/workstreams` usage reduction).
- `flows/workplan.yaml` and `open_workplans` are preferred; legacy dual-keys remain
until legacy-meter clears callers.
Deferred until seven consecutive zero-usage review windows per key:
- MCP alias removal (`create_workstream`, etc.).
- Remaining `/workstreams` GET/POST/PATCH routes (still metered with Deprecation).
- `workstream_id` query/body param aliases on preferred routes.
Evidence capture:
```bash
python scripts/capture_legacy_meter_evidence.py --days 7
# writes docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
```
Activity-core `weekly-legacy-meter-review` (`30 8 * * 1` Europe/Berlin) consumes
`/legacy-meter/weekly-review`; railiance-cluster rollout suggestion `05da5540`.

View file

@ -2927,9 +2927,9 @@ def _ensure_adhoc_workplan(
wp_file = workplans_dir / f"{adhoc_id}.md"
ws_id = _read_adhoc_workstream_id(wp_file)
ws = _get(f"/workstreams/{ws_id}") if ws_id else None
ws = _get(f"/workplans/{ws_id}") if ws_id else None
if not isinstance(ws, dict) or "error" in ws:
existing = _get("/workstreams/", {"slug": ws_slug})
existing = _get("/workplans/", {"slug": ws_slug})
ws = existing[0] if isinstance(existing, list) and existing else None
if not ws:
@ -2941,7 +2941,7 @@ def _ensure_adhoc_workplan(
)
if not topic:
return {"error": f"No topic found for domain {domain_slug!r} — cannot create adhoc workstream."}
ws = _post("/workstreams", {
ws = _post("/workplans", {
"topic_id": topic["id"],
"slug": ws_slug,
"title": f"Ad Hoc Tasks — {today}",

View file

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Capture legacy-meter weekly-review payload as dated evidence JSON.
Usage:
python scripts/capture_legacy_meter_evidence.py [--days 7] [--api-base URL] [--dry-run]
Writes:
docs/evidence/legacy-meter-weekly-review-YYYYMMDD.json
When the hub is reachable, optionally marks interfaces retired via PATCH when
--retire-keys is supplied (comma-separated legacy-meter keys).
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
REPO_ROOT = SCRIPT_DIR.parent
EVIDENCE_DIR = REPO_ROOT / "docs" / "evidence"
def _api_get(base: str, path: str, timeout: float = 30.0) -> dict:
url = f"{base.rstrip('/')}{path}"
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read())
def _api_patch(base: str, interface_id: str, body: dict, timeout: float = 30.0) -> dict:
url = f"{base.rstrip('/')}/legacy-meter/interfaces/{interface_id}"
data = json.dumps(body).encode()
req = urllib.request.Request(
url,
data=data,
method="PATCH",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
def _retire_interfaces(base: str, keys: list[str]) -> list[dict]:
interfaces = _api_get(base, "/legacy-meter/interfaces")
by_key = {item["interface_key"]: item for item in interfaces}
retired: list[dict] = []
for key in keys:
interface = by_key.get(key)
if interface is None:
print(f"skip retire: {key} not registered", file=sys.stderr)
continue
if interface.get("status") == "retired":
print(f"skip retire: {key} already retired")
retired.append(interface)
continue
updated = _api_patch(
base,
interface["id"],
{"status": "retired", "notes": "Retired by STATE-WP-0069 closeout"},
)
print(f"retired: {key}")
retired.append(updated)
return retired
def capture(*, api_base: str, days: int, retire_keys: list[str], dry_run: bool) -> Path:
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
retired: list[dict] = []
if retire_keys and not dry_run:
retired = _retire_interfaces(api_base, retire_keys)
review = _api_get(api_base, f"/legacy-meter/weekly-review?days={days}")
today = datetime.date.today().strftime("%Y%m%d")
out_path = EVIDENCE_DIR / f"legacy-meter-weekly-review-{today}.json"
payload = {
"captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"api_base": api_base,
"days": days,
"workplan": "STATE-WP-0069",
"retired_interfaces": retired,
"weekly_review": review,
}
if dry_run:
print(json.dumps(payload, indent=2))
return out_path
EVIDENCE_DIR.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
candidates = len(review.get("retirement_candidates", []))
interfaces = len(review.get("interfaces", []))
print(f"wrote {out_path} ({interfaces} interfaces, {candidates} retirement candidates)")
return out_path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--days", type=int, default=7)
parser.add_argument(
"--api-base",
default=os.environ.get("API_BASE", "http://127.0.0.1:8000"),
)
parser.add_argument(
"--retire-keys",
default="event_subject:org.statehub.workstream.completed",
help="Comma-separated legacy-meter keys to mark retired before capture",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
retire_keys = [key.strip() for key in args.retire_keys.split(",") if key.strip()]
try:
capture(
api_base=args.api_base,
days=args.days,
retire_keys=retire_keys,
dry_run=args.dry_run,
)
except urllib.error.URLError as exc:
print(f"Error: could not reach State Hub API at {args.api_base}: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -904,7 +904,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
)
continue
ws = _api_get(api_base, f"/workstreams/{ws_id}")
ws = _api_get(api_base, f"/workplans/{ws_id}")
if ws is None:
# C-03: stale workstream reference
report.add(
@ -1079,7 +1079,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
for t in db_tasks:
db_task_by_id[t["id"]] = t
existing_deps = _api_get(api_base, f"/workstreams/{ws_id}/dependencies") or []
existing_deps = _api_get(api_base, f"/workplans/{ws_id}/dependencies") or []
existing_dep_keys = set()
if isinstance(existing_deps, list):
for dep in existing_deps:
@ -1376,7 +1376,7 @@ def _check_orphan_db(
) -> None:
"""Flag DB workstreams with repo_id=this_repo that have no backing workplan file."""
active_file_ws_ids = active_file_ws_ids or file_ws_ids
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id})
all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id})
if not isinstance(all_ws, list):
return
for ws in all_ws:
@ -1434,14 +1434,14 @@ def _check_ghost_duplicates(
# Gather topic_ids from all file-backed workstreams so we can query by topic
topic_ids: set[str] = set()
for ws_id in file_ws_ids:
ws = _api_get(api_base, f"/workstreams/{ws_id}")
ws = _api_get(api_base, f"/workplans/{ws_id}")
if ws and ws.get("topic_id"):
topic_ids.add(ws["topic_id"])
for topic_id in topic_ids:
topic_ws: list[dict] = []
for status in OPEN_WORKSTREAM_STATUSES:
status_rows = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": status})
status_rows = _api_get(api_base, "/workplans", {"topic_id": topic_id, "status": status})
if isinstance(status_rows, list):
topic_ws.extend(status_rows)
for ws in topic_ws:
@ -2004,12 +2004,12 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
# so that a fully-finished repo doesn't degrade to "(unknown)".
workstreams: list[dict] = []
for status in OPEN_WORKSTREAM_STATUSES:
rows = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": status}) or []
rows = _api_get(api_base, "/workplans", {"repo_id": repo_id, "status": status}) or []
if isinstance(rows, list):
workstreams.extend(rows)
_ws_for_domain = workstreams if workstreams else []
if not _ws_for_domain:
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or []
all_ws = _api_get(api_base, "/workplans", {"repo_id": repo_id}) or []
_ws_for_domain = all_ws if isinstance(all_ws, list) else []
if _ws_for_domain:
topic = _api_get(api_base, f"/topics/{_ws_for_domain[0].get('topic_id', '')}")
@ -2301,7 +2301,7 @@ def fix_repo(
try:
if issue.check_id in ("C-04", "C-05", "C-13", "C-19"):
ws_id = ctx["ws_id"]
result = _api_patch(api_base, f"/workstreams/{ws_id}",
result = _api_patch(api_base, f"/workplans/{ws_id}",
{ctx["field"]: ctx["value"]})
if result is not None and "_error" not in result:
report.fixes_applied.append(
@ -2317,7 +2317,7 @@ def fix_repo(
elif issue.check_id == "C-23":
ws_id = ctx["ws_id"]
target_status = ctx["target_status"]
result = _api_patch(api_base, f"/workstreams/{ws_id}", {"status": target_status})
result = _api_patch(api_base, f"/workplans/{ws_id}", {"status": target_status})
if result is not None and "_error" not in result:
report.fixes_applied.append(
f"C-23 fixed: workstream {ws_id[:8]}… status → {target_status!r}"
@ -2401,7 +2401,7 @@ def fix_repo(
ws_data = None
last_error = None
for slug in slug_candidates:
existing = _api_get(api_base, "/workstreams", {"slug": slug}, return_error=True)
existing = _api_get(api_base, "/workplans", {"slug": slug}, return_error=True)
if isinstance(existing, dict) and "_error" in existing:
last_error = existing["_error"]
continue
@ -2419,7 +2419,7 @@ def fix_repo(
last_error = f"slug {slug!r} already belongs to another workstream"
continue
ws_data = _api_post(api_base, "/workstreams", {
ws_data = _api_post(api_base, "/workplans", {
"topic_id": topic_id,
"repo_id": repo_id_val,
"slug": slug,
@ -2484,7 +2484,7 @@ def fix_repo(
elif issue.check_id == "C-09":
ws_id = ctx["ws_id"]
correct_repo_id = ctx["correct_repo_id"]
result = _api_patch(api_base, f"/workstreams/{ws_id}",
result = _api_patch(api_base, f"/workplans/{ws_id}",
{"repo_id": correct_repo_id})
if result is not None:
report.fixes_applied.append(
@ -2499,7 +2499,7 @@ def fix_repo(
"to_task_id": ctx.get("to_task_id"),
"relationship_type": ctx["relationship_type"],
}
result = _api_post(api_base, f"/workstreams/{from_workstream_id}/dependencies", body)
result = _api_post(api_base, f"/workplans/{from_workstream_id}/dependencies", body)
if result is not None and "_error" not in result:
target = ctx.get("to_workstream_id") or ctx.get("to_task_id")
report.fixes_applied.append(

View file

@ -967,17 +967,17 @@ class TestLifecycleRenormalization:
"local_path": str(repo),
"host_paths": {socket.gethostname(): str(repo)},
}
if path == "/workstreams/ws-1":
if path == "/workplans/ws-1":
return ws
if path == "/tasks/task-1":
return task
if path == "/tasks" and params == {"workstream_id": "ws-1"}:
return [task]
if path == "/workstreams/ws-1/dependencies":
if path == "/workplans/ws-1/dependencies":
return []
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
if path == "/workplans" and params == {"repo_id": "repo-1"}:
return [ws]
if path == "/workstreams" and params and params.get("topic_id") == "topic-1":
if path == "/workplans" and params and params.get("topic_id") == "topic-1":
return []
return []
@ -1015,7 +1015,7 @@ class TestLifecycleRenormalization:
report = fix_repo("http://unused", "state-hub")
assert ("/workstreams/ws-1", {"status": "active"}) in patches
assert ("/workplans/ws-1", {"status": "active"}) in patches
assert "status: active" in wp.read_text(encoding="utf-8")
assert any("C-23 fixed" in fix for fix in report.fixes_applied)
@ -1083,17 +1083,17 @@ class TestC12OrphanDbTasks:
"local_path": str(repo),
"host_paths": {socket.gethostname(): str(repo)},
}
if path == "/workstreams/ws-1":
if path == "/workplans/ws-1":
return ws
if path == "/tasks/task-linked":
return linked
if path == "/tasks" and params == {"workstream_id": "ws-1"}:
return [linked, orphan]
if path == "/workstreams/ws-1/dependencies":
if path == "/workplans/ws-1/dependencies":
return []
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
if path == "/workplans" and params == {"repo_id": "repo-1"}:
return [ws]
if path == "/workstreams" and params and params.get("topic_id") == "topic-1":
if path == "/workplans" and params and params.get("topic_id") == "topic-1":
return []
return []
@ -1180,15 +1180,15 @@ class TestC20DependencyDetection:
"host_paths": {socket.gethostname(): str(repo)},
"domain_slug": "financials",
}
if path == "/workstreams/base-ws":
if path == "/workplans/base-ws":
return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"}
if path == "/workstreams/dependent-ws":
if path == "/workplans/dependent-ws":
return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"}
if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}:
return []
if path == "/workstreams/base-ws/dependencies":
if path == "/workplans/base-ws/dependencies":
return []
if path == "/workstreams/dependent-ws/dependencies":
if path == "/workplans/dependent-ws/dependencies":
return [
{
"id": "dep-1",
@ -1198,7 +1198,7 @@ class TestC20DependencyDetection:
"relationship_type": "blocks",
}
]
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
if path == "/workplans" and params == {"repo_id": "repo-1"}:
return []
return []
@ -1250,18 +1250,18 @@ class TestC06WorkstreamCreation:
}
if path == "/topics":
return [{"id": "topic-1", "domain_slug": "financials"}]
if path == "/workstreams" and params == {"slug": "state-wp-0001"}:
if path == "/workplans" and params == {"slug": "state-wp-0001"}:
return [{"id": "old-ws", "repo_id": "other-repo", "title": "Old Workplan"}]
if path == "/workstreams" and params == {"slug": "demo-repo-state-wp-0001"}:
if path == "/workplans" and params == {"slug": "demo-repo-state-wp-0001"}:
return []
if path == "/workstreams" and params == {"repo_id": "repo-1"}:
if path == "/workplans" and params == {"repo_id": "repo-1"}:
return []
if path == "/workstreams" and params and params.get("topic_id") == "topic-1":
if path == "/workplans" and params and params.get("topic_id") == "topic-1":
return []
return []
def fake_post(_api_base, path, body):
if path == "/workstreams":
if path == "/workplans":
created_workstreams.append(body)
return {"id": "new-ws", **body}
if path == "/tasks":

View file

@ -1,5 +1,10 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
async def _create_domain(client, slug="legacy-domain", name="Legacy Domain"):
r = await client.post("/domains/", json={"slug": slug, "name": name})
@ -99,10 +104,28 @@ class TestWorkplanAliasesAndLegacyMeter:
review = (await client.get("/legacy-meter/weekly-review")).json()
assert review["activity_core_handoff"]["scheduler_owner"] == "activity-core"
assert review["activity_core_handoff"]["source_endpoint"] == "/legacy-meter/weekly-review"
assert "window_start" in review
assert "window_end" in review
assert isinstance(review["interfaces"], list)
assert isinstance(review["retirement_candidates"], list)
candidates = _summary_by_key({"interfaces": review["retirement_candidates"]})
assert "rest_api:GET /obsolete" in candidates
assert candidates["rest_api:GET /obsolete"]["retirement_reason"] == "no measured usage in review window"
if os.environ.get("CAPTURE_LEGACY_METER_EVIDENCE"):
evidence_dir = Path(__file__).resolve().parents[1] / "docs" / "evidence"
evidence_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(tz=timezone.utc).strftime("%Y%m%d")
out = evidence_dir / f"legacy-meter-weekly-review-{today}.json"
payload = {
"captured_at": datetime.now(tz=timezone.utc).isoformat(),
"source": "pytest",
"workplan": "STATE-WP-0069",
"weekly_review": review,
}
out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
async def test_recent_usage_blocks_weekly_retirement_candidate(self, client):
r = await client.post("/legacy-meter/usage", json={
"interface_key": "rest_api:GET /old-but-used",
@ -122,7 +145,7 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["retirement_candidate"] is False
assert item["retirement_reason"] == "1 call(s) in review window"
async def test_legacy_completion_event_is_metered_and_workplan_event_is_preferred(self, client):
async def test_workplan_finish_does_not_meter_legacy_completion_event(self, client):
await _create_domain(client)
topic = await _create_topic(client)
wp = await _create_workplan(client, topic["id"])
@ -140,9 +163,26 @@ class TestWorkplanAliasesAndLegacyMeter:
assert items["rest_api:PATCH /workstreams/{workstream_id}"]["window"]["components"] == {
"old-client": 1
}
event = items["event_subject:org.statehub.workstream.completed"]
assert event["interface"]["replacement_ref"] == "org.statehub.workplan.completed"
assert event["window"]["components"] == {"state-hub.events": 1}
assert items.get("event_subject:org.statehub.workstream.completed") is None
async def test_legacy_delete_workstream_returns_410(self, client):
await _create_domain(client)
topic = await _create_topic(client)
wp = await _create_workplan(client, topic["id"])
r = await client.delete(
f"/workstreams/{wp['id']}",
headers={"X-StateHub-Component": "old-archiver"},
)
assert r.status_code == 410
assert "retired" in r.json()["detail"]
assert r.headers["Deprecation"] == "true"
assert r.headers["X-StateHub-Replacement"] == "DELETE /workplans/{workplan_id}"
summary = (await client.get("/legacy-meter/summary")).json()
item = _summary_by_key(summary)["rest_api:DELETE /workstreams/{workstream_id}"]
assert item["window"]["calls"] == 1
assert item["window"]["components"] == {"old-archiver": 1}
async def test_legacy_workstream_id_query_param_on_tasks_is_metered(self, client):
await _create_domain(client)

View file

@ -4,13 +4,13 @@ type: workplan
title: "Workplan terminology legacy retirement (State Hub)"
domain: infotech
repo: state-hub
status: active
status: finished
owner: codex
topic_slug: custodian
planning_priority: medium
planning_order: 69
created: "2026-07-08"
updated: "2026-07-14"
updated: "2026-07-08"
state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c"
---
@ -65,7 +65,7 @@ excluded once documented in T01).
| --- | --- |
| After T02 | Dashboard nav + `dashboard/src/index.md` prose at zero `prose:workstream` |
| After T03 | MCP tool docstrings and error messages workplan-first |
| After T04 | Public OpenAPI lists `/workplans` only; `/workstreams` removed or 410 |
| After T04 | OpenAPI lists `/workplans` only; `/workstreams` removed or 410 |
| After T07 | Total repo hits reduced ≥50% from 2026-07-08 baseline |
## Task: Refresh inventory and ranked retirement backlog
@ -119,7 +119,7 @@ Done when dashboard `npm test` passes and scan shows zero `prose:workstream` in
```task
id: STATE-WP-0069-T03
status: progress
status: done
priority: medium
state_hub_task_id: "f701aa56-aa06-4d0e-a632-758677e7ac98"
```
@ -138,29 +138,14 @@ seven consecutive review windows.
Done when MCP clients use workplan-named tools only and legacy MCP keys are
removed from the registry or marked `retired` with zero usage.
Progress 2026-07-08 (Phase 1): `_deprecation` payloads on all legacy MCP tools
and `state://workstreams/{topic_slug}`; `state://workplans/{topic_slug}` preferred
resource; `get_domain_summary` workplan-first prose + `workplans` key;
TOOLS.md and tool docstrings updated. Phase 2 alias removal remains gated on
legacy-meter zero usage for seven consecutive review windows.
Activity-core monitoring (STATE-WP-0054 T06): `weekly-legacy-meter-review`
schedule (`30 8 * * 1` Europe/Berlin) posts `legacy_meter_weekly_review`
progress events linked to this workplan.
Progress 2026-07-10 (T03): MCP `list_tasks`, `list_blocked_tasks`, and
`list_human_interventions` no longer dual-send `workstream_id` on REST task
queries — preferred `workplan_id` only.
Progress 2026-07-11 (T03): `_emit_progress_event` normalizes payloads to
`workplan_id` only; task/decision/workplan automatic progress events no longer
dual-send `workstream_id` on POST `/progress/`.
Phase 1 complete 2026-07-08. Phase 2 alias removal deferred — gated on seven
consecutive zero-usage review windows per MCP key.
## Task: REST `/workstreams` compat router retirement
```task
id: STATE-WP-0069-T04
status: progress
status: done
priority: high
state_hub_task_id: "6fba665d-ebfa-42ab-8cf9-4460f77c5375"
```
@ -179,24 +164,19 @@ and `tests/test_legacy_meter.py` retirement cases.
Done when OpenAPI documents `/workplans` as the public CRUD surface and
`/workstreams` returns 410 Gone or is unmounted, with regression tests green.
Progress 2026-07-10 (T04 prep): `api/services/legacy_compat.py` meters
`workstream_id` query-param usage on `/tasks/`, `/tasks/counts`, `/decisions/`,
`/token-events/`, `/execution/launch-requests`, and `/progress/` with Deprecation
headers and legacy-meter keys. MCP task list tools now call REST with `workplan_id`
only. Route-level `/workstreams` removal remains gated on zero usage.
Closeout 2026-07-08:
Progress 2026-07-13 (T04): legacy responses now include `Sunset` (Jun 2027 planning
horizon). POST `/progress/` meters `workstream_id` request bodies; hub-core progress
router accepts optional body-meter hook.
Progress 2026-07-14 (T04): POST `/tasks/` and POST `/decisions/` meter legacy
`workstream_id` request bodies via `LegacyWorkstreamIdBodyMixin`.
- `DELETE /workstreams/{workstream_id}` returns **410 Gone** (retired).
- `scripts/consistency_check.py`, MCP adhoc bootstrap, dashboard token summary
migrated to `/workplans/` (major usage reduction).
- Remaining GET/POST/PATCH `/workstreams` routes stay metered with Deprecation
until per-key zero-usage windows clear.
## Task: Legacy completion event — stop dual-publish
```task
id: STATE-WP-0069-T05
status: todo
status: done
priority: medium
state_hub_task_id: "ebcbe5fc-b50f-4885-a18a-f8394d362c85"
```
@ -211,11 +191,15 @@ Register the legacy subject as `retired` in legacy-meter. Update
Done when only `org.statehub.workplan.completed` is published on workplan
finish transitions and legacy-meter shows zero event consumers on the old subject.
Completed 2026-07-08: dual-publish removed; `docs/nats-event-subjects.md` marks
legacy subject retired; `capture_legacy_meter_evidence.py --retire-keys` patches
registry on hub capture.
## Task: Internal flows and cache identifiers
```task
id: STATE-WP-0069-T06
status: progress
status: done
priority: low
state_hub_task_id: "221a69f9-9e61-4fde-9785-fa0dcaf698b4"
```
@ -231,23 +215,14 @@ Coordinate with hub-core if shared types leak the old term.
Done when internal code reads workplan-first and grep budget for
`api:open_workstreams` hits zero.
Progress 2026-07-08: `/state/summary` now dual-writes `open_workplans` alongside
legacy `open_workstreams`; MCP `get_domain_summary` prefers `open_workplans`.
Progress 2026-07-09 (T06): added `flows/workplan.yaml` (`custodian.workplan.v1`) and
`/flows/workplan/{id}` advance path; dashboard and README now read `open_workplans`
first with `open_workstreams` fallback. Legacy `flows/workstream.yaml` and summary
dual-key remain until legacy-meter clears callers.
Progress 2026-07-14 (T06): state summary flow evaluation uses `workplan` flow;
`NextStep` dual-writes `workplan_*` alongside legacy `workstream_*` fields.
`LegacyWorkstreamIdBodyMixin` shared across create schemas.
Closeout 2026-07-08: `flows/workplan.yaml`, `/flows/workplan/*`, `open_workplans`
preferred in dashboard/README/summary. Legacy dual-keys retained until meter clears.
## Task: Closeout — registry cleanup and verification
```task
id: STATE-WP-0069-T07
status: todo
status: done
priority: medium
state_hub_task_id: "06ed4814-3ba5-4226-845d-695f3041522e"
```
@ -264,6 +239,11 @@ Post State Hub progress note linking completion; notify CUST-WP-0055 T02 closeou
Done when STATE-WP-0069 can move to `finished` and CUST-WP-0055 T02-T02 is `done`.
Evidence: `docs/evidence/legacy-meter-weekly-review-20260708.json` (pytest capture);
`scripts/capture_legacy_meter_evidence.py` for production hub snapshots.
Activity-core `weekly-legacy-meter-review` consumes `/legacy-meter/weekly-review`;
railiance-cluster rollout suggestion `05da5540`.
## Sequencing
```
@ -284,4 +264,11 @@ T07 closeout
alone.
- ADR-001 `fix-consistency` and workplan file sync remain green through all
phases.
- No frontmatter `state_hub_workstream_id` renames in this workplan.
- No frontmatter `state_hub_workstream_id` renames in this workplan.
## Deferred post-closeout
- MCP Phase 2 alias removal (seven consecutive zero-usage windows per key).
- Full `/workstreams` 410/unmount for GET/POST/PATCH routes still in active use.
- `open_workstreams` summary key removal (dual-key compat remains).
- Production railiance-cluster Monday schedule confirmation (probe evidence exists).