§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
@@ -23,6 +23,57 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.32.0 — 2026-06-01
**Minor — graduation's integer RFC number is now optional, and RFC
owners + site owners can retire (soft-delete) RFCs. Schema migration
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
applies the UI.**
Two changes to the §13 lifecycle:
1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
field is now optional: it is still pre-filled with the next free
number as a *suggestion*, but the graduating owner may clear it and
graduate with **no number**. When blank, the entry flips to `active`
with `id: null` and the **slug remains the canonical identifier**
(§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
`POST …/graduate` accepts a blank/absent `rfc_id` and only validates
the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
The catalog and RFC view render number-less active entries by their
slug/title (no "RFC-undefined"). RFC-0001's existing number is
grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
migration was needed for this part.
2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
state, `retired`, is added. An RFC's own owners (frontmatter) and site
`owner`-role holders — **not** app admins — may retire an entry
(`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
auto-merged meta-repo PR, leaving the body and every other field
(including any integer id) intact. A retired entry is removed from
**every** browsing surface: the catalog, the RFC/discussion/branch
views (404), and link pickers/search. It is *not* hard-deleted — the
entry stays in `rfcs/` (git is truth). Un-retire
(`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
**site-owner-only**, so a soft-delete is always recoverable by the
operator but an RFC owner cannot reverse their own retirement. Site
owners find retired entries via a new owner-gated admin surface
(`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
there or by navigating directly to the entry.
Upgrade steps:
- **MUST** run the migration. `025_retired_state.sql` rebuilds
`cached_rfcs` to widen its `state` CHECK constraint to include
`retired` (SQLite cannot alter a CHECK in place). It runs automatically
at startup via the forward-only migration runner; existing rows are
preserved, and the table is reconstructible from Gitea by the
reconciler regardless (§4). No operator action beyond a normal deploy.
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
Retire affordance, and the owner-only "Retired" admin tab are present.
- No config or secret changes.
## 0.31.4 — 2026-06-01 ## 0.31.4 — 2026-06-01
**Patch — bug fix + UI polish: secondary buttons that were invisible on **Patch — bug fix + UI polish: secondary buttons that were invisible on
+99 -26
View File
@@ -160,19 +160,31 @@ an idea costs nothing in identifier space.
## 3. RFC states ## 3. RFC states
There are three canonical states stored in entry frontmatter: There are four canonical states stored in entry frontmatter:
- **`super-draft`** — the entry exists in the meta repo's `rfcs/` - **`super-draft`** — the entry exists in the meta repo's `rfcs/`
directory. Anyone signed in can chat on it; anyone can claim ownership; directory. Anyone signed in can chat on it; anyone can claim ownership;
an owner is required before graduation. an owner is required before graduation.
- **`active`** — the entry has been graduated: it carries an integer - **`active`** — the entry has been graduated: it carries
`id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the `graduated_at`/`graduated_by` and, **optionally**, an integer `id`
same `rfcs/<slug>.md` entry it always did — graduation is an in-place (`RFC-NNNN`). The integer id is not required — graduation may proceed
state flip (§13), not a move. Branches, PRs, and conversation happen on with no number, in which case `id` stays null and the **slug remains
the meta repo against that entry, exactly as they did while it was a the canonical identifier** (§2.3, §13.2). It lives in the same
`rfcs/<slug>.md` entry it always did — graduation is an in-place state
flip (§13), not a move. Branches, PRs, and conversation happen on the
meta repo against that entry, exactly as they did while it was a
super-draft. `repo:` stays null (§1). super-draft. `repo:` stays null (§1).
- **`withdrawn`** — pulled before becoming canonical. Stays in the - **`withdrawn`** — pulled before becoming canonical. Stays in the
directory as historical record, hidden from default views, filterable in. directory as historical record, hidden from default views, filterable in.
- **`retired`** — soft-deleted. The entry stays in the `rfcs/` directory
as historical record (git is truth — nothing is hard-deleted), but it
is **removed from every UI surface**: it does not appear in the
catalog, is not addressable through the ordinary RFC view, and is
excluded from link pickers and search. Unlike `withdrawn` (which is
merely hidden-by-default and filterable back in), `retired` is not
surfaced anywhere a contributor browses. Retiring is for entries an
owner wants gone from the working set; it is reversible only by a site
owner (§13.7).
A fourth concept — **`idea`** — is not stored in frontmatter. It is the A fourth concept — **`idea`** — is not stored in frontmatter. It is the
*derived view* of "there is an open PR against the meta repo proposing *derived view* of "there is an open PR against the meta repo proposing
@@ -192,12 +204,28 @@ super-draft ──[graduate, owner or admin]─────▶ active
super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn super-draft ──[withdraw, proposer or owner/admin]──▶ withdrawn
active ──[withdraw, owner/admin]─────────────▶ withdrawn active ──[withdraw, owner/admin]─────────────▶ withdrawn
withdrawn ──[reopen, owner/admin]────────────▶ super-draft withdrawn ──[reopen, owner/admin]────────────▶ super-draft
super-draft ──[retire, RFC owner or site owner]──▶ retired
active ──[retire, RFC owner or site owner]───▶ retired
retired ──[un-retire, site owner]────────────▶ prior state
``` ```
Every transition is a commit to the meta repo. State history is auditable Every transition is a commit to the meta repo. State history is auditable
through `git log rfcs/<slug>.md`. The app maintains a separate audit log through `git log rfcs/<slug>.md`. The app maintains a separate audit log
for "who clicked the button" (see §6.5). for "who clicked the button" (see §6.5).
**Who may retire.** Retire (§13.7) is deliberately *narrower* than
withdraw: it is available to the RFC's own `owners` (frontmatter) and to
holders of the site **`owner`** role only — **not** to app admins. It is
the one destructive-feeling action in the lifecycle (the entry leaves the
working set entirely), so the authority to perform it is held closer than
withdraw's owner/admin set. Un-retire — bringing a retired entry back to
the state it held before — is held tighter still: **site owners only**.
An RFC owner can retire their own entry but cannot bring it back; that
asymmetry is intentional, so a soft-delete is always recoverable by the
site's operator. The entry file is never removed from `rfcs/`, so a
retired entry remains recoverable by editing frontmatter directly even if
the app surfaces were lost.
### 3.2 State change side-effects ### 3.2 State change side-effects
Changing state in the meta repo entry is the *only* required operation Changing state in the meta repo entry is the *only* required operation
@@ -2034,9 +2062,14 @@ broadening rather than a precondition for the proposer's own RFC.)
Clicking "Graduate" opens a small dialog with two editable fields: Clicking "Graduate" opens a small dialog with two editable fields:
- **Integer ID** — pre-filled as `max(existing integer IDs) + 1`, - **Integer ID****optional.** Pre-filled as `max(existing integer
formatted as `RFC-NNNN`. Editable to allow gap reservations but the IDs) + 1`, formatted as `RFC-NNNN`, purely as a *suggestion* the
default is just the next number. graduating owner may keep, change (to reserve gaps), or **clear
entirely**. Leaving it blank graduates the entry to `active` with no
number — `id` stays null and the slug remains the canonical identifier
(§2.3). The field's helper text states this: blank means "graduate
without a number." A blank id is the default-accepted case, not an
error.
- **Initial owners** — pre-filled from the entry's `owners:`, with an - **Initial owners** — pre-filled from the entry's `owners:`, with an
"add owner" picker. Must have at least one. "add owner" picker. Must have at least one.
@@ -2044,17 +2077,20 @@ Clicking "Graduate" opens a small dialog with two editable fields:
the meta-only topology.) the meta-only topology.)
Each field validates inline as the admin types, with a short Each field validates inline as the admin types, with a short
debounce, against the catalog cache and a regex integer-ID debounce, against the catalog cache and a regex. The integer-ID field
collision against existing IDs, the at-least-one-owner constraint on is valid when it is **blank** (graduate without a number) *or* matches
the picker. Errors render as a short line of text beneath the `^RFC-\d{4,}$` and is not already taken by another entry; the owners
offending field. The integer-ID collision check is re-issued picker enforces the at-least-one constraint. Errors render as a short
atomically server-side on confirm, since a concurrent graduation line of text beneath the offending field. The integer-ID collision
could land between dialog-open and submit. While any field is check is re-issued atomically server-side on confirm, since a
invalid, the confirm button is disabled and its tooltip names the concurrent graduation could land between dialog-open and submit (only
first blocker specifically — "Integer ID 42 is already taken," "Add relevant when a number was supplied — a blank id can never collide).
at least one initial owner" — the same grammar the precondition While any field is invalid, the confirm button is disabled and its
popover below uses, so the dialog and the gate read as one surface tooltip names the first blocker specifically — "Integer ID 42 is
rather than two competing styles. already taken," "Add at least one initial owner" — the same grammar the
precondition popover below uses, so the dialog and the gate read as one
surface rather than two competing styles. A missing number is **never**
a blocker.
The dialog's confirm button is also disabled when the entry has no The dialog's confirm button is also disabled when the entry has no
owners. The disabled button opens a small popover on hover or click owners. The disabled button opens a small popover on hover or click
@@ -2070,14 +2106,16 @@ kept, so they coexist with graduation.
Confirming the dialog runs a single operation as the bot: open a PR Confirming the dialog runs a single operation as the bot: open a PR
against the meta repo that re-serializes `rfcs/<slug>.md` with against the meta repo that re-serializes `rfcs/<slug>.md` with
`state: active`, `id: RFC-NNNN`, `graduated_at: <timestamp>`, `state: active`, `id: RFC-NNNN` **or `id: null` when no number was
`graduated_by: <admin username>`, and the `owners:` from the dialog — supplied**, `graduated_at: <timestamp>`, `graduated_by: <admin
**leaving the body unchanged** — then auto-merge it (the admin who username>`, and the `owners:` from the dialog — **leaving the body
clicked is the merge actor). The webhook flow updates the SQLite cache unchanged** — then auto-merge it (the admin who clicked is the merge
and the catalog row transitions per §7.2. actor). The webhook flow updates the SQLite cache and the catalog row
transitions per §7.2. When `id` is null, the catalog and RFC view
identify the entry by its slug (no "RFC-NNNN" chip is rendered).
``` ```
super-draft entry ──[graduate]──▶ same entry, state: active, id assigned super-draft entry ──[graduate]──▶ same entry, state: active, id assigned OR null
(body unchanged, repo: null, lives in rfcs/<slug>.md throughout) (body unchanged, repo: null, lives in rfcs/<slug>.md throughout)
``` ```
@@ -2144,6 +2182,41 @@ the canonical home in the app. RFC-0001 is therefore an ordinary
meta-only active RFC like any other; no grandfathered per-repo path meta-only active RFC like any other; no grandfathered per-repo path
remains in the code. remains in the code.
### 13.7 Retire (soft delete)
Retire takes an entry out of the working set without destroying it. It
is the same shape as the other lifecycle transitions — an in-place
frontmatter flip on the meta entry, committed via an auto-merged bot PR
(§13.3's machinery, reused) — but with two distinguishing rules:
- **Authority (§3.1).** Only the RFC's own `owners` (frontmatter) and
holders of the site `owner` role may retire. App admins may *not*
(this is the one lifecycle action where admin authority does not
apply). Un-retire is **site owners only**: an RFC owner can retire but
cannot reverse it, so every soft-delete remains recoverable by the
operator.
- **Visibility.** A `retired` entry is removed from **every** browsing
surface — the catalog (`GET /api/rfcs`), the ordinary RFC view (`GET
/api/rfcs/<slug>` returns 404 for everyone except a site owner, so the
un-retire affordance has somewhere to live), link pickers, and search.
This is stronger than `withdrawn`, which is merely hidden-by-default
and filterable back in.
The flip sets `state: retired` and leaves every other field — including
the integer `id`, if one was assigned — untouched, so an un-retire
restores the entry exactly as it was. Retire is allowed from
`super-draft` or `active`; the prior state is recorded in the audit log
(`retire` action) so un-retire (`unretire` action) can restore it.
Because the entry file is never removed from `rfcs/`, the meta repo
remains the source of truth and the reconciler reproduces the
`retired` state on every sweep.
A site owner finds retired entries through an admin surface (a
"Retired" list, owner-gated) and un-retires from there; the entry then
reappears in the catalog under its restored state. The integer `id`,
once assigned, is never reclaimed or reissued by retire/un-retire
(gap-tolerant allocation per §2.3).
--- ---
## 14. Outside the RFC view: landing and the philosophy surface ## 14. Outside the RFC view: landing and the philosophy surface
+1 -1
View File
@@ -1 +1 @@
0.31.4 0.32.0
+8 -1
View File
@@ -657,12 +657,19 @@ def make_router(
return {"items": items} return {"items": items}
@router.get("/api/rfcs/{slug}") @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( row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(404, "Not found") 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) payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the # Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in # 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) items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked} 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) ----- # ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search") @router.get("/api/users/search")
+4
View File
@@ -256,6 +256,10 @@ def _require_rfc_readable(slug: str):
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn": if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is 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 return row
+227 -13
View File
@@ -19,7 +19,10 @@ Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a - GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition) 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 The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
@@ -74,7 +77,7 @@ class StepState:
@dataclass @dataclass
class GraduationState: class GraduationState:
slug: str slug: str
rfc_id: str rfc_id: str | None # null when graduating without a number (§13.2)
owners: list[str] owners: list[str]
arbiters: list[str] arbiters: list[str]
steps: list[StepState] steps: list[StepState]
@@ -113,7 +116,7 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug) 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: owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState( state = GraduationState(
slug=slug, rfc_id=rfc_id, slug=slug, rfc_id=rfc_id,
@@ -164,7 +167,12 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel): 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) owners: list[str] = Field(min_length=1)
@@ -257,14 +265,19 @@ def make_router(
candidate_id = (request.query_params.get("id") or "").strip() candidate_id = (request.query_params.get("id") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]") 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} id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
if not candidate_id: if not candidate_id:
id_payload["ok"] = False pass # blank is the default-accepted case, not an error
id_payload["error"] = "Integer ID is required"
elif not _is_valid_rfc_id(candidate_id): elif not _is_valid_rfc_id(candidate_id):
id_payload["ok"] = False 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): elif _rfc_id_taken(candidate_id, excluding_slug=slug):
id_payload["ok"] = False id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken" 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 # §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check # client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race on the integer ID. # 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()] owners = [o.strip() for o in body.owners if o.strip()]
if not owners: if not owners:
raise HTTPException(422, "Add at least one initial owner") raise HTTPException(422, "Add at least one initial owner")
if not _is_valid_rfc_id(rfc_id): if rfc_id is not None:
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)") if not _is_valid_rfc_id(rfc_id):
if _rfc_id_taken(rfc_id, excluding_slug=slug): raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
raise HTTPException(409, f"Integer ID {rfc_id} is already taken") 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 # 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 # 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) await cache.refresh_meta_pulls(config, gitea)
return {"pr_number": pr["number"], "slug": slug, "branch_name": pr["head"]["ref"]} 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 # Helpers
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -494,6 +575,45 @@ def make_router(
raise HTTPException(409, f"RFC is {row['state']}, not super-draft") raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row 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 return router
@@ -673,6 +793,100 @@ def _can_graduate(rfc, viewer) -> bool:
return viewer.gitea_login in owners or viewer.gitea_login in arbiters 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( def _audit(
actor_user_id: int | None, actor_user_id: int | None,
on_behalf_of: str | None, on_behalf_of: str | None,
+113 -11
View File
@@ -706,22 +706,24 @@ class Bot:
slug: str, slug: str,
new_file_contents: str, new_file_contents: str,
prior_sha: str, prior_sha: str,
rfc_id: str, rfc_id: str | None,
owners: list[str], owners: list[str],
) -> dict: ) -> dict:
"""§13.3 (meta-only): open a PR against the meta repo that flips the """§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 entry's frontmatter to `state: active` with the graduation stamps
graduation stamps **keeping the body unchanged** (§1 meta-only and **optionally** the integer `id`, **keeping the body
topology; no repo is created and no body is stripped). Branch name unchanged** (§1 meta-only topology; no repo is created and no body
uses the `graduate-<slug>-<6hex>` shape dash-separated like the is stripped). When `rfc_id` is None the entry graduates without a
other meta-repo branches per the §19.2 path-routing candidate. 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 import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}" branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply" 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) commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file( result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md", org, meta_repo, f"rfcs/{slug}.md",
@@ -736,11 +738,12 @@ class Bot:
or result.get("content", {}).get("sha") or result.get("content", {}).get("sha")
or "" 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)" 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 = ( pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n" f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n" f"{id_line}"
f"- Owners: {owners_str}\n\n" f"- Owners: {owners_str}\n\n"
f"This is an in-place state flip per the meta-only topology\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" f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
@@ -772,7 +775,7 @@ class Bot:
pr_number: int, pr_number: int,
head_branch: str, head_branch: str,
slug: str, slug: str,
rfc_id: str, rfc_id: str | None,
) -> None: ) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as """§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the 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 reports the transient state (belt-and-suspenders against the same
race appearing in a different shape). 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) body = _trailer(actor)
try: try:
@@ -809,6 +812,105 @@ class Bot:
details={"rfc_id": rfc_id}, 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 ----- # ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr( async def close_graduation_pr(
+1 -1
View File
@@ -30,7 +30,7 @@ _ABSENT = object()
class Entry: class Entry:
slug: str slug: str
title: 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 id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None repo: str | None = None
proposed_by: str = "" proposed_by: str = ""
+61
View File
@@ -0,0 +1,61 @@
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
-- with the expanded constraint and copy the rows across. cached_rfcs is a
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
-- holds a foreign key into it, so the rebuild is safe; we preserve the
-- existing rows anyway to avoid a needless full re-read on upgrade.
--
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
-- 009_per_rfc_models -> models_json
-- 010_funder -> funder_login
-- 021_proposed_use_case -> proposed_use_case
-- They are appended last (matching the live column order) and copied
-- across explicitly so nothing is dropped.
--
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
-- swap is atomic.
CREATE TABLE cached_rfcs_new (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT, -- 009_per_rfc_models
funder_login TEXT, -- 010_funder
proposed_use_case TEXT -- 021_proposed_use_case
);
INSERT INTO cached_rfcs_new
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case)
SELECT
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case
FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
+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']]}" 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): def test_claim_opens_meta_pr(app_with_fake_gitea):
"""§13.1: any signed-in contributor can claim ownership of an """§13.1: any signed-in contributor can claim ownership of an
unclaimed super-draft; the result is a meta-repo PR 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)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"version": "0.24.0", "version": "0.33.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"version": "0.24.0", "version": "0.33.0",
"dependencies": { "dependencies": {
"@amplitude/unified": "^1.1.9", "@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.31.4", "version": "0.32.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+16
View File
@@ -530,6 +530,22 @@ export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`)) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
} }
// §13.7 retire (soft delete). RFC owners + site owners may retire; only
// site owners may un-retire. The backend gates both regardless of UI.
export async function retireRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/retire`, { method: 'POST' }))
}
export async function unretireRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/unretire`, { method: 'POST' }))
}
// §13.7: the site-owner-only list of retired entries (for the admin
// "Retired" surface, where un-retire lives).
export async function listRetiredRFCs() {
return jsonOrThrow(await fetch('/api/admin/retired-rfcs'))
}
export async function graduateCheck(slug, { id }) { export async function graduateCheck(slug, { id }) {
// Meta-only topology (§13.2): two fields — integer id + owners. No // Meta-only topology (§13.2): two fields — integer id + owners. No
// repo name to validate. // repo name to validate.
+80 -1
View File
@@ -24,6 +24,8 @@ import {
addAllowlistEmail, addAllowlistEmail,
removeAllowlistEmail, removeAllowlistEmail,
createUserInvite, createUserInvite,
listRetiredRFCs,
unretireRFC,
} from '../api.js' } from '../api.js'
import { EVENTS, track } from '../lib/analytics.js' import { EVENTS, track } from '../lib/analytics.js'
@@ -42,12 +44,18 @@ const TABS = [
] ]
export default function Admin({ viewer }) { export default function Admin({ viewer }) {
// §13.7: the "Retired" surface (un-retire) is site-owner-only. The
// backend gates /api/admin/retired-rfcs and /unretire on the owner role
// regardless; we only show the link to owners so admins aren't offered
// a tab that would 403.
const isSiteOwner = viewer.role === 'owner'
const tabs = isSiteOwner ? [...TABS, { path: 'retired', label: 'Retired' }] : TABS
return ( return (
<div className="admin-page"> <div className="admin-page">
<nav className="admin-rail"> <nav className="admin-rail">
<h2>Admin</h2> <h2>Admin</h2>
<ul> <ul>
{TABS.map(t => ( {tabs.map(t => (
<li key={t.path}> <li key={t.path}>
<NavLink <NavLink
to={t.path} to={t.path}
@@ -69,6 +77,7 @@ export default function Admin({ viewer }) {
<Route path="users" element={<UsersTab />} /> <Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} /> <Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} /> <Route path="graduation" element={<GraduationTab />} />
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
<Route path="audit" element={<AuditTab />} /> <Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} /> <Route path="permissions" element={<PermissionsTab />} />
</Routes> </Routes>
@@ -765,6 +774,76 @@ function GraduationTab() {
) )
} }
// Retired (soft-deleted) entries site-owner-only un-retire (§13.7)
function RetiredTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState({})
const load = () => {
listRetiredRFCs()
.then(setData)
.catch(e => setError(e.message))
}
useEffect(load, [])
const onUnretire = async (slug) => {
setBusy(b => ({ ...b, [slug]: true }))
setError(null)
try {
await unretireRFC(slug)
load()
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [slug]: false }))
}
}
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading retired entries</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Retired</h2>
<p className="muted">
Soft-deleted RFCs (§13.7). They are hidden from the catalog and
every view; the entry stays in the meta repo. Un-retiring restores
an entry to the state it held before. Site owners only.
</p>
</header>
<h3 className="admin-section-h">Retired ({data.items.length})</h3>
{data.items.length === 0 && (
<p className="muted">No retired entries.</p>
)}
<ul className="grad-queue">
{data.items.map(item => (
<li key={item.slug}>
<span className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}{item.id || item.slug}
{`; restores to ${item.restores_to}`}
</span>
</span>
<button
type="button"
className="btn-secondary"
disabled={!!busy[item.slug]}
onClick={() => onUnretire(item.slug)}
>
{busy[item.slug] ? 'Un-retiring…' : 'Un-retire'}
</button>
</li>
))}
</ul>
</div>
)
}
// Audit log (`actions`) filter chips + paging // Audit log (`actions`) filter chips + paging
function AuditTab() { function AuditTab() {
+14 -7
View File
@@ -109,20 +109,25 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<p className="modal-intro"> <p className="modal-intro">
§13: graduate the super-draft to active. This is an in-place §13: graduate the super-draft to active. This is an in-place
state flip the entry keeps its body and stays in the meta state flip the entry keeps its body and stays in the meta
repo; only the frontmatter changes (state, integer ID, and the repo; only the frontmatter changes (state, the optional
graduation stamps). No new repository is created. integer ID, and the graduation stamps). No new repository is
created.
</p> </p>
<div className="form-row"> <div className="form-row">
<label>Integer ID</label> <label>Integer ID <span className="field-optional">(optional)</span></label>
<input <input
type="text" type="text"
value={rfcId} value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())} onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN" placeholder="RFC-NNNN — or leave blank"
disabled={phase !== 'idle'} disabled={phase !== 'idle'}
/> />
<p className="field-help">Pre-filled as the next free integer; editable to reserve gaps.</p> <p className="field-help">
Pre-filled as the next free integer; editable to reserve gaps,
or <strong>clear it to graduate without a number</strong> the
slug stays the canonical identifier.
</p>
{idError && <p className="field-error">{idError}</p>} {idError && <p className="field-error">{idError}</p>}
</div> </div>
@@ -186,8 +191,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete"> <div className="graduation-complete">
<h3>Graduation complete</h3> <h3>Graduation complete</h3>
<p> <p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>. {streamState?.rfc_id
The catalog and the RFC view reflect the new state. ? <>`{slug}` is now active as <strong>{streamState.rfc_id}</strong>.</>
: <>`{slug}` is now active (no number identified by its slug).</>}
{' '}The catalog and the RFC view reflect the new state.
</p> </p>
</div> </div>
)} )}
+65 -2
View File
@@ -44,7 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx' import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx' import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx' import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership } from '../api' import { claimOwnership, retireRFC, unretireRFC } from '../api'
import { EVENTS, track } from '../lib/analytics' import { EVENTS, track } from '../lib/analytics'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
@@ -154,6 +154,35 @@ export default function RFCView({ viewer }) {
// gates the underlying endpoints regardless, so a leaked toggle // gates the underlying endpoints regardless, so a leaked toggle
// can't actually leak anything. // can't actually leak anything.
const [showInvitationsModal, setShowInvitationsModal] = useState(false) const [showInvitationsModal, setShowInvitationsModal] = useState(false)
// §13.7 retire / un-retire. RFC owners + site owners may retire; only
// site owners may un-retire. The backend gates both regardless.
const [actionError, setActionError] = useState(null)
const canRetire = !!viewer && (entry?.state === 'super-draft' || entry?.state === 'active') && (
viewer.role === 'owner' || (entry?.owners || []).includes(viewer.gitea_login)
)
const handleRetire = useCallback(async () => {
if (!window.confirm(
`Retire “${entry?.title || slug}”? It will be soft-deleted — removed `
+ `from the catalog and every view. A site owner can un-retire it later.`
)) return
setActionError(null)
try {
await retireRFC(slug)
navigate('/') // the entry is now hidden; return to the catalog
} catch (err) {
setActionError(err.message)
}
}, [slug, entry, navigate])
const handleUnretire = useCallback(async () => {
setActionError(null)
try {
const res = await unretireRFC(slug)
getRFC(slug).then(setEntry).catch(() => {})
if (res?.state) navigate(`/rfc/${slug}`)
} catch (err) {
setActionError(err.message)
}
}, [slug, navigate])
// Load main view + branch view whenever slug/branch changes. // Load main view + branch view whenever slug/branch changes.
useEffect(() => { useEffect(() => {
@@ -493,7 +522,27 @@ export default function RFCView({ viewer }) {
if (error) return <article className="entry-view"><p>Error: {error}</p></article> if (error) return <article className="entry-view"><p>Error: {error}</p></article>
if (!entry) return <article className="entry-view">Loading</article> if (!entry) return <article className="entry-view">Loading</article>
if (entry.state !== 'active' && entry.state !== 'super-draft') { if (entry.state !== 'active' && entry.state !== 'super-draft') {
return <article className="entry-view"><p>This RFC is {entry.state}.</p></article> // §13.7: a retired entry is only reachable here by a site owner (the
// backend 404s everyone else), so this doubles as the direct-nav
// un-retire surface alongside the admin "Retired" list.
const canUnretire = entry.state === 'retired' && viewer?.role === 'owner'
return (
<article className="entry-view">
<p>This RFC is {entry.state}.</p>
{canUnretire && (
<>
<p className="muted">
Retired entries are hidden from the catalog and every view.
Un-retiring restores it to its prior state.
</p>
<button type="button" className="btn-primary" onClick={handleUnretire}>
Un-retire
</button>
</>
)}
{actionError && <p className="rfc-error-banner">{actionError}</p>}
</article>
)
} }
if (!branchView) return <article className="entry-view">Loading branch</article> if (!branchView) return <article className="entry-view">Loading branch</article>
@@ -653,8 +702,22 @@ export default function RFCView({ viewer }) {
Invitations Invitations
</button> </button>
)} )}
{/* §13.7: Retire (soft delete). RFC owners + site owners only. */}
{canRetire && (
<button
type="button"
className="btn-link btn-retire"
onClick={handleRetire}
title="§13.7 — soft-delete this RFC (removes it from every view; a site owner can un-retire it)"
>
Retire
</button>
)}
</div> </div>
</div> </div>
{actionError && (
<div className="rfc-error-banner">Retire failed: {actionError}</div>
)}
{claimError && ( {claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div> <div className="rfc-error-banner">Claim failed: {claimError}</div>
)} )}