§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
+8 -1
View File
@@ -657,12 +657,19 @@ def make_router(
return {"items": items}
@router.get("/api/rfcs/{slug}")
async def get_rfc(slug: str) -> dict[str, Any]:
async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
# §13.7: a retired entry is removed from every browsing surface.
# The sole exception is a site owner, so the un-retire affordance
# has somewhere to live; everyone else gets a plain 404.
if row["state"] == "retired":
viewer = auth.current_user(request)
if viewer is None or viewer.role != "owner":
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
+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")
+4
View File
@@ -256,6 +256,10 @@ def _require_rfc_readable(slug: str):
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
if row["state"] == "retired":
raise HTTPException(404, "RFC not found")
return row
+227 -13
View File
@@ -19,7 +19,10 @@ Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim) and the
§13.7 retire / un-retire endpoints (POST /api/rfcs/<slug>/retire,
POST /api/rfcs/<slug>/unretire) soft-delete and its site-owner-only
reversal, each a single in-place frontmatter flip via an auto-merged PR.
The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
@@ -74,7 +77,7 @@ class StepState:
@dataclass
class GraduationState:
slug: str
rfc_id: str
rfc_id: str | None # null when graduating without a number (§13.2)
owners: list[str]
arbiters: list[str]
steps: list[StepState]
@@ -113,7 +116,7 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str,
def _new_active(slug: str, *, rfc_id: str | None,
owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState(
slug=slug, rfc_id=rfc_id,
@@ -164,7 +167,12 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40)
# §13.2: the integer id is OPTIONAL. Blank/absent → graduate with no
# number (id stays null, the slug is the canonical identifier per
# §2.3). When supplied it must match ^RFC-\d{4,}$ and be free — both
# are checked in the handler, not by the field bound (an empty string
# is a legitimate value here).
rfc_id: str | None = Field(default=None, max_length=40)
owners: list[str] = Field(min_length=1)
@@ -257,14 +265,19 @@ def make_router(
candidate_id = (request.query_params.get("id") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]")
# ID field
# ID field — §13.2: the integer id is OPTIONAL. A blank id is
# valid and means "graduate without a number" (id stays null, the
# slug is canonical per §2.3). Only a *non-blank* id is held to the
# RFC-NNNN regex and the collision check.
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
if not candidate_id:
id_payload["ok"] = False
id_payload["error"] = "Integer ID is required"
pass # blank is the default-accepted case, not an error
elif not _is_valid_rfc_id(candidate_id):
id_payload["ok"] = False
id_payload["error"] = "ID must look like RFC-NNNN (at least four digits)"
id_payload["error"] = (
"ID must look like RFC-NNNN (at least four digits), "
"or leave blank to graduate without a number"
)
elif _rfc_id_taken(candidate_id, excluding_slug=slug):
id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
@@ -315,14 +328,18 @@ def make_router(
# §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race on the integer ID.
rfc_id = body.rfc_id.strip()
# The id is OPTIONAL: a blank/absent id graduates with no number
# (id stays null, slug is canonical per §2.3). Only a supplied id
# is held to the regex and the collision re-check.
rfc_id = (body.rfc_id or "").strip() or None
owners = [o.strip() for o in body.owners if o.strip()]
if not owners:
raise HTTPException(422, "Add at least one initial owner")
if not _is_valid_rfc_id(rfc_id):
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if rfc_id is not None:
if not _is_valid_rfc_id(rfc_id):
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
# Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the body to carry through
@@ -482,6 +499,70 @@ def make_router(
await cache.refresh_meta_pulls(config, gitea)
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]}
# -------------------------------------------------------------------
# §13.7: POST /api/rfcs/<slug>/retire
# Soft-delete an entry — flip its frontmatter `state` to `retired` via
# an auto-merged meta-repo PR (§13.3 machinery reused). RFC owners and
# site owners only — NOT app admins (§3.1). Allowed from super-draft or
# active; the body and every other field (including the integer id) are
# kept, so the entry stays recoverable in git and un-retire restores it
# exactly.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/retire")
async def retire_rfc(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_retirable(slug)
if not _can_retire(rfc, viewer):
raise HTTPException(
403, "Only this RFC's owners or a site owner may retire it"
)
prior_state = rfc["state"]
entry, sha = await _read_meta_entry(slug)
entry.state = "retired"
await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
verb="retire", target_state="retired",
)
_audit(
viewer.user_id, viewer.gitea_login, "retire",
rfc_slug=slug,
details={"prior_state": prior_state, "rfc_id": entry.id},
)
await _refresh_catalog()
return {"ok": True, "slug": slug, "state": "retired"}
# -------------------------------------------------------------------
# §13.7: POST /api/rfcs/<slug>/unretire
# Bring a retired entry back to the state it held before retirement.
# Site owners only (§3.1) — tighter than retire itself, so a soft-delete
# is always recoverable by the operator but an RFC owner cannot reverse
# their own retirement.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/unretire")
async def unretire_rfc(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
if viewer.role != "owner":
raise HTTPException(403, "Only a site owner may un-retire an RFC")
_require_retired(slug)
restored = _prior_state_before_retire(slug)
entry, sha = await _read_meta_entry(slug)
entry.state = restored
await _run_state_flip(
config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(),
slug=slug, new_contents=entry_mod.serialize(entry), prior_sha=sha,
verb="unretire", target_state=restored,
)
_audit(
viewer.user_id, viewer.gitea_login, "unretire",
rfc_slug=slug,
details={"restored_state": restored},
)
await _refresh_catalog()
return {"ok": True, "slug": slug, "state": restored}
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
@@ -494,6 +575,45 @@ def make_router(
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
def _require_retirable(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] not in ("super-draft", "active"):
raise HTTPException(409, f"RFC is {row['state']}, cannot be retired")
return row
def _require_retired(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] != "retired":
raise HTTPException(409, f"RFC is {row['state']}, not retired")
return row
async def _read_meta_entry(slug: str) -> tuple[entry_mod.Entry, str]:
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
if fetched is None:
raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main")
text, file_sha = fetched
try:
return entry_mod.parse(text), file_sha
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
async def _refresh_catalog() -> None:
# Inline refresh so the catalog reflects the flip immediately; the
# webhook/reconciler path is the steady-state per §4.1. A refresh
# failure does not unwind the merge — the reconciler catches up.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
except Exception as e:
log.warning("retire/unretire cache refresh failed for slug: %s", e)
return router
@@ -673,6 +793,100 @@ def _can_graduate(rfc, viewer) -> bool:
return viewer.gitea_login in owners or viewer.gitea_login in arbiters
def _can_retire(rfc, viewer) -> bool:
"""§3.1: retire is narrower than graduate/withdraw — RFC owners
(frontmatter) and site `owner`-role holders only, NOT app admins."""
if viewer is None:
return False
if viewer.role == "owner": # site owner
return True
owners = json.loads(rfc["owners_json"] or "[]")
return viewer.gitea_login in owners
def _prior_state_before_retire(slug: str) -> str:
"""§13.7: the state to restore on un-retire. Read it from the most
recent `retire` audit row's `prior_state`; if that's missing (e.g. the
entry was retired out of band), fall back to inferring from the cached
row an assigned integer id implies it was `active`, else `super-draft`.
"""
row = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = ? AND action_kind = 'retire'
ORDER BY id DESC LIMIT 1
""",
(slug,),
).fetchone()
if row and row["details"]:
try:
prior = json.loads(row["details"]).get("prior_state")
if prior in ("super-draft", "active"):
return prior
except (ValueError, TypeError):
pass
cached = db.conn().execute(
"SELECT rfc_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
return "active" if (cached and cached["rfc_id"]) else "super-draft"
async def _run_state_flip(
*,
config: Config,
gitea: Gitea,
bot: Bot,
actor: Actor,
slug: str,
new_contents: str,
prior_sha: str,
verb: str,
target_state: str,
) -> None:
"""§13.7: open + merge a retire / un-retire frontmatter flip PR. Runs
inline (no SSE the flip is a single quick state change, unlike the
multi-step graduation that streams progress). On an open failure
nothing was created; on a merge failure the half-open PR/branch is
cleaned up. Either failure raises 502 so the caller does not record the
transition as having happened."""
try:
pr = await bot.open_retire_flip_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
slug=slug, new_file_contents=new_contents, prior_sha=prior_sha,
verb=verb, target_state=target_state,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
pr_number = pr["number"]
head_branch = pr["head"]["ref"]
try:
await bot.merge_retire_flip_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=pr_number, head_branch=head_branch,
slug=slug, verb=verb,
)
except GiteaError as e:
# Clean up the unmerged flip PR/branch so failed attempts don't
# accumulate (mirrors graduation's `_cleanup_unmerged`).
try:
await bot.close_graduation_pr(
actor, org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=pr_number, head_branch=head_branch,
slug=slug, reason=f"{verb} merge failed",
)
await bot.delete_branch(
actor, owner=config.gitea_org, repo=config.meta_repo,
branch=head_branch, slug=slug,
action_kind="delete_post_merge_branch",
reason=f"{verb} merge failed",
)
except Exception:
log.exception("%s cleanup failed for %s", verb, slug)
raise HTTPException(502, f"Gitea: {e.detail}")
def _audit(
actor_user_id: int | None,
on_behalf_of: str | None,
+113 -11
View File
@@ -706,22 +706,24 @@ class Bot:
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
rfc_id: str | None,
owners: list[str],
) -> dict:
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the integer `id` and
graduation stamps **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). Branch name
uses the `graduate-<slug>-<6hex>` shape dash-separated like the
other meta-repo branches per the §19.2 path-routing candidate.
entry's frontmatter to `state: active` with the graduation stamps
and **optionally** the integer `id`, **keeping the body
unchanged** (§1 meta-only topology; no repo is created and no body
is stripped). When `rfc_id` is None the entry graduates without a
number (id stays null, slug is canonical per §2.3, §13.2). Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
commit_subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
@@ -736,11 +738,12 @@ class Bot:
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
pr_title = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
owners_str = ", ".join(owners) if owners else "(none)"
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"{id_line}"
f"- Owners: {owners_str}\n\n"
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
@@ -772,7 +775,7 @@ class Bot:
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
rfc_id: str | None,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
@@ -788,7 +791,7 @@ class Bot:
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}"
subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
body = _trailer(actor)
try:
@@ -809,6 +812,105 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
async def open_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
verb: str,
target_state: str,
) -> dict:
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
<target_state>` for retire (`verb='retire'`, target `retired`)
or un-retire (`verb='unretire'`, target the restored prior state).
Only the frontmatter `state` changes; the body and every other
field (including the integer `id`) are kept, so an un-retire
restores the entry exactly. Branch shape mirrors graduation's
`<verb>-<slug>-<6hex>`.
"""
import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
verb_title = "Retire" if verb == "retire" else "Un-retire"
commit_subject = f"{verb_title} {slug}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"{verb_title} {slug}"
pr_body_text = (
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
f"only `state` changes."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
f"{verb}_pr_open",
rfc_slug=slug,
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "target_state": target_state},
)
return pr
async def merge_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
verb: str,
) -> None:
"""§13.7: auto-merge the retire / un-retire flip PR (same
mergeable-wait + retry shape as `merge_graduation_pr`)."""
verb_title = "Retire" if verb == "retire" else "Un-retire"
subject = f"{verb_title} {slug}"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
f"{verb}_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
+1 -1
View File
@@ -30,7 +30,7 @@ _ABSENT = object()
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""