feat(STATE-WP-0079): retire the suggestion-backlog surface (slice E1)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 37s

History is archived fleet-side, so the read surface has no remaining job.

- api/routers/suggestions.py: whole prefix 410s with a pointer to intake and
  to the archive; the router drops from 176 lines to a stub
- mcp_server: the 6 suggestion tools removed outright rather than stubbed --
  a retired tool that still appears in the tool list costs every agent
  session context on every call, which is the opposite of retiring it
- write_idempotency: 5 /suggestions rules dropped
- dashboard: suggestions.md deleted, nav entry removed, reference.md and
  wsjf-triage.md updated; docs/suggestions.md rewritten as archive pointer
- tests: two tests pinned the old read-live behaviour and now pin the
  retirement contract instead

Tables stay: they are retire/archive in SHR-INV-0001 and are captured by the
final dump at T06.

Untouched, and worth knowing during cutover: ui-feedback.md / todo.md
'suggestions' are Shift+click dashboard feedback backed by technical_debt, a
different entity that shares the word. E3 (dashboard-meta) is that page; its
owner is state-hub-until-cutover so it retires at the T06 window, not now.

Full suite 612 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-20 08:11:14 +02:00
parent 98af485cd5
commit fb363b37d3
13 changed files with 148 additions and 447 deletions

View file

@ -1,177 +1,45 @@
import uuid
"""Retired: the suggestion backlog.
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Slice E1 of the State Hub retirement (`STATE-WP-0079-T05`). The capability was
superseded by the **intake** work-record entity; mutations were retired
2026-07-21 under `CUST-WP-0061-T06`, and the reads were kept live only so the
historical record stayed reachable.
from api.database import get_session
from api.models.domain import Domain
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionNote,
SuggestionStage,
)
from api.schemas.suggestion import (
SuggestionBumpRelevance,
SuggestionCreate,
SuggestionDecline,
SuggestionNoteRead,
SuggestionPromote,
SuggestionRead,
SuggestionVet,
)
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
That history is now archived at
`the-custodian/docs/archived-suggestion-backlog.md` all 10 suggestions, 10
notes and 5 relevance bumps, every one closed as `declined` during the intake
migration and none promoted. With a durable record outside this repo, the read
surface has no remaining job, so the whole router answers 410.
The `suggestions`, `suggestion_notes` and `suggestion_relevance_bumps` tables
are deliberately left in place: they are `retire`/`archive` in `SHR-INV-0001`
and are captured by the final dump at `STATE-WP-0079-T06`. Dropping them here
would remove data ahead of the dump for no gain.
"""
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
_DETAIL = (
"suggestions are retired (STATE-WP-0079-T05, slice E1). "
"Use POST /intakes/ for new discovery work. The historical backlog is "
"archived at the-custodian/docs/archived-suggestion-backlog.md — see also "
"the-custodian/intake-legacy-suggestions-migration.md and "
"canon/standards/work-record-types_v0.1.md."
)
def _retired() -> HTTPException:
"""CUST-WP-0061-T06 (2026-07-21): suggestions are read-only legacy.
Reads (GET) stay live for the historical record; every mutation is
retired. Use the intake work-record entity instead
(canon/standards/work-record-types_v0.1.md)."""
return HTTPException(
status_code=410,
detail=(
"suggestions are retired (read-only legacy, CUST-WP-0061-T06). "
"Use POST /intakes/ instead — see canon/standards/"
"work-record-types_v0.1.md and the-custodian/"
"intake-legacy-suggestions-migration.md."
),
)
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
return HTTPException(status_code=410, detail=_DETAIL)
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
data = SuggestionRead.model_validate(suggestion)
data.cost_of_delay = cost_of_delay(suggestion)
data.wsjf = compute_wsjf(suggestion)
return data
async def _get_suggestion_or_404(
suggestion_id: uuid.UUID,
session: AsyncSession,
) -> Suggestion:
suggestion = await session.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status_code=404, detail="Suggestion not found")
return suggestion
@router.get("/", response_model=list[SuggestionRead])
async def list_suggestions(
domain: str | None = None,
stage: SuggestionStage | None = None,
include_terminal: bool = Query(False),
rank: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> list[SuggestionRead]:
q = select(Suggestion)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(Suggestion.domain_id == domain_id)
if stage:
q = q.where(Suggestion.stage == stage)
elif not include_terminal:
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
result = await session.execute(q)
suggestions = list(result.scalars().all())
if rank == "wsjf":
suggestions.sort(key=suggestion_sort_key)
else:
suggestions.sort(key=lambda s: s.created_at)
return [_enrich_read(s) for s in suggestions[:limit]]
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
async def create_suggestion(
body: SuggestionCreate,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
"""Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. Use `POST /intakes/` (kind: intake per
canon/standards/work-record-types_v0.1.md) instead a fresh entity,
not a rename of this table. The 10 records that were still open at
retirement were migrated to file-backed intake records; see
the-custodian/intake-legacy-suggestions-migration.md."""
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PATCH", "PUT", "DELETE"],
include_in_schema=False,
)
@router.api_route("/", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
async def suggestions_retired(path: str = "") -> None:
"""Every suggestion route is retired; see module docstring."""
raise _retired()
@router.get("/{suggestion_id}", response_model=SuggestionRead)
async def get_suggestion(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
async def vet_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionVet,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
async def decline_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionDecline,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
async def promote_suggestion_to_task(
suggestion_id: uuid.UUID,
body: SuggestionPromote,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
async def bump_suggestion_relevance(
suggestion_id: uuid.UUID,
body: SuggestionBumpRelevance,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
"""Retired (CUST-WP-0061-T06). See _retired()."""
raise _retired()
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
async def list_suggestion_notes(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[SuggestionNote]:
await _get_suggestion_or_404(suggestion_id, session)
result = await session.execute(
select(SuggestionNote)
.where(SuggestionNote.suggestion_id == suggestion_id)
.order_by(SuggestionNote.created_at)
)
return list(result.scalars().all())

View file

@ -45,11 +45,6 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
)