STATE-WP-0069 T04: Sunset headers and POST /progress body metering
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:
parent
05a138bc1c
commit
6e5e150803
8 changed files with 126 additions and 14 deletions
|
|
@ -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"]
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"')
|
||||||
|
|
||||||
|
|
@ -50,3 +54,34 @@ async def meter_legacy_query_param(
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue