CUST-WP-0061-T06: retire suggestions to read-only legacy
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 19s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m4s

Founder-reviewed decision (WorkOrchestrationArchitectureDraft.md v0.2
section 8 item 6): the fresh intake work-record entity replaces
suggestions, not a rename-bridge. All 5 mutation endpoints (create, vet,
decline, promote, bump-relevance) now 410 with a pointer to POST
/intakes/ and the migration doc; GET/list stay live for the historical
record (10 rows migrated to file-backed intake records in the-custodian,
see that repo's intake-legacy-suggestions-migration.md and CUST-IN-0001
through CUST-IN-0010).

Removed dead code the retirement makes unreachable: Task/TaskPriority/
TaskStatus/normalize_task_status imports (only used by the deleted
promote body), the suggestion_relevance.bump_relevance import, and the
_ALLOWED_*_FROM stage-guard sets + _reject_stage helper (only used by
the deleted vet/decline/promote bodies). WSJF ranking (compute_wsjf,
cost_of_delay, suggestion_sort_key) stays -- still exercised by the
surviving GET /suggestions/?rank=wsjf read path.

MCP tool docstrings (create_suggestion, vet_suggestion,
decline_suggestion, promote_suggestion_to_task,
bump_suggestion_relevance) updated to point at the replacement
(create_intake/route_intake/close_intake) rather than silently 410ing
with no guidance.

tests/test_suggestions.py rewritten: verifies all 5 mutations 410,
GET/list still work for historical rows (seeded directly via the DB
session since creation is retired -- there's no other way to get
historical data into the table anymore), 404 still behaves normally on
unknown ids. Live-verified against the running dev API: POST 410s,
GET with include_terminal=true still returns all 10 migrated-and-declined
historical rows. No regressions: full repo suite green (563 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-21 01:51:59 +02:00
parent b564ac7046
commit 6c4fc64ef3
3 changed files with 136 additions and 257 deletions

View file

@ -12,7 +12,6 @@ from api.models.suggestion import (
SuggestionNote, SuggestionNote,
SuggestionStage, SuggestionStage,
) )
from api.models.task import Task, TaskPriority, TaskStatus
from api.schemas.suggestion import ( from api.schemas.suggestion import (
SuggestionBumpRelevance, SuggestionBumpRelevance,
SuggestionCreate, SuggestionCreate,
@ -22,16 +21,25 @@ from api.schemas.suggestion import (
SuggestionRead, SuggestionRead,
SuggestionVet, SuggestionVet,
) )
from api.services.suggestion_relevance import bump_relevance
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
from api.task_status import normalize_task_status
router = APIRouter(prefix="/suggestions", tags=["suggestions"]) router = APIRouter(prefix="/suggestions", tags=["suggestions"])
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
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: async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
row = await session.execute( row = await session.execute(
@ -66,14 +74,6 @@ async def _get_suggestion_or_404(
return suggestion return suggestion
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
if suggestion.stage not in allowed:
raise HTTPException(
status_code=409,
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
)
@router.get("/", response_model=list[SuggestionRead]) @router.get("/", response_model=list[SuggestionRead])
async def list_suggestions( async def list_suggestions(
domain: str | None = None, domain: str | None = None,
@ -105,23 +105,13 @@ async def create_suggestion(
body: SuggestionCreate, body: SuggestionCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SuggestionRead: ) -> SuggestionRead:
domain_id = await _resolve_domain_id(body.domain, session) """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
suggestion = Suggestion( legacy. Use `POST /intakes/` (kind: intake per
domain_id=domain_id, canon/standards/work-record-types_v0.1.md) instead a fresh entity,
topic_id=body.topic_id, not a rename of this table. The 10 records that were still open at
workplan_id=body.workplan_id, retirement were migrated to file-backed intake records; see
title=body.title, the-custodian/intake-legacy-suggestions-migration.md."""
description=body.description, raise _retired()
origin=body.origin,
origin_ref=body.origin_ref,
base_value=body.base_value,
job_size=body.job_size,
relevance_weight=body.relevance_weight,
)
session.add(suggestion)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}", response_model=SuggestionRead) @router.get("/{suggestion_id}", response_model=SuggestionRead)
@ -139,28 +129,8 @@ async def vet_suggestion(
body: SuggestionVet, body: SuggestionVet,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SuggestionRead: ) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session) """Retired (CUST-WP-0061-T06). See _retired()."""
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet") raise _retired()
suggestion.stage = SuggestionStage.requirement
if body.base_value is not None:
suggestion.base_value = body.base_value
if body.job_size is not None:
suggestion.job_size = body.job_size
if body.relevance_weight is not None:
suggestion.relevance_weight = body.relevance_weight
if body.workplan_id is not None:
suggestion.workplan_id = body.workplan_id
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.requirement.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead) @router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
@ -169,20 +139,8 @@ async def decline_suggestion(
body: SuggestionDecline, body: SuggestionDecline,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SuggestionRead: ) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session) """Retired (CUST-WP-0061-T06). See _retired()."""
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline") raise _retired()
suggestion.stage = SuggestionStage.declined
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.declined.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead) @router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
@ -191,38 +149,8 @@ async def promote_suggestion_to_task(
body: SuggestionPromote, body: SuggestionPromote,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SuggestionRead: ) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session) """Retired (CUST-WP-0061-T06). See _retired()."""
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote") raise _retired()
if suggestion.workplan_id is None:
raise HTTPException(
status_code=409,
detail="Suggestion must have workplan_id before promotion",
)
task = Task(
workplan_id=suggestion.workplan_id,
title=body.task_title or suggestion.title,
description=body.task_description or suggestion.description,
status=TaskStatus(normalize_task_status(body.task_status)),
priority=TaskPriority(body.task_priority),
)
session.add(task)
await session.flush()
suggestion.stage = SuggestionStage.promoted
suggestion.promoted_task_id = task.id
if body.note:
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.promoted.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead) @router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
@ -231,18 +159,8 @@ async def bump_suggestion_relevance(
body: SuggestionBumpRelevance, body: SuggestionBumpRelevance,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SuggestionRead: ) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session) """Retired (CUST-WP-0061-T06). See _retired()."""
source_key = body.author or "explicit" raise _retired()
await bump_relevance(
session,
suggestion,
source="explicit",
source_key=source_key,
reason=body.reason,
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead]) @router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])

View file

@ -1269,7 +1269,10 @@ def create_suggestion(
base_value: float = 3.0, base_value: float = 3.0,
job_size: float = 3.0, job_size: float = 3.0,
) -> str: ) -> str:
"""Record a gated need as a relevance-accruing suggestion.""" """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. Use create_intake instead (kind: intake per canon/standards/
work-record-types_v0.1.md). This call now 410s server-side; kept only
so old callers get a clear error instead of a missing tool."""
return json.dumps(_post("/suggestions", { return json.dumps(_post("/suggestions", {
"domain": domain, "domain": domain,
"title": title, "title": title,
@ -1283,7 +1286,9 @@ def create_suggestion(
@mcp.tool() @mcp.tool()
def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str: def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
"""Promote a suggestion to a vetted requirement with an append-only note.""" """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. There is no vet/route equivalent needed for intake use
route_intake directly. This call now 410s server-side."""
return json.dumps(_post(f"/suggestions/{suggestion_id}/vet", { return json.dumps(_post(f"/suggestions/{suggestion_id}/vet", {
"note": note, "note": note,
"author": author, "author": author,
@ -1292,7 +1297,9 @@ def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) ->
@mcp.tool() @mcp.tool()
def decline_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str: def decline_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
"""Decline a suggestion or requirement.""" """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. Use close_intake(outcome="declined") instead. This call now
410s server-side."""
return json.dumps(_post(f"/suggestions/{suggestion_id}/decline", { return json.dumps(_post(f"/suggestions/{suggestion_id}/decline", {
"note": note, "note": note,
"author": author, "author": author,
@ -1306,7 +1313,10 @@ def promote_suggestion_to_task(
task_title: str | None = None, task_title: str | None = None,
author: str | None = None, author: str | None = None,
) -> str: ) -> str:
"""Promote a vetted requirement into a real Task.""" """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy. Use route_intake then the promote-intake CLI
(scripts/promote_intake.py --to task) instead. This call now 410s
server-side."""
return json.dumps(_post(f"/suggestions/{suggestion_id}/promote", { return json.dumps(_post(f"/suggestions/{suggestion_id}/promote", {
"note": note, "note": note,
"task_title": task_title, "task_title": task_title,
@ -1320,7 +1330,10 @@ def bump_suggestion_relevance(
reason: str | None = None, reason: str | None = None,
author: str | None = None, author: str | None = None,
) -> str: ) -> str:
"""Explicitly bump demand relevance when an agent hits an unmet gated need.""" """Retired (CUST-WP-0061-T06, 2026-07-21): suggestions are read-only
legacy relevance/WSJF scoring has no intake equivalent yet (ordering
is lane+priority+age per the founder-reviewed architecture draft).
This call now 410s server-side."""
return json.dumps(_post(f"/suggestions/{suggestion_id}/bump-relevance", { return json.dumps(_post(f"/suggestions/{suggestion_id}/bump-relevance", {
"reason": reason, "reason": reason,
"author": author, "author": author,

View file

@ -1,158 +1,106 @@
"""Demand-weighted suggestion backlog tests (STATE-WP-0061).""" """Suggestion (legacy) tests — CUST-WP-0061-T06, 2026-07-21.
The suggestions table/router is retired to read-only legacy: a fresh
`intake` work-record entity replaces it (canon/standards/
work-record-types_v0.1.md), not a rename. The 10 records still open at
retirement were migrated to file-backed intake records (see
the-custodian/intake-legacy-suggestions-migration.md) and declined here.
These tests verify the retirement itself: every mutation endpoint 410s;
GET/list still works for the historical record. Since creation is
retired, historical rows are seeded directly via the ORM/DB session
(bypassing the retired POST) rather than through the API.
"""
from __future__ import annotations from __future__ import annotations
import uuid
import pytest import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from tests.conftest import create_test_repo, create_test_workplan from tests.test_capability_requests import _create_domain
from tests.test_capability_requests import _create_domain, _create_topic
async def _create_suggestion(client, **kwargs): async def _seed_suggestion(test_engine, *, domain_slug: str, **overrides) -> str:
payload = { """Insert a Suggestion row directly, bypassing the retired POST
"domain": "custodian", endpoint this is now the only way historical rows get created."""
"title": "Issue-core ingestion API key path", from api.models.domain import Domain
"description": "OpenBao KV path for issue-core ingestion", from api.models.suggestion import Suggestion
"origin_ref": "issue-core-ingestion-api-key", from sqlalchemy import select
"base_value": 4.0,
"job_size": 2.0, factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
} async with factory() as session:
payload.update(kwargs) domain_id = (
r = await client.post("/suggestions/", json=payload) await session.execute(select(Domain.id).where(Domain.slug == domain_slug))
assert r.status_code == 201, r.text ).scalar_one()
return r.json() suggestion = Suggestion(
domain_id=domain_id,
title=overrides.get("title", "Legacy suggestion"),
description=overrides.get("description"),
origin=overrides.get("origin"),
origin_ref=overrides.get("origin_ref"),
base_value=overrides.get("base_value", 3.0),
job_size=overrides.get("job_size", 3.0),
)
session.add(suggestion)
await session.commit()
await session.refresh(suggestion)
return str(suggestion.id)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_list_and_wsjf_ranking(client): class TestSuggestionsRetired:
await _create_domain(client, "custodian", "Custodian") async def test_create_returns_410(self, client):
low = await _create_suggestion( await _create_domain(client, "custodian", "Custodian")
client, r = await client.post("/suggestions/", json={
title="Low priority path", "domain": "custodian",
origin_ref="low-priority", "title": "New suggestion attempt",
base_value=1.0, })
job_size=5.0, assert r.status_code == 410
) assert "intake" in r.json()["detail"].lower()
high = await _create_suggestion(
client,
title="High priority path",
origin_ref="high-priority",
base_value=5.0,
job_size=1.0,
)
r = await client.get("/suggestions/?rank=wsjf") async def test_vet_returns_410(self, client, test_engine):
assert r.status_code == 200 await _create_domain(client, "custodian", "Custodian")
ranked = r.json() sid = await _seed_suggestion(test_engine, domain_slug="custodian")
assert ranked[0]["id"] == high["id"] r = await client.post(f"/suggestions/{sid}/vet", json={"note": "x"})
assert ranked[0]["wsjf"] > ranked[1]["wsjf"] assert r.status_code == 410
await client.post( async def test_decline_returns_410(self, client, test_engine):
f"/suggestions/{low['id']}/bump-relevance", await _create_domain(client, "custodian", "Custodian")
json={"reason": "hit again", "author": "agent-a"}, sid = await _seed_suggestion(test_engine, domain_slug="custodian")
) r = await client.post(f"/suggestions/{sid}/decline", json={"note": "x"})
await client.post( assert r.status_code == 410
f"/suggestions/{low['id']}/bump-relevance",
json={"reason": "hit again", "author": "agent-b"},
)
r2 = await client.get(f"/suggestions/{low['id']}")
assert r2.json()["relevance"] == 2
async def test_promote_returns_410(self, client, test_engine):
await _create_domain(client, "custodian", "Custodian")
sid = await _seed_suggestion(test_engine, domain_slug="custodian")
r = await client.post(f"/suggestions/{sid}/promote", json={})
assert r.status_code == 410
@pytest.mark.asyncio async def test_bump_relevance_returns_410(self, client, test_engine):
async def test_bump_relevance_debounces_duplicate_explicit_bumps(client): await _create_domain(client, "custodian", "Custodian")
await _create_domain(client, "custodian", "Custodian") sid = await _seed_suggestion(test_engine, domain_slug="custodian")
suggestion = await _create_suggestion(client) r = await client.post(f"/suggestions/{sid}/bump-relevance", json={})
first = await client.post( assert r.status_code == 410
f"/suggestions/{suggestion['id']}/bump-relevance",
json={"reason": "routing gap", "author": "codex"},
)
second = await client.post(
f"/suggestions/{suggestion['id']}/bump-relevance",
json={"reason": "routing gap", "author": "codex"},
)
assert first.status_code == 200
assert second.status_code == 200
refreshed = await client.get(f"/suggestions/{suggestion['id']}")
assert refreshed.json()["relevance"] == 1
async def test_get_and_list_still_work_for_historical_rows(self, client, test_engine):
await _create_domain(client, "custodian", "Custodian")
sid = await _seed_suggestion(
test_engine, domain_slug="custodian",
title="Historical gated-need signal",
origin="WARDEN-WP-0012", origin_ref="example",
)
@pytest.mark.asyncio r = await client.get(f"/suggestions/{sid}")
async def test_vet_decline_and_promote_flow(client): assert r.status_code == 200
await _create_domain(client, "custodian", "Custodian") assert r.json()["title"] == "Historical gated-need signal"
topic = await _create_topic(client, "custodian")
repo = await create_test_repo(client, domain_slug="custodian", slug="state-hub")
workplan = await create_test_workplan(
client, repo_id=repo["id"], topic_id=topic["id"], slug="state-wp-0061", title="WP-0061",
)
suggestion = await _create_suggestion( r2 = await client.get("/suggestions/")
client, assert r2.status_code == 200
workplan_id=workplan["id"], assert any(s["id"] == sid for s in r2.json())
title="Promotable gated need",
origin_ref="promote-me",
)
vet = await client.post( async def test_unknown_id_still_404s_not_410(self, client):
f"/suggestions/{suggestion['id']}/vet", """Read paths keep their normal semantics; only mutations are
json={"note": "Vetted as requirement", "author": "codex"}, blanket-retired."""
) r = await client.get(f"/suggestions/{uuid.uuid4()}")
assert vet.status_code == 200 assert r.status_code == 404
assert vet.json()["stage"] == "requirement"
bad_promote = await client.post(
f"/suggestions/{suggestion['id']}/promote",
json={"note": "too early"},
)
assert bad_promote.status_code == 200
promoted = bad_promote.json()
assert promoted["stage"] == "promoted"
assert promoted["promoted_task_id"] is not None
task = await client.get(f"/tasks/{promoted['promoted_task_id']}")
assert task.status_code == 200
assert task.json()["title"] == "Promotable gated need"
fresh = await _create_suggestion(client, title="Decline me", origin_ref="decline-me")
declined = await client.post(
f"/suggestions/{fresh['id']}/decline",
json={"note": "Not needed", "author": "codex"},
)
assert declined.status_code == 200
assert declined.json()["stage"] == "declined"
illegal = await client.post(
f"/suggestions/{fresh['id']}/vet",
json={"note": "too late"},
)
assert illegal.status_code == 409
@pytest.mark.asyncio
async def test_next_steps_surfaces_and_bumps_suggestions(client):
await _create_domain(client, "custodian", "Custodian")
await _create_suggestion(client, title="Surfaced need", origin_ref="surfaced-need")
before = await client.get("/suggestions/?origin_ref=surfaced-need")
# no filter by origin_ref on list - get all and find
all_items = await client.get("/suggestions/")
item = next(i for i in all_items.json() if i["origin_ref"] == "surfaced-need")
assert item["relevance"] == 0
steps = await client.get("/state/next_steps")
assert steps.status_code == 200
payload = steps.json()
assert any(s["type"] == "open_suggestion" for s in payload)
after = await client.get(f"/suggestions/{item['id']}")
assert after.json()["relevance"] >= 1
@pytest.mark.asyncio
async def test_summary_includes_ranked_suggestions(client):
await _create_domain(client, "custodian", "Custodian")
await _create_suggestion(client, title="Summary ranked", origin_ref="summary-ranked")
summary = await client.get("/state/summary")
assert summary.status_code == 200
data = summary.json()
assert "ranked_suggestions" in data
assert any(s["origin_ref"] == "summary-ranked" for s in data["ranked_suggestions"])