§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
+14 -7
View File
@@ -109,20 +109,25 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<p className="modal-intro">
§13: graduate the super-draft to active. This is an in-place
state flip the entry keeps its body and stays in the meta
repo; only the frontmatter changes (state, integer ID, and the
graduation stamps). No new repository is created.
repo; only the frontmatter changes (state, the optional
integer ID, and the graduation stamps). No new repository is
created.
</p>
<div className="form-row">
<label>Integer ID</label>
<label>Integer ID <span className="field-optional">(optional)</span></label>
<input
type="text"
value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN"
placeholder="RFC-NNNN — or leave blank"
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>}
</div>
@@ -186,8 +191,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
The catalog and the RFC view reflect the new state.
{streamState?.rfc_id
? <>`{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>
</div>
)}