CUST-WP-0061-T06: retire suggestions to read-only legacy
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:
parent
b564ac7046
commit
6c4fc64ef3
3 changed files with 136 additions and 257 deletions
|
|
@ -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
|
||||
|
||||
import uuid
|
||||
|
||||
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, _create_topic
|
||||
from tests.test_capability_requests import _create_domain
|
||||
|
||||
|
||||
async def _create_suggestion(client, **kwargs):
|
||||
payload = {
|
||||
"domain": "custodian",
|
||||
"title": "Issue-core ingestion API key path",
|
||||
"description": "OpenBao KV path for issue-core ingestion",
|
||||
"origin_ref": "issue-core-ingestion-api-key",
|
||||
"base_value": 4.0,
|
||||
"job_size": 2.0,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
r = await client.post("/suggestions/", json=payload)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
async def _seed_suggestion(test_engine, *, domain_slug: str, **overrides) -> str:
|
||||
"""Insert a Suggestion row directly, bypassing the retired POST
|
||||
endpoint — this is now the only way historical rows get created."""
|
||||
from api.models.domain import Domain
|
||||
from api.models.suggestion import Suggestion
|
||||
from sqlalchemy import select
|
||||
|
||||
factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
domain_id = (
|
||||
await session.execute(select(Domain.id).where(Domain.slug == domain_slug))
|
||||
).scalar_one()
|
||||
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
|
||||
async def test_create_list_and_wsjf_ranking(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
low = await _create_suggestion(
|
||||
client,
|
||||
title="Low priority path",
|
||||
origin_ref="low-priority",
|
||||
base_value=1.0,
|
||||
job_size=5.0,
|
||||
)
|
||||
high = await _create_suggestion(
|
||||
client,
|
||||
title="High priority path",
|
||||
origin_ref="high-priority",
|
||||
base_value=5.0,
|
||||
job_size=1.0,
|
||||
)
|
||||
class TestSuggestionsRetired:
|
||||
async def test_create_returns_410(self, client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
r = await client.post("/suggestions/", json={
|
||||
"domain": "custodian",
|
||||
"title": "New suggestion attempt",
|
||||
})
|
||||
assert r.status_code == 410
|
||||
assert "intake" in r.json()["detail"].lower()
|
||||
|
||||
r = await client.get("/suggestions/?rank=wsjf")
|
||||
assert r.status_code == 200
|
||||
ranked = r.json()
|
||||
assert ranked[0]["id"] == high["id"]
|
||||
assert ranked[0]["wsjf"] > ranked[1]["wsjf"]
|
||||
async def test_vet_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}/vet", json={"note": "x"})
|
||||
assert r.status_code == 410
|
||||
|
||||
await client.post(
|
||||
f"/suggestions/{low['id']}/bump-relevance",
|
||||
json={"reason": "hit again", "author": "agent-a"},
|
||||
)
|
||||
await client.post(
|
||||
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_decline_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}/decline", json={"note": "x"})
|
||||
assert r.status_code == 410
|
||||
|
||||
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_debounces_duplicate_explicit_bumps(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
suggestion = await _create_suggestion(client)
|
||||
first = await client.post(
|
||||
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_bump_relevance_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}/bump-relevance", json={})
|
||||
assert r.status_code == 410
|
||||
|
||||
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
|
||||
async def test_vet_decline_and_promote_flow(client):
|
||||
await _create_domain(client, "custodian", "Custodian")
|
||||
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",
|
||||
)
|
||||
r = await client.get(f"/suggestions/{sid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == "Historical gated-need signal"
|
||||
|
||||
suggestion = await _create_suggestion(
|
||||
client,
|
||||
workplan_id=workplan["id"],
|
||||
title="Promotable gated need",
|
||||
origin_ref="promote-me",
|
||||
)
|
||||
r2 = await client.get("/suggestions/")
|
||||
assert r2.status_code == 200
|
||||
assert any(s["id"] == sid for s in r2.json())
|
||||
|
||||
vet = await client.post(
|
||||
f"/suggestions/{suggestion['id']}/vet",
|
||||
json={"note": "Vetted as requirement", "author": "codex"},
|
||||
)
|
||||
assert vet.status_code == 200
|
||||
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"])
|
||||
async def test_unknown_id_still_404s_not_410(self, client):
|
||||
"""Read paths keep their normal semantics; only mutations are
|
||||
blanket-retired."""
|
||||
r = await client.get(f"/suggestions/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue