STATE-WP-0069 T04: Sunset headers and POST /progress body metering
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Add Sunset to legacy compat responses (Jun 2027 planning horizon).
Meter POST /progress/ bodies that use workstream_id; wire hub-core body
hook. Consolidate workstreams deprecation headers via legacy_compat.
This commit is contained in:
tegwick 2026-07-08 23:06:45 +02:00
parent 05a138bc1c
commit 6e5e150803
8 changed files with 126 additions and 14 deletions

View file

@ -1,14 +1,16 @@
from typing import Any
from fastapi import Request, Response from fastapi import Request, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.progress_event import ProgressEvent from api.models.progress_event import ProgressEvent
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
from api.services.legacy_compat import meter_legacy_query_param from api.services.legacy_compat import meter_legacy_body_field, meter_legacy_query_param
from hub_core.routers.progress import create_progress_router from hub_core.routers.progress import create_progress_router
async def _meter_progress_workstream_id( async def _meter_progress_workstream_id_query(
session: AsyncSession, session: AsyncSession,
request: Request, request: Request,
response: Response, response: Response,
@ -23,12 +25,34 @@ async def _meter_progress_workstream_id(
) )
async def _meter_progress_workstream_id_body(
session: AsyncSession,
request: Request,
response: Response,
) -> None:
await meter_legacy_body_field(
session=session,
request=request,
response=response,
method="POST",
route="/progress/",
field="workstream_id",
replacement_ref="POST /progress/ with workplan_id",
)
def _progress_body_uses_legacy_workstream_id(body: Any) -> bool:
return bool(getattr(body, "used_legacy_workstream_id", False))
router = create_progress_router( router = create_progress_router(
get_session, get_session,
progress_model=ProgressEvent, progress_model=ProgressEvent,
progress_create_schema=ProgressEventCreate, progress_create_schema=ProgressEventCreate,
progress_read_schema=ProgressEventRead, progress_read_schema=ProgressEventRead,
meter_legacy_workstream_id=_meter_progress_workstream_id, meter_legacy_workstream_id=_meter_progress_workstream_id_query,
progress_body_uses_legacy_workstream_id=_progress_body_uses_legacy_workstream_id,
meter_legacy_workstream_id_body=_meter_progress_workstream_id_body,
) )
__all__ = ["router"] __all__ = ["router"]

View file

@ -23,6 +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 mark_legacy_response
from api.services.legacy_meter import ( from api.services.legacy_meter import (
LegacyUsageIdentity, LegacyUsageIdentity,
identity_from_request, identity_from_request,
@ -86,14 +87,6 @@ def _legacy_key(method: str, route: str) -> str:
return f"rest_api:{method} {route}" return f"rest_api:{method} {route}"
def _mark_legacy_response(response: Response | None, replacement_ref: str) -> None:
if response is None:
return
response.headers["Deprecation"] = "true"
response.headers["X-StateHub-Replacement"] = replacement_ref
response.headers.append("Link", f"<{replacement_ref}>; rel=\"successor-version\"")
async def _meter_legacy_route( async def _meter_legacy_route(
*, *,
session: AsyncSession, session: AsyncSession,
@ -102,7 +95,7 @@ async def _meter_legacy_route(
interface_key: str, interface_key: str,
replacement_ref: str, replacement_ref: str,
) -> None: ) -> None:
_mark_legacy_response(response, replacement_ref) mark_legacy_response(response, replacement_ref)
try: try:
await record_legacy_usage( await record_legacy_usage(
session, session,

View file

@ -2,7 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pydantic import AliasChoices, BaseModel, ConfigDict, Field from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from api.schemas.compat import OptionalWorkplanIdCompatMixin from api.schemas.compat import OptionalWorkplanIdCompatMixin
@ -13,6 +13,7 @@ class ProgressEventCreate(BaseModel):
default=None, default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"), validation_alias=AliasChoices("workplan_id", "workstream_id"),
) )
used_legacy_workstream_id: bool = Field(default=False, exclude=True)
task_id: uuid.UUID | None = None task_id: uuid.UUID | None = None
decision_id: uuid.UUID | None = None decision_id: uuid.UUID | None = None
event_type: str event_type: str
@ -21,6 +22,14 @@ class ProgressEventCreate(BaseModel):
author: str | None = None author: str | None = None
session_id: str | None = None session_id: str | None = None
@model_validator(mode="before")
@classmethod
def _detect_legacy_workstream_id(cls, data: Any) -> Any:
if isinstance(data, dict):
if data.get("workstream_id") is not None and data.get("workplan_id") is None:
return {**data, "used_legacy_workstream_id": True}
return data
class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel): class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View file

@ -11,12 +11,16 @@ from api.services.legacy_meter import identity_from_request, record_legacy_usage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LEGACY_OWNER = "state-hub.api" _LEGACY_OWNER = "state-hub.api"
# Planning horizon for legacy workstream interfaces; actual removal remains
# legacy-meter-gated per docs/workplan-terminology-transition.md.
_LEGACY_WORKSTREAM_SUNSET = "Wed, 30 Jun 2027 23:59:59 GMT"
def mark_legacy_response(response: Response | None, replacement_ref: str) -> None: def mark_legacy_response(response: Response | None, replacement_ref: str) -> None:
if response is None: if response is None:
return return
response.headers["Deprecation"] = "true" response.headers["Deprecation"] = "true"
response.headers["Sunset"] = _LEGACY_WORKSTREAM_SUNSET
response.headers["X-StateHub-Replacement"] = replacement_ref response.headers["X-StateHub-Replacement"] = replacement_ref
response.headers.append("Link", f'<{replacement_ref}>; rel="successor-version"') response.headers.append("Link", f'<{replacement_ref}>; rel="successor-version"')
@ -37,6 +41,37 @@ async def meter_legacy_query_param(
) -> None: ) -> None:
interface_key = legacy_query_param_key(method, route, param) interface_key = legacy_query_param_key(method, route, param)
mark_legacy_response(response, replacement_ref) mark_legacy_response(response, replacement_ref)
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref=replacement_ref,
owner_component=_LEGACY_OWNER,
replacement_verified=True,
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
def legacy_body_field_key(method: str, route: str, field: str = "workstream_id") -> str:
return f"rest_api:{method} {route} {field}"
async def meter_legacy_body_field(
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
method: str,
route: str,
replacement_ref: str,
field: str = "workstream_id",
) -> None:
interface_key = legacy_body_field_key(method, route, field)
mark_legacy_response(response, replacement_ref)
try: try:
await record_legacy_usage( await record_legacy_usage(
session, session,

View file

@ -75,6 +75,7 @@ These accept `workstream_id` alongside `workplan_id` on preferred routes:
| `rest_api:GET /token-events/?workstream_id` | `GET /token-events/` | `GET /token-events/?workplan_id=` | | `rest_api:GET /token-events/?workstream_id` | `GET /token-events/` | `GET /token-events/?workplan_id=` |
| `rest_api:GET /execution/launch-requests?workstream_id` | `GET /execution/launch-requests` | `GET /execution/launch-requests?workplan_id=` | | `rest_api:GET /execution/launch-requests?workstream_id` | `GET /execution/launch-requests` | `GET /execution/launch-requests?workplan_id=` |
| `rest_api:GET /progress/?workstream_id` | `GET /progress/` | `GET /progress/?workplan_id=` | | `rest_api:GET /progress/?workstream_id` | `GET /progress/` | `GET /progress/?workplan_id=` |
| `rest_api:POST /progress/ workstream_id` | `POST /progress/` | `POST /progress/` with `workplan_id` body field |
Retire param aliases in T04 after zero-usage windows; callers surface via Retire param aliases in T04 after zero-usage windows; callers surface via
`X-StateHub-Component` in weekly review. `X-StateHub-Component` in weekly review.

View file

@ -39,6 +39,7 @@ Physical database renames are intentionally out of scope for this workplan.
Legacy REST responses include: Legacy REST responses include:
- `Deprecation: true` - `Deprecation: true`
- `Sunset: Wed, 30 Jun 2027 23:59:59 GMT` (planning horizon; removal remains legacy-meter-gated)
- `X-StateHub-Replacement: <preferred interface>` - `X-StateHub-Replacement: <preferred interface>`
- `Link: <<preferred interface>>; rel="successor-version"` - `Link: <<preferred interface>>; rel="successor-version"`

View file

@ -77,6 +77,7 @@ class TestWorkplanAliasesAndLegacyMeter:
) )
assert r.status_code == 200 assert r.status_code == 200
assert r.headers["Deprecation"] == "true" assert r.headers["Deprecation"] == "true"
assert r.headers.get("Sunset")
assert r.headers["X-StateHub-Replacement"] == "/workplans/" assert r.headers["X-StateHub-Replacement"] == "/workplans/"
summary = (await client.get("/legacy-meter/summary")).json() summary = (await client.get("/legacy-meter/summary")).json()
@ -189,6 +190,50 @@ class TestWorkplanAliasesAndLegacyMeter:
assert item["window"]["calls"] == 1 assert item["window"]["calls"] == 1
assert item["window"]["components"] == {"old-progress-client": 1} assert item["window"]["components"] == {"old-progress-client": 1}
async def test_legacy_workstream_id_body_on_progress_post_is_metered(self, client):
await _create_domain(client)
topic = await _create_topic(client)
wp = await _create_workplan(client, topic["id"])
r = await client.post(
"/progress/",
json={
"workstream_id": str(wp["id"]),
"event_type": "note",
"summary": "legacy body field",
"author": "test",
},
headers={"X-StateHub-Component": "old-progress-writer"},
)
assert r.status_code == 201
assert r.headers["Deprecation"] == "true"
assert r.headers["X-StateHub-Replacement"] == "POST /progress/ with workplan_id"
summary = (await client.get("/legacy-meter/summary")).json()
item = _summary_by_key(summary)["rest_api:POST /progress/ workstream_id"]
assert item["window"]["calls"] == 1
assert item["window"]["components"] == {"old-progress-writer": 1}
async def test_workplan_id_body_on_progress_post_is_not_metered(self, client):
await _create_domain(client)
topic = await _create_topic(client)
wp = await _create_workplan(client, topic["id"])
r = await client.post(
"/progress/",
json={
"workplan_id": str(wp["id"]),
"event_type": "note",
"summary": "preferred body field",
"author": "test",
},
)
assert r.status_code == 201
assert r.headers.get("Deprecation") != "true"
summary = (await client.get("/legacy-meter/summary")).json()
assert _summary_by_key(summary).get("rest_api:POST /progress/ workstream_id") is None
async def test_workplan_id_query_param_on_tasks_is_not_metered(self, client): async def test_workplan_id_query_param_on_tasks_is_not_metered(self, client):
await _create_domain(client) await _create_domain(client)
topic = await _create_topic(client) topic = await _create_topic(client)

View file

@ -10,7 +10,7 @@ topic_slug: custodian
planning_priority: medium planning_priority: medium
planning_order: 69 planning_order: 69
created: "2026-07-08" created: "2026-07-08"
updated: "2026-07-12" updated: "2026-07-13"
state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c" state_hub_workstream_id: "923bb94a-d16c-422c-b81e-16328bd7b60c"
--- ---
@ -185,6 +185,10 @@ Progress 2026-07-10 (T04 prep): `api/services/legacy_compat.py` meters
headers and legacy-meter keys. MCP task list tools now call REST with `workplan_id` 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. only. Route-level `/workstreams` removal remains gated on zero usage.
Progress 2026-07-13 (T04): legacy responses now include `Sunset` (Jun 2027 planning
horizon). POST `/progress/` meters `workstream_id` request bodies; hub-core progress
router accepts optional body-meter hook.
## Task: Legacy completion event — stop dual-publish ## Task: Legacy completion event — stop dual-publish
```task ```task