§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
+114
View File
@@ -514,6 +514,120 @@ def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
"""§13.2 (optional number): a blank id is VALID — it means "graduate
without a number." `can_submit` stays true (owners are set), and an
absent `id` param behaves the same as an explicit empty string."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Explicit empty id.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
assert d["id"]["ok"] is True
assert d["id"]["error"] is None
assert d["can_submit"] is True
# No id param at all — same default-accepted shape.
d = client.get("/api/rfcs/ohm/graduate/check").json()
assert d["id"]["ok"] is True
assert d["can_submit"] is True
# A malformed (non-blank) id is still rejected.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
entry to `active` with `id: null`. The slug is the canonical identifier;
the catalog shows the entry as active with no number, and the audit row
records rfc_id null."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"], arbiters=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Blank rfc_id → graduate without a number.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["succeeded"] is True
assert d["rfc_id"] is None
# Meta entry: active, id null, body kept, graduation stamped.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at
assert "Open Human Model is a framework" in graduated.body
# Cache flipped to active with a null rfc_id.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] is None
# Catalog: present as active, identified by slug (no number).
items = client.get("/api/rfcs").json()["items"]
ohm = next(i for i in items if i["slug"] == "ohm")
assert ohm["state"] == "active"
assert ohm["id"] is None
# Audit: graduate_complete with rfc_id null.
complete = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
ORDER BY id DESC LIMIT 1
"""
).fetchone()
assert complete is not None
assert _json.loads(complete["details"])["rfc_id"] is None
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
"""Omitting the rfc_id field entirely is treated the same as blank —
graduates without a number — so older clients that drop the field don't
break, and the supplied-number path stays exactly as before."""
from fastapi.testclient import TestClient
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
assert r.status_code == 200, r.text
assert r.json()["rfc_id"] is None
graduated = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert graduated.state == "active"
assert graduated.id is None
def test_claim_opens_meta_pr(app_with_fake_gitea):
"""§13.1: any signed-in contributor can claim ownership of an
unclaimed super-draft; the result is a meta-repo PR
+257
View File
@@ -0,0 +1,257 @@
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
Retire is an in-place frontmatter flip on the meta entry (state
`retired`) committed via an auto-merged PR the same machinery as
graduation, reused. The distinguishing rules under test:
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
holders may retire; app admins may NOT. Un-retire is site-owners-only.
* Visibility (§13.7): a retired entry drops out of the catalog
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
a site owner (so the un-retire affordance has a surface).
* Reversibility: a site owner can un-retire, restoring the prior state
(and keeping the integer id intact); an RFC owner cannot.
These walk against the in-process FakeGitea from test_propose_vertical.py,
reusing the super-draft seed + sync graduation seam from the graduation
suite.
"""
from __future__ import annotations
import json as _json
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
def _catalog_slugs(client) -> set[str]:
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
"""An RFC owner (frontmatter, role contributor) retires their own
super-draft. The entry flips to `retired`, drops out of the catalog,
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"], arbiters=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert "ohm" in _catalog_slugs(client)
r = client.post("/api/rfcs/ohm/retire")
assert r.status_code == 200, r.text
assert r.json()["state"] == "retired"
# Meta entry on main: state retired, body + fields kept.
meta = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert meta.state == "retired"
assert "carol" in meta.owners
# Cache flipped; gone from the catalog.
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "retired"
assert "ohm" not in _catalog_slugs(client)
# The RFC owner is NOT a site owner → 404 on the entry read.
assert client.get("/api/rfcs/ohm").status_code == 404
# Audit row records the prior state for un-retire.
row = db.conn().execute(
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert _json.loads(row["details"])["prior_state"] == "super-draft"
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
(so the un-retire UI has a surface); an admin, a non-owner contributor,
and an anonymous viewer all get 404."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="carol", role="contributor")
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Site owner: 200.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm")
assert r.status_code == 200, r.text
assert r.json()["state"] == "retired"
# Admin (not site owner): 404.
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
assert client.get("/api/rfcs/ohm").status_code == 404
# Non-owner contributor: 404.
provision_user_row(user_id=4, login="erin", role="contributor")
sign_in_as(client, user_id=4, gitea_login="erin",
display_name="Erin", role="contributor")
assert client.get("/api/rfcs/ohm").status_code == 404
def test_admin_cannot_retire(app_with_fake_gitea):
"""§3.1: retire authority excludes app admins. An admin who is not an
RFC owner gets 403 the one lifecycle action where admin authority
does not apply."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
r = client.post("/api/rfcs/ohm/retire")
assert r.status_code == 403, r.text
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
"""A signed-in contributor who is not an RFC owner gets 403."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=4, login="erin", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=4, gitea_login="erin",
display_name="Erin", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 403
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
"""Round-trip on an active RFC: graduate (with a number) → retire →
un-retire. The integer id survives, and un-retire restores `active`."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"], arbiters=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Graduate with a number.
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
# Retire (site owner).
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
assert "ohm" not in _catalog_slugs(client)
# Un-retire (site owner) restores active, id intact.
r = client.post("/api/rfcs/ohm/unretire")
assert r.status_code == 200, r.text
assert r.json()["state"] == "active"
meta = entry_mod.parse(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
)
assert meta.state == "active"
assert meta.id == "RFC-0042"
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert "ohm" in _catalog_slugs(client)
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
bring it back (the soft-delete is recoverable only by the operator)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Same RFC owner tries to un-retire → 403.
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
an admin (who lacks un-retire authority) gets 403."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="carol", role="contributor")
provision_user_row(user_id=3, login="dave", role="admin")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# Admin: 403.
sign_in_as(client, user_id=3, gitea_login="dave",
display_name="Dave", role="admin")
assert client.get("/api/admin/retired-rfcs").status_code == 403
# Site owner: 200, sees ohm with its restore target.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/admin/retired-rfcs")
assert r.status_code == 200, r.text
items = r.json()["items"]
ohm = next(i for i in items if i["slug"] == "ohm")
assert ohm["restores_to"] == "super-draft"
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
"""A retired entry refuses content reads of every shape (§13.7) — the
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="carol", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["carol"])
sign_in_as(client, user_id=2, gitea_login="carol",
display_name="Carol", role="contributor")
assert client.post("/api/rfcs/ohm/retire").status_code == 200
# The /main branch surface no longer serves it.
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)