§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
+16
View File
@@ -530,6 +530,22 @@ export async function listBlockingPRs(slug) {
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 }) {
// Meta-only topology (§13.2): two fields — integer id + owners. No
// repo name to validate.
+80 -1
View File
@@ -24,6 +24,8 @@ import {
addAllowlistEmail,
removeAllowlistEmail,
createUserInvite,
listRetiredRFCs,
unretireRFC,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
@@ -42,12 +44,18 @@ const TABS = [
]
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 (
<div className="admin-page">
<nav className="admin-rail">
<h2>Admin</h2>
<ul>
{TABS.map(t => (
{tabs.map(t => (
<li key={t.path}>
<NavLink
to={t.path}
@@ -69,6 +77,7 @@ export default function Admin({ viewer }) {
<Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} />
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
</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 ──────────────────────────
function AuditTab() {
+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>
)}
+65 -2
View File
@@ -44,7 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership } from '../api'
import { claimOwnership, retireRFC, unretireRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
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
// can't actually leak anything.
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.
useEffect(() => {
@@ -493,7 +522,27 @@ export default function RFCView({ viewer }) {
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
if (!entry) return <article className="entry-view">Loading</article>
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>
@@ -653,8 +702,22 @@ export default function RFCView({ viewer }) {
Invitations
</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>
{actionError && (
<div className="rfc-error-banner">Retire failed: {actionError}</div>
)}
{claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div>
)}