§13: make graduation's RFC number optional; add retire (soft delete)

Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).

Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).

Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-01 17:22:49 -07:00
parent 0062510a4e
commit 49ba06e0c2
18 changed files with 1165 additions and 66 deletions
+51
View File
@@ -829,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked}
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
@router.get("/api/admin/retired-rfcs")
async def retired_rfcs(request: Request) -> dict[str, Any]:
# Retired entries are hidden from every browsing surface (§13.7);
# this owner-gated list is how a site owner discovers them to
# un-retire. Admins do not get this surface — un-retire authority is
# site-owner-only, so neither is the list that feeds it.
viewer = auth.require_admin(request)
if viewer.role != "owner":
raise HTTPException(403, "Site owner role required")
rows = db.conn().execute(
"""
SELECT slug, title, rfc_id, owners_json, tags_json,
proposed_at, updated_at
FROM cached_rfcs
WHERE state = 'retired'
ORDER BY updated_at DESC
"""
).fetchall()
items = []
for r in rows:
# The state it would return to on un-retire, mirroring
# api_graduation._prior_state_before_retire's audit lookup.
prior = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = ? AND action_kind = 'retire'
ORDER BY id DESC LIMIT 1
""",
(r["slug"],),
).fetchone()
restored_state = None
if prior and prior["details"]:
try:
restored_state = json.loads(prior["details"]).get("prior_state")
except (ValueError, TypeError):
restored_state = None
if restored_state not in ("super-draft", "active"):
restored_state = "active" if r["rfc_id"] else "super-draft"
items.append({
"slug": r["slug"],
"title": r["title"],
"id": r["rfc_id"],
"owners": json.loads(r["owners_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"retired_at": r["updated_at"],
"restores_to": restored_state,
})
return {"items": items}
# ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search")