diff --git a/CHANGELOG.md b/CHANGELOG.md index a076671..15895d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,59 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.31.0 — 2026-05-29 + +**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no +longer graduate into their own Gitea repositories; every RFC lives in +its meta-repo entry (`rfcs/.md`) for its whole life, and +graduation is an in-place `super-draft → active` state flip that keeps +the body in the entry. The per-RFC-repo machinery — repo creation, +`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the +five-step transactional sequence and its rollback — is removed. This is +the framework change behind a much simpler deployer story: one content +repository, every RFC under `rfcs/`.** + +What changed, concretely: + +- **Graduation is a single flip.** `POST /api/rfcs//graduate` now + opens one meta-repo PR that re-serializes the entry with + `state: active`, the integer `id`, and `graduated_at`/`graduated_by` + — **body unchanged, `repo` left null** — then auto-merges it. No repo + is created, nothing is seeded, and there is no rollback (an open- or + merge-failure leaves the entry a super-draft; a failed merge's PR and + branch are cleaned up). The Graduate dialog drops the **Repo name** + field (two fields now: integer ID + owners) and the progress stack is + two steps (`open_pr`, `merge_pr`). +- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on + meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`, + so an active RFC's branches, body-edit PRs, threads, flags, and + `changes` all live on the meta repo exactly as a super-draft's do. + `promote-to-branch` names an active RFC's auto-branch + `edit--` so the shared-repo cache can attribute it. +- **Open body-edit PRs no longer block graduation** (§9.8) — the body is + kept, so they coexist with the flip. +- **`refresh_rfc_repo` and the per-RFC read path are dead** for + meta-only entries (the reconciler only sweeps entries with a non-null + `repo`, of which there are none after the fold-back below). + +**Upgrade steps:** + +- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back + into its meta entry before/with this deploy: restore the per-RFC + `RFC.md` body into `rfcs/.md`, set `repo: null` (keep + `state: active` and the integer `id`), and archive the per-RFC repo. + An entry left with a non-null `repo` keeps using the retained legacy + read path, but **no new** per-RFC repos are ever created. For OHM, + RFC-0001 `human` was folded back in driver session 0041.0 (§13.6). +- No schema migration, no new config, no new secret, no overlay change. + A plain code deploy applies it; the running reconciler reconciles the + catalog on its next sweep (≤5 min). +- The `repo:` frontmatter field and the `/api/rfcs//blocking-prs` + endpoint are **retained** (the field for schema stability + legacy + entries; the endpoint as an informational, non-blocking surface), so + no client contract is removed — `graduate/check` simply no longer + returns a `repo` field and never reports `blocking_prs` as a gate. + ## 0.30.2 — 2026-05-29 **Patch — header nav label: the persistent chrome link reverts from diff --git a/SPEC.md b/SPEC.md index f3e2016..d204abe 100644 --- a/SPEC.md +++ b/SPEC.md @@ -30,13 +30,44 @@ providers (Anthropic, Google, OpenAI / GitHub Copilot). ## 1. Repository topology -Each RFC is its own Gitea repository. There is in addition exactly one **meta -repository** that serves as the authoritative directory of all RFCs in the -system — drafts, active work, and retired entries alike. +There is exactly one **meta repository** that holds every RFC in the +system as a single markdown entry under `rfcs/` — drafts, active work, +and retired entries alike — regardless of state. An RFC is a single +canonical document, and its document *is* its meta entry: the body +lives in the entry file at every state, from idea through active. There +are **no per-RFC repositories**. -All Git operations across all repositories are performed by a single **bot +For a deployment, this single repository is its **content repository**: +the one place every RFC document lives (under `rfcs/`), alongside the +framework's `PHILOSOPHY.md`, `README.md`, and `CONTRIBUTING.md` (§2). A +deployment names it concretely — `-content` reads cleanly +— and the `META_REPO` setting carries the name. The term *meta +repository* persists for the config surface and +historical continuity, but under the meta-only topology this repo is, +functionally, the deployment's content repo: "one repo, your RFCs are +in `rfcs/`" is the whole mental model a new deployer needs. + +> **Topology change (v0.31.0, meta-only — supersedes the original +> per-RFC-repo model).** This spec originally said "each RFC is its own +> Gitea repository," and graduation created a dedicated `rfc-NNNN-` +> repo and moved the body into its `RFC.md`. That model is **retired**. +> The per-RFC-repo machinery never paid for itself under this design: +> authorization is decided in app data before a single bot acts (below), +> not by per-repo Gitea permissions; raw `git clone`+`push` was never a +> supported contribution path; and the super-draft phase already ran +> meta-only. So an RFC now lives in its meta entry for its whole life, +> and graduation is an in-place state flip rather than a repo-creation +> transaction (see §13). Where later sections still say "the RFC's repo" +> or "RFC.md on the new repo," read it as "the RFC's meta entry body" — +> the editing, branch, PR, and chat machinery is unchanged; only the +> location collapses onto the meta repo. The one RFC graduated under the +> old model, **RFC-0001 `human`**, was folded back into its meta entry +> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The +> decision record is OHM ROADMAP #36. + +All Git operations on the meta repository are performed by a single **bot service account** in Gitea. Real human users do not have meaningful Gitea -permissions on the repos themselves; their accounts exist for OAuth identity +permissions on the repo itself; their accounts exist for OAuth identity only. The bot is the author of every commit, the opener of every PR, and the merger of every merge. Authorization decisions are made by the app, in app data, *before* the bot acts on the user's behalf. @@ -67,8 +98,8 @@ The meta repo's `main` branch contains: arriving at the meta-repo via Git rather than via the app. The **index below the header is regenerated by CI on every merge to main** and lists active RFCs, super-drafts, and (eventually) retired entries with links - into the corresponding entry files and, when present, the RFC's own - repository. + into the corresponding entry files. (There is no per-RFC repository to + link to under the meta-only topology, §1.) - `CONTRIBUTING.md` — explains how to propose, claim, and contribute. - A workflow file (Gitea Actions) that regenerates the README index. @@ -84,7 +115,9 @@ slug: human title: Human state: super-draft # super-draft | active | withdrawn id: null # null until graduated; then "RFC-0042" -repo: null # null until graduated; then "wiggleverse/rfc-0042-human" +repo: null # always null under the meta-only topology (§1). + # Retained for schema stability + historical + # entries; never populated by graduation (§13). proposed_by: ben@wiggleverse.org proposed_at: 2026-05-22 graduated_at: null @@ -103,8 +136,9 @@ tags: [identity, schema] ## Why this RFC is needed (One- or two-paragraph pitch from the proposer. While the entry is a -super-draft, the body may grow into the actual draft document. On -graduation, this body migrates to RFC.md in the new repo; see §13.) +super-draft, the body grows into the actual draft document. The body +stays in this entry at every state — graduation is an in-place state +flip and does not move it (§13).) ``` ### 2.2 Idea submission as PR @@ -129,11 +163,14 @@ an idea costs nothing in identifier space. There are three canonical states stored in entry frontmatter: - **`super-draft`** — the entry exists in the meta repo's `rfcs/` - directory. No dedicated repo yet. Anyone signed in can chat on it; anyone - can claim ownership; an owner is required before graduation. -- **`active`** — the entry has been graduated. A dedicated RFC repo - exists, `repo:` points to it, and real branches/PRs/conversation happen - there. + directory. Anyone signed in can chat on it; anyone can claim ownership; + an owner is required before graduation. +- **`active`** — the entry has been graduated: it carries an integer + `id` (`RFC-NNNN`) and `graduated_at`/`graduated_by`. It lives in the + same `rfcs/.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). - **`withdrawn`** — pulled before becoming canonical. Stays in the directory as historical record, hidden from default views, filterable in. @@ -163,20 +200,23 @@ for "who clicked the button" (see §6.5). ### 3.2 State change side-effects -For now, changing state in the meta repo entry is the *only* required -operation for a state transition. Graduation has additional side effects -(creating the new repo, seeding it); those are covered in §13. We -deliberately do not tag commits, lock branches, or post notices on -state change for now — the entry frontmatter is the single source of -truth and any further automation is a later refinement. +Changing state in the meta repo entry is the *only* required operation +for a state transition — graduation included. Graduation additionally +assigns the integer `id` and stamps `graduated_at`/`graduated_by` in the +same commit (§13); it has no other side effects under the meta-only +topology (no repo to create, nothing to seed). We deliberately do not +tag commits, lock branches, or post notices on state change for now — +the entry frontmatter is the single source of truth and any further +automation is a later refinement. --- ## 4. Storage architecture: Git is truth, app keeps a cache Gitea remains the source of truth for everything Git-shaped: meta repo -content, RFC repo content, branches, PRs, commits. Nothing in this system -overrides Gitea on those concerns. +content, branches, PRs, commits — all of which live on the single meta +repo under the meta-only topology (§1). Nothing in this system overrides +Gitea on those concerns. The app maintains a **SQLite database**, colocated with the FastAPI process, that serves three purposes: @@ -187,10 +227,11 @@ process, that serves three purposes: assignments, per-branch grants, branch visibility settings, chat history, audit logs. This data is canonical; it is not cached, it is owned by the app. -3. **Cached bodies** — the main-branch body of each RFC's `RFC.md` (and - each super-draft's entry body) is cached for left-pane previews and - read-without-roundtrip. Branch bodies are *not* cached; the editor - fetches them live from Gitea when opened. +3. **Cached bodies** — the main-branch body of each RFC, read from its + `rfcs/.md` meta entry (the same source for super-drafts and + active RFCs alike under the meta-only topology), is cached for + left-pane previews and read-without-roundtrip. Branch bodies are + *not* cached; the editor fetches them live from Gitea when opened. ### 4.1 Cache freshness @@ -201,7 +242,7 @@ Two paths keep the cache current, running in parallel: A webhook handler does a focused re-read of just what changed. Typical latency: sub-second. - **A periodic reconciler** runs every five minutes and does a full - sweep — list meta-repo entries, list each RFC repo's branches and + sweep — list meta-repo entries, list the meta repo's branches and PRs, diff against the cache, fix drift. This is the safety net for missed webhooks and downtime. @@ -1607,42 +1648,43 @@ contributor — identical to an active RFC's main chat per §11.4 plus ### 9.8 Graduation handoff additions -§13's graduation sequence was written before this section's -machinery existed. The mechanics §13 needs to absorb fold inline -into §13.2 and §13.4 in their respective sections; the substantive -additions are captured here for cross-reference: +> **Meta-only update (v0.31.0).** This section originally reconciled +> §13's per-RFC-repo graduation transaction with the §9-era editing +> machinery. Under the meta-only topology (§1, §13) the entry never +> moves and its body is never stripped, so the frictions this section +> existed to manage **dissolve**. The bullets are retained, struck +> through, for the audit trail of what the per-repo model required. -- **Open body-edit PRs block graduation.** §13.3's step 3 removes +Under meta-only, graduation is a single frontmatter-flipping commit +to `rfcs/.md` (§13.3). Nothing else changes: the body stays in +the entry; branches, edit-PRs, threads, flags, and `changes` rows all +remain exactly where they were, keyed by the slug per §2.3, and keep +working against the same meta entry after the flip as before it. There +is no entry move, no body strip, no per-repo seed, and therefore no +"handoff" to coordinate. + +- ~~**Open body-edit PRs block graduation.** §13.3's step 3 removes the meta-repo entry's body field, and an open body-edit PR post-graduation would attempt to re-introduce a body to a - frontmatter-only entry. The Graduate dialog disables the confirm - button if any meta-repo PR is open against `rfcs/.md`. The - precondition is enforced before the bot starts §13.3's sequence, - so §13.3's rollback complexity does not grow. -- **Bare edit branches survive graduation.** Edit branches without - an open PR are not blocked. They remain on the meta repo subject - to §12's hygiene timers. The contributor can re-cut against the - new RFC repo's main if they still want the work. The branch chat - persists per §8.4 as historical record even after auto-close, so - the argument that produced the work is preserved regardless of - whether the work itself merges. -- **Chat migration includes range and paragraph sub-threads.** - §13.4's chat-follows-the-work rule covers the whole-doc main - thread; it extends to range and paragraph sub-threads on the - super-draft's main view, which migrate as part of the same - movement. Anchors re-resolve against `RFC.md` on the new repo; - since §13.3's step 2 seeds `RFC.md` from the super-draft body - verbatim, anchors typically locate the same content. Where they - do not, §8.12's stale mechanic engages. -- **Pre-graduation history surfaces from the new RFC view.** - Meta-repo edit-branch chats, flag threads, and `changes` rows - stay attached to their original `branch_name` on the meta repo; - they do not migrate. A **"Pre-graduation history"** affordance on - the new RFC view surfaces these — the slug remains the canonical - key per §2.3, so the query is a straightforward lookup of - `threads` and `changes` rows where `rfc_slug = ` and - `branch_name` begins with `edit//`. UI affordance; no data - movement, no rollback cost. + frontmatter-only entry.~~ **No longer applies** — the body is kept, + so an open body-edit PR coexists with graduation. Graduation touches + only the frontmatter; a body-edit PR that merges after graduation + edits the same entry's body just as it would have before. The + Graduate dialog no longer gates on open body-edit PRs (§13.2). +- ~~**Bare edit branches survive graduation.**~~ Trivially true now — + no repo boundary is crossed, so every edit branch simply remains a + meta-repo branch on the same slug, subject to §12's hygiene timers, + with no "re-cut against the new repo" step. +- ~~**Chat migration includes range and paragraph sub-threads.**~~ No + migration occurs: whole-doc, range, and paragraph threads stay on + their `(rfc_slug, branch_name)` rows. Their anchors resolve against + the same entry body, which did not move, so §8.12's stale mechanic is + not provoked by graduation. +- ~~**Pre-graduation history surfaces from the new RFC view.**~~ There + is no "new RFC view" distinct from the entry's own view, so there is + no pre-graduation hop to bridge. Edit-branch threads, flags, and + `changes` rows surface on the active RFC the same way they did on the + super-draft — same slug, same surface. --- @@ -1953,16 +1995,23 @@ email request to an owner. --- -## 13. The graduation flow (super-draft → active RFC repo) +## 13. The graduation flow (super-draft → active, in place) -Graduation is initiated by an owner or admin clicking "Graduate to RFC -repo" on a super-draft's page. The button is disabled with a tooltip -when the super-draft has no owners (see §13.1) or when any meta-repo -body-edit PR is open against `rfcs/.md` (see §9.8 — open -body-edit PRs would attempt to re-introduce a body to a frontmatter- -only entry after step 3 of §13.3). Bare edit branches without an open -PR do not block graduation; they remain on the meta repo subject to -§12's hygiene timers. +Graduation is initiated by an owner or admin clicking "Graduate" on a +super-draft's page. The button is disabled with a tooltip when the +super-draft has no owners (see §13.1). Open meta-repo body-edit PRs no +longer block graduation: under the meta-only topology (§1) the body is +kept in the entry, so graduation touches only frontmatter and coexists +with body edits (see §9.8). Bare edit branches are likewise unaffected; +they remain on the meta repo subject to §12's hygiene timers. + +> **Meta-only rewrite (v0.31.0).** §13 originally described a +> transactional create-repo-seed-flip sequence (`super-draft → active +> RFC repo`) with rollback. That is **retired** (§1). Graduation is now +> a single in-place state flip on the entry: no repo is created, the +> body is not moved or stripped, and there is nothing to roll back. The +> subsections below are rewritten to the new model; §13.3 records what +> the old transaction did, struck through, for the audit trail. ### 13.1 Claim ownership (prerequisite) @@ -1983,137 +2032,117 @@ broadening rather than a precondition for the proposer's own RFC.) ### 13.2 The Graduate dialog -Clicking "Graduate to RFC repo" opens a small dialog with three -editable fields: +Clicking "Graduate" opens a small dialog with two editable fields: - **Integer ID** — pre-filled as `max(existing integer IDs) + 1`, formatted as `RFC-NNNN`. Editable to allow gap reservations but the default is just the next number. -- **Repo name** — pre-filled as `rfc-NNNN-`, editable but - constrained to valid Gitea repo names. - **Initial owners** — pre-filled from the entry's `owners:`, with an "add owner" picker. Must have at least one. +(The old **Repo name** field is gone — there is no repo to name under +the meta-only topology.) + Each field validates inline as the admin types, with a short debounce, against the catalog cache and a regex — integer-ID -collision against existing IDs, repo-name pattern against valid -Gitea name rules, the at-least-one-owner constraint on the picker. -Errors render as a short line of text beneath the offending field. -The repo-name collision check is re-issued atomically server-side -on confirm, since a concurrent graduation could land between -dialog-open and submit. While any field is invalid, the confirm -button is disabled and its tooltip names the first blocker -specifically — "Integer ID 42 is already taken," "Repo name must be -lowercase letters, digits, and dashes," "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. +collision against existing IDs, the at-least-one-owner constraint on +the picker. Errors render as a short line of text beneath the +offending field. The integer-ID collision check is re-issued +atomically server-side on confirm, since a concurrent graduation +could land between dialog-open and submit. While any field is +invalid, the confirm button is disabled and its tooltip names the +first blocker specifically — "Integer ID 42 is 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. -The dialog's confirm button is also disabled when the preconditions -from §13's opening paragraph fail — no owners on the entry, or any -open meta-repo PR against `rfcs/.md`. The disabled button -opens a small popover on hover or click that lists each failing -precondition as its own line item with an inline remediation -affordance per item. "No owners claimed yet" surfaces a "Copy share -link" affordance for surfacing the super-draft to a would-be -claimer, plus a secondary "Claim ownership yourself" — admins are -contributors per §6.1, so they can claim if they intend to graduate -solo. "N open body-edit PRs" expands inline within the popover to a -list of the offending PRs, one per row, carrying each PR's title, -author, and last-activity timestamp plus inline merge, withdraw, -and open-in-new-tab affordances; admins hold §6.3 authority on -those PRs and can resolve the precondition from the popover without -leaving the Graduate context. +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 +listing the failing precondition with an inline remediation +affordance: "No owners claimed yet" surfaces a "Copy share link" +affordance for surfacing the super-draft to a would-be claimer, plus +a secondary "Claim ownership yourself" — admins are contributors per +§6.1, so they can claim if they intend to graduate solo. Open +body-edit PRs are **not** a precondition anymore (§9.8): the body is +kept, so they coexist with graduation. -The preconditions are enforced before the bot starts §13.3's -sequence, so §13.3's rollback complexity is unchanged. +### 13.3 The flip -### 13.3 The transactional sequence +Confirming the dialog runs a single operation as the bot: open a PR +against the meta repo that re-serializes `rfcs/.md` with +`state: active`, `id: RFC-NNNN`, `graduated_at: `, +`graduated_by: `, and the `owners:` from the dialog — +**leaving the body unchanged** — then auto-merge it (the admin who +clicked is the merge actor). The webhook flow updates the SQLite cache +and the catalog row transitions per §7.2. -Confirming the dialog runs this sequence as the bot: +``` +super-draft entry ──[graduate]──▶ same entry, state: active, id assigned +(body unchanged, repo: null, lives in rfcs/.md throughout) +``` -1. Create the new Gitea repo. -2. Seed it with an initial commit on `main` containing: - - `README.md` (header pointing at the meta-repo entry, plus the - super-draft's pitch body migrated over). - - `RFC.md` (the actual document, starting from the super-draft body - or a template if the body is empty). - - `.rfc/metadata.yaml` — mirror of the meta-repo frontmatter for - future tooling. -3. Open a PR against the meta repo updating the entry: `state: active`, - `id: RFC-NNNN`, `repo: `, `graduated_at: `, - `graduated_by: `. The meta-repo entry's body field - is removed (frontmatter only, plus a generated "see the full RFC at - " link). -4. Auto-merge the PR (the same admin who clicked the button is the - merge actor). -5. Webhook flow updates the SQLite cache; left pane reflects the new - state immediately. +There is no repo to create, nothing to seed, and the body is neither +moved nor stripped, so there is **no multi-step transaction and no +rollback**. If opening or merging the flip PR fails, the entry simply +stays a super-draft and the admin sees the error — nothing partial was +created that needs cleaning up. The dialog reports a single in-flight +"Graduating…" state that resolves to success (the PR merged) or a +plain error (the PR could not be opened or merged), rather than the +old five-step stack. On success, a brief "Graduation complete" frame +holds for a moment before the dialog closes. -The dialog renders the sequence in flight as a stack of the five -named steps with per-step states — `pending`, `running`, `done`, -`failed`, `not reached` — and a one-line caption beneath the current -step naming the concrete operation ("Creating repository -wiggleverse/rfc-0042-human…"). The stack streams from -the server via the SSE surface in §17, one event per step -transition. On success, a brief "Graduation complete" frame holds -for a moment before the dialog closes and the catalog row -transitions per §7.2. +> ~~**The old transactional sequence (per-RFC-repo model, retired).** +> Confirming created a new Gitea repo; seeded it with `README.md`, +> `RFC.md` (body migrated), and `.rfc/metadata.yaml`; opened a meta-repo +> PR flipping `state`/`id`/`repo`/`graduated_*` **and stripping the +> entry body** to frontmatter-only with a "see the full RFC at " +> link; auto-merged it; refreshed the cache. A five-step SSE stack +> rendered progress, and any mid-sequence failure rolled back (delete +> the half-created repo, abandon the PR). All of that machinery is +> removed — the body strip was the only reason most of it existed.~~ -If any step fails partway, the app rolls back: deletes the -half-created repo, abandons the unmerged PR, surfaces a clear error -to the admin. The rollback is itself a visible step appended to the -stack on failure — the admin sees that cleanup ran, not just that -the act failed. The failed step turns red, later original-sequence -steps mark "not reached," and a "What happened" panel renders below -the stack explaining what was rolled back, what wasn't (if anything -is unrecoverable), and what to do next. The panel persists until the -admin dismisses it — a failure surface is not auto-dismissed. -Graduation is rare enough to afford this level of care. +### 13.4 Chat, branches, and history stay put -### 13.4 Chat history follows the work +Nothing moves at graduation. The whole-doc main thread (§8.4), range +and paragraph sub-threads (§8.12), edit-branch chats, flag threads, +and `changes` rows all remain on their existing `(rfc_slug, +branch_name)` rows — the slug is the canonical key per §2.3 and does +not change. Their anchors resolve against the same entry body, which +did not move, so §8.12's stale mechanic is not provoked by graduation. -The chat thread attached to the super-draft moves to the new repo's -main-branch chat at graduation. This covers both the whole-doc main -thread per §8.4 and any range or paragraph sub-threads per §8.12 -anchored to the super-draft's main view; anchors re-resolve against -`RFC.md` on the new repo and, where they fail, §8.12's stale -mechanic engages. The meta-repo entry retains a generated link -"Conversation continues at ." The chat is about the RFC, -not the meta-repo entry, and it should travel with the work. +Because there is no repo boundary to cross, there is no +"pre-graduation history" hop: the active RFC's view is the same view +the super-draft had, listing the same `main`, open branches, and open +PRs in the §8.1 breadcrumb dropdown. Edit branches that closed during +the super-draft phase surface through the ordinary "Show closed +branches" filter — there is no separate "lived on the meta repo before +the repo existed" set to distinguish, because the repo never existed. -Meta-repo edit-branch chats, flag threads, and `changes` rows from -the super-draft phase **do not migrate**. They stay attached to -their original `branch_name` on the meta repo and surface from the -new RFC view via a **"Pre-graduation history"** affordance — a -straightforward lookup of `threads` and `changes` rows where -`rfc_slug = ` and `branch_name` begins with `edit//` -(the slug remains the canonical key per §2.3, before and after -graduation). UI affordance; no data movement, no rollback cost. +### 13.5 Reversing graduation -The affordance renders as a section in the §8.1 breadcrumb dropdown -on the new RFC view, alongside `main`, open branches, and open PRs, -headed "Pre-graduation history (N)" with each pre-graduation edit -branch listed as its own row. Selecting a row swaps the center -column to a read-only render of that branch's body at its last -commit and the right column to that branch's chat, with associated -change-cards and flags inline — the same machinery a closed branch -on an active RFC uses per §10.7 and §11.5. Anchors on pre-graduation -threads resolve against the pre-graduation body, not against -`RFC.md` on the new repo. The pre-graduation set is kept distinct -from the post-graduation "Show closed branches" filter in the same -dropdown — "branches that closed normally on this repo" and -"branches that lived on the meta repo before this repo existed" are -semantically different sets, and conflating them would obscure the -graduation hop. +The canonical forward path from `active` is still `withdrawn` (§3.1), +and `withdrawn → super-draft` reopens an entry. Under the meta-only +topology, reversing a graduation is no longer operationally messy — +there are no repo commits to orphan, only a frontmatter flip — but the +state graph in §3.1 remains the authority: `active → withdrawn → +super-draft` is the supported route, and the integer `id`, once +assigned, is not reclaimed (gap-free allocation per §2.3 tolerates +gaps from withdrawals). A direct `active → super-draft` un-graduate is +not exposed in v1; withdraw-and-reopen covers the need. -### 13.5 Graduation is not reversible +### 13.6 RFC-0001 fold-back (migration record) -Once an entry is graduated to `active`, the path forward is -`withdrawn`, not back to `super-draft`. Reversing graduation cleanly -is operationally messy (existing commits in the new repo, etc.) and -the cost of not having it is low — withdraw and re-graduate as a -fresh idea if needed. +RFC-0001 `human` was graduated under the original per-RFC-repo model +(2026-05-26) into `wiggleverse/rfc-0001-human`, with its body in that +repo's `RFC.md` and its meta entry stripped to frontmatter. When the +meta-only topology landed (v0.31.0, OHM ROADMAP #36, driver session +0041.0), RFC-0001 was folded back to the single model: the full +`RFC.md` body was restored into `rfcs/human.md` in the meta repo +(`repo:` set null, `state: active` and `id: RFC-0001` retained), and +`wiggleverse/rfc-0001-human` was archived with a `README` pointing at +the canonical home in the app. RFC-0001 is therefore an ordinary +meta-only active RFC like any other; no grandfathered per-repo path +remains in the code. --- diff --git a/VERSION b/VERSION index 0f72177..26bea73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.2 +0.31.0 diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 3079942..9218c0b 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -150,7 +150,7 @@ def make_router( # `refresh_meta_branches` writes is internal scaffolding for the # §10.1 has-commits-ahead check — the §9.4 dropdown's first # position is rendered separately as 'canonical body'. - if _is_super_draft(rfc): + if _is_meta_resident(rfc): branch_rows = db.conn().execute( """ SELECT branch_name, head_sha, state, last_commit_at, pinned @@ -180,7 +180,7 @@ def make_router( # per-RFC repo. For super-draft: meta_body_edit and meta_metadata # PRs on the meta repo. Same shape either way — the §9.4 dropdown # treats both as "open work against this entry." - pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",) + pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",) placeholders = ",".join("?" * len(pr_kinds)) pr_rows = db.conn().execute( f""" @@ -204,17 +204,18 @@ def make_router( for r in pr_rows ] - # For super-drafts the cached body is entry.body already (see - # cache._upsert_cached_rfc), so no extraction is needed. - # §9.8 / §13.4 pre-graduation history: for active RFCs, surface - # any `threads` or `changes` rows whose `branch_name` starts with - # `edit--` so the breadcrumb dropdown can render the - # affordance as a distinct disclosure alongside main, open - # branches, and open PRs. The slug is the canonical key per §2.3 - # before and after graduation, so the query is a straightforward - # lookup — no data movement. + # For meta-resident entries the cached body is entry.body already + # (see cache._upsert_cached_rfc), so no extraction is needed. + # Pre-graduation history is a LEGACY-only affordance: under the + # meta-only topology (§1, §13.4) graduation moves nothing, so an + # active RFC's edit branches are its *current* branches and already + # surface in `branches` above — there is no separate pre-graduation + # set. The disclosure is therefore computed only for a legacy + # per-RFC-repo active entry (`repo` set), where edit branches on the + # meta repo genuinely predate the per-RFC repo and would otherwise + # not appear. After the RFC-0001 fold-back (§13.6) nothing matches. pre_grad: list[dict[str, Any]] = [] - if rfc["state"] == "active": + if rfc["state"] == "active" and rfc["repo"]: pre_grad_rows = db.conn().execute( """ SELECT t.branch_name, @@ -292,8 +293,20 @@ def make_router( owner, repo = _repo_for(rfc) new_branch = (body.branch_name or "").strip() if not new_branch: - new_branch = _auto_branch_name(viewer.gitea_login) - _validate_branch_name(new_branch) + # Meta-only topology (§1): an active RFC's branches live on the + # shared meta repo, so the auto name must embed the slug for the + # cache to attribute it (`edit--`, recovered by + # `_slug_from_branch_name`). A legacy per-RFC-repo entry can use + # the slug-free `-draft-` since every branch there + # belongs to the one RFC. The auto name is trusted (it carries a + # reserved `edit-` prefix by design); only a user-supplied name + # is validated, mirroring `start_edit_branch`. + new_branch = ( + _auto_edit_branch_name(slug) if _is_meta_resident(rfc) + else _auto_branch_name(viewer.gitea_login) + ) + else: + _validate_branch_name(new_branch) try: await bot.cut_branch_from_main( viewer.as_actor(), @@ -326,8 +339,10 @@ def make_router( _ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id) # Make the cache aware immediately so the breadcrumb reflects - # the new branch without waiting for the webhook hop. - await cache.refresh_rfc_repo(config, gitea, slug) + # the new branch without waiting for the webhook hop. Meta-resident + # entries (§1) refresh meta branches; a legacy per-RFC repo refreshes + # its own — `_refresh_cache_for` dispatches on residency. + await _refresh_cache_for(rfc) return {"branch_name": new_branch, "slug": slug} @@ -1081,14 +1096,14 @@ def make_router( return row def _require_rfc_with_repo(slug: str): - """Used by every branch-scoped endpoint. For active RFCs, a repo is - required. For super-drafts, the meta repo is the implicit target — - no per-RFC repo check needed.""" + """Used by every branch-scoped endpoint. Under the meta-only + topology (§1) the meta repo is the implicit target for every + entry — super-draft and active alike — so there is no per-RFC + repo check. The name is retained for call-site stability; a + withdrawn entry is still rejected.""" row = _require_rfc(slug) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") - if row["state"] == "active" and not row["repo"]: - raise HTTPException(409, "RFC has no repo") return row def _require_active_rfc(slug: str): @@ -1106,22 +1121,28 @@ def make_router( def _is_super_draft(rfc) -> bool: return rfc["state"] == "super-draft" + def _is_meta_resident(rfc) -> bool: + """Meta-only topology (§1): an entry lives in the meta repo's + `rfcs/.md` (super-draft or active-in-place) unless it carries + a legacy per-RFC `repo:` — which nothing does after the RFC-0001 + fold-back (§13.6).""" + return not rfc["repo"] + def _is_meta_branch_name(name: str) -> bool: """A branch name shaped like one of the bot's meta-repo prefixes. - §9.8's pre-graduation history affordance points the new RFC view - at branches matching `edit--...` even after the entry is - active; treating those names as meta-repo targets lets the read - path dispatch correctly without a separate endpoint.""" + Retained for the legacy per-RFC-repo read path; under meta-only + every entry is already a meta target via `_is_meta_resident`.""" return name != "main" and name.startswith(( "edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/", "graduate-", )) def _is_meta_target(rfc, branch: str) -> bool: - """Either a super-draft branch (active edit branch or the - canonical body) or an active RFC's pre-graduation meta-repo - branch surfaced through the §9.8 history affordance.""" - if _is_super_draft(rfc): + """A meta-resident entry (super-draft or active-in-place, §1) + targets the meta repo for every branch. The branch-name fallback + covers the retired per-RFC-repo case for any legacy entry that + still carries a `repo:`.""" + if _is_meta_resident(rfc): return True return _is_meta_branch_name(branch) @@ -1161,7 +1182,7 @@ def make_router( return entry_mod.serialize(entry) async def _refresh_cache_for(rfc) -> None: - if _is_super_draft(rfc): + if _is_meta_resident(rfc): await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) else: @@ -1270,12 +1291,13 @@ def make_router( return False if branch == "main": return False - # §9.8: pre-graduation history branches are read-only on the - # post-graduation surface. The contributor can re-cut against the - # new repo's main if they still want the work, but the meta-repo - # branches that lived on the super-draft are not editable from - # the active-RFC view. - if rfc["state"] == "active" and _is_meta_branch_name(branch): + # §9.8 (LEGACY per-repo only): pre-graduation history branches are + # read-only on the post-graduation surface of a per-RFC-repo active + # entry. Under the meta-only topology (§1, §13.4) an active RFC's + # `edit--…` branches are its *current* editable branches, not + # a frozen pre-graduation set, so this guard applies only when a + # legacy `repo:` is set (nothing, after the RFC-0001 fold-back). + if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): return False if viewer.role in ("owner", "admin"): return True @@ -1302,7 +1324,7 @@ def make_router( def _require_can_contribute(slug: str, branch: str, viewer) -> None: rfc = db.conn().execute( - "SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", + "SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if not _can_contribute(rfc, slug, branch, viewer): diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 66443ee..6933145 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -1,26 +1,30 @@ -"""Slice 5 API surface — the §13 graduation flow's endpoints and the -in-process orchestrator that runs the §13.3 transactional sequence with -rollback. +"""§13 graduation flow — the meta-only in-place state flip. -Owns four routes per §17: +Under the meta-only topology (SPEC §1), graduation no longer creates a +per-RFC repo. It is a single frontmatter-flipping commit to the entry's +`rfcs/.md` on the meta repo: open a PR that re-serializes the entry +with `state: active`, the assigned integer `id`, `graduated_at` / +`graduated_by`, and the dialog's owners — **leaving the body unchanged** — +then auto-merge it. There is no repo to create, nothing to seed, and the +body is neither moved nor stripped, so there is no multi-step transaction +and no rollback (§13.3). If the open or merge fails, the entry stays a +super-draft and we clean up the half-open PR/branch (the only artifact a +mid-flip failure can leave behind). + +Routes (§17): - - GET /api/rfcs//blocking-prs (§13.2 precondition popover) - GET /api/rfcs//graduate/check (§13.2 debounced validator) - - POST /api/rfcs//graduate (§13.3 kickoff) + - POST /api/rfcs//graduate (§13.3 the flip) - GET /api/rfcs//graduate/progress (§13.3 SSE step stream) + - GET /api/rfcs//blocking-prs (informational; no longer a + graduation precondition) -Plus the §13.1 claim PR endpoint (POST /api/rfcs//claim), which is -graduation's prerequisite for non-admins per §13.1. +Plus the §13.1 claim PR endpoint (POST /api/rfcs//claim). The orchestrator runs in-process — each in-flight graduation lives in a small `GraduationState` keyed by slug, with an asyncio.Queue feeding the -SSE handler. Per the §13.3 transactional contract, every forward step is -paired with an undo; rollback runs the undos in reverse order from the -last step that completed. §13.4's chat migration is a database semantic -no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted -as super-draft canonical-body before graduation and as new-RFC main -afterwards — same shape, different meaning), so the only DB work the -sequence does is the audit-log rows the bot's `_log` writes per step. +SSE handler. §13.4's chat/branch/history are a database no-op: every row +is keyed by the slug per §2.3 and stays put across the flip. """ from __future__ import annotations @@ -44,24 +48,18 @@ log = logging.getLogger(__name__) # --------------------------------------------------------------------------- -# Step machine +# Step machine — two steps under meta-only: open the flip PR, merge it. # --------------------------------------------------------------------------- STEP_KEYS = ( - "create_repo", - "seed_files", "open_pr", "merge_pr", - "refresh_cache", ) STEP_LABELS = { - "create_repo": "Create per-RFC repository", - "seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml", - "open_pr": "Open meta-repo graduation PR", + "open_pr": "Open graduation PR (flip state to active)", "merge_pr": "Merge graduation PR", - "refresh_cache": "Refresh catalog and views", } @@ -77,8 +75,6 @@ class StepState: class GraduationState: slug: str rfc_id: str - repo_name: str - repo_full: str owners: list[str] arbiters: list[str] steps: list[StepState] @@ -86,8 +82,6 @@ class GraduationState: finished: bool = False succeeded: bool = False error: str | None = None - rollback_started: bool = False - rollback_steps: list[StepState] = field(default_factory=list) new_pr_number: int | None = None graduation_branch: str | None = None @@ -95,12 +89,9 @@ class GraduationState: return { "slug": self.slug, "rfc_id": self.rfc_id, - "repo_full": self.repo_full, "steps": [_step_payload(s) for s in self.steps], - "rollback_steps": [_step_payload(s) for s in self.rollback_steps], "finished": self.finished, "succeeded": self.succeeded, - "rolled_back": self.rollback_started, "error": self.error, "pr_number": self.new_pr_number, } @@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict: # is fine; the registry is keyed by slug to refuse concurrent graduations # of the same entry (the §13.2 atomic re-check is a separate defense # against a concurrent attempt of a DIFFERENT slug claiming the same -# integer ID or repo name). +# integer ID). _active: dict[str, GraduationState] = {} @@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None: return _active.get(slug) -def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str, +def _new_active(slug: str, *, rfc_id: str, owners: list[str], arbiters: list[str]) -> GraduationState: state = GraduationState( - slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, + slug=slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters, steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS], ) @@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str, # --------------------------------------------------------------------------- -# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes, -# dots, and underscores; cannot start with a dot. 100-char cap as a sane -# upper bound — the spec doesn't pin a max but Gitea's enforcement does. -_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$") _RFC_ID_RE = re.compile(r"^RFC-\d{4,}$") -def _is_valid_repo_name(name: str) -> bool: - return bool(_REPO_NAME_RE.match(name)) and ".." not in name - - def _is_valid_rfc_id(rfc_id: str) -> bool: return bool(_RFC_ID_RE.match(rfc_id)) @@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str: return f"RFC-{nxt:04d}" -def _suggest_repo_name(slug: str, rfc_id: str) -> str: - # rfc-NNNN- per §13.2's default. Strip the 'RFC-' prefix and - # lowercase the number-pad. - num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001" - return f"rfc-{num}-{slug}" - - def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool: row = db.conn().execute( "SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?", @@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool: class GraduateBody(BaseModel): rfc_id: str = Field(min_length=5, max_length=40) - repo_name: str = Field(min_length=1, max_length=100) owners: list[str] = Field(min_length=1) @@ -206,19 +181,17 @@ def make_router( router = APIRouter() # ------------------------------------------------------------------- - # §13.2: GET /api/rfcs//blocking-prs - # Lists open meta-repo PRs against rfcs/.md per the precondition - # popover. Returns PR number, title, author, last-activity timestamp, - # and the viewer's available actions (merge, withdraw, open-in-new-tab). + # GET /api/rfcs//blocking-prs + # Lists open meta-repo body-edit PRs against rfcs/.md. Under the + # meta-only topology (§9.8) these no longer block graduation — the body + # is kept, so a body-edit PR coexists with the flip. Retained as an + # informational surface (the dialog can show "N body-edit PRs open"). # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/blocking-prs") async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) rfc = _require_super_draft(slug) - # §13's opening paragraph: only body-edit PRs block graduation. - # Bare edit branches without an open PR do not block. The query - # filters cached_prs to open meta_body_edit kinds for this slug. rows = db.conn().execute( """ SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind @@ -261,14 +234,15 @@ def make_router( "open_in_new_tab": True, }, }) - return {"items": items} + # `blocking` is a legacy field name kept for client compatibility; + # under §9.8 these PRs do not block graduation. + return {"items": items, "blocking": False} # ------------------------------------------------------------------- - # §13.2: GET /api/rfcs//graduate/check?id=&repo= + # GET /api/rfcs//graduate/check?id= # Inline validation for the Graduate dialog — debounced from the - # client; the dialog calls this as the admin types. Returns per-field - # collision/validity from the catalog cache plus a server-authoritative - # repo-name collision check. + # client. Two fields under meta-only: the integer ID and the owners + # precondition. There is no repo name to validate (§13.2). # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/graduate/check") @@ -281,16 +255,7 @@ def make_router( # admins/owners, but the check itself is read-only. candidate_id = (request.query_params.get("id") or "").strip() - candidate_repo = (request.query_params.get("repo") or "").strip() - owners = json.loads(rfc["owners_json"] or "[]") - blocking_count = db.conn().execute( - """ - SELECT COUNT(*) AS n FROM cached_prs - WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' - """, - (slug,), - ).fetchone()["n"] # ID field id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None} @@ -304,34 +269,6 @@ def make_router( id_payload["ok"] = False id_payload["error"] = f"Integer ID {candidate_id} is already taken" - # Repo field — validate pattern then probe Gitea for an existing - # repo of that name under our org. The repo lookup is a single GET - # so it's cheap to call on every keystroke (debounced from the - # client per §13.2). - repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None} - if not candidate_repo: - repo_payload["ok"] = False - repo_payload["error"] = "Repo name is required" - elif not _is_valid_repo_name(candidate_repo): - repo_payload["ok"] = False - repo_payload["error"] = ( - "Repo name must be alphanumerics, dashes, dots, or underscores " - "(start with alphanumeric)" - ) - else: - try: - existing = await gitea.get_repo(config.gitea_org, candidate_repo) - except GiteaError as e: - # Network/auth flake — surface as a non-fatal hint; the - # atomic server-side check at POST time is the authority. - existing = None - log.warning("graduate_check: Gitea get_repo error: %s", e) - if existing is not None: - repo_payload["ok"] = False - repo_payload["error"] = ( - f"Repo `{config.gitea_org}/{candidate_repo}` already exists" - ) - # Owners precondition — §13's opening paragraph. owners_payload: dict[str, Any] = { "ok": len(owners) > 0, @@ -340,28 +277,13 @@ def make_router( "error": None if len(owners) > 0 else "No owners claimed yet", } - # Blocking PR precondition — §9.8 / §13's opening paragraph. - prs_payload: dict[str, Any] = { - "ok": blocking_count == 0, - "count": blocking_count, - "error": ( - None if blocking_count == 0 - else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation" - ), - } - in_flight = _get_active(slug) - any_invalid = not ( - id_payload["ok"] and repo_payload["ok"] - and owners_payload["ok"] and prs_payload["ok"] - ) + any_invalid = not (id_payload["ok"] and owners_payload["ok"]) return { "slug": slug, "id": id_payload, - "repo": repo_payload, "owners": owners_payload, - "blocking_prs": prs_payload, "can_submit": (not any_invalid) and (in_flight is None or in_flight.finished), "in_flight": ( None if in_flight is None @@ -370,8 +292,8 @@ def make_router( } # ------------------------------------------------------------------- - # §13.3: POST /api/rfcs//graduate - # Atomic re-validation, then kicks off the sequence as an async task. + # POST /api/rfcs//graduate + # Atomic re-validation, then kicks off the flip as an async task. # The client opens GET /graduate/progress on confirm to watch the SSE. # ------------------------------------------------------------------- @@ -392,9 +314,8 @@ def make_router( # §13.2 atomic re-validation. The dialog's debounced check runs # client-side as the admin types; this is the authoritative check - # that closes the dialog-open-to-confirm race. + # that closes the dialog-open-to-confirm race on the integer ID. rfc_id = body.rfc_id.strip() - repo_name = body.repo_name.strip() owners = [o.strip() for o in body.owners if o.strip()] if not owners: raise HTTPException(422, "Add at least one initial owner") @@ -402,35 +323,10 @@ def make_router( raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)") if _rfc_id_taken(rfc_id, excluding_slug=slug): raise HTTPException(409, f"Integer ID {rfc_id} is already taken") - if not _is_valid_repo_name(repo_name): - raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores") - try: - existing_repo = await gitea.get_repo(config.gitea_org, repo_name) - except GiteaError as e: - raise HTTPException(502, f"Gitea: {e.detail}") - if existing_repo is not None: - raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists") - - # §9.8 precondition gate — enforced before the bot starts the - # sequence so the §13.3 rollback complexity does not grow. An - # open body-edit PR against rfcs/.md would attempt to - # re-introduce a body to a frontmatter-only entry after step 3. - blocking = db.conn().execute( - """ - SELECT COUNT(*) AS n FROM cached_prs - WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' - """, - (slug,), - ).fetchone()["n"] - if blocking > 0: - raise HTTPException( - 409, - f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation", - ) # Read the meta-repo entry once — we need the file's sha for the - # graduation PR's update_file call and the original body so the - # bot can seed RFC.md on the new repo with the migrated body. + # graduation PR's update_file call and the body to carry through + # unchanged (meta-only keeps the body in the entry, §13.3). fetched = await gitea.read_file( config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", ) @@ -442,19 +338,17 @@ def make_router( except Exception as e: raise HTTPException(500, f"Meta entry malformed: {e}") - repo_full = f"{config.gitea_org}/{repo_name}" arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1] - # Compose the graduated frontmatter — body stripped, graduation - # fields filled. The serializer is run now so the PR-open step - # has the contents pre-rendered (single source of truth for the - # body migration vs. the meta-entry update). + # Compose the graduated frontmatter — body KEPT, graduation fields + # filled, repo left null (§1). Serialized now so the PR-open step + # has the contents pre-rendered. graduated_entry = entry_mod.Entry( slug=slug, title=super_draft_entry.title, state="active", id=rfc_id, - repo=repo_full, + repo=None, proposed_by=super_draft_entry.proposed_by, proposed_at=super_draft_entry.proposed_at, graduated_at=entry_mod.today(), @@ -462,37 +356,30 @@ def make_router( owners=owners, arbiters=arbiters, tags=list(super_draft_entry.tags), - body="", + models=super_draft_entry.models, + funder=super_draft_entry.funder, + body=super_draft_entry.body, ) graduated_contents = entry_mod.serialize(graduated_entry) state = _new_active( - slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full, - owners=owners, arbiters=arbiters, + slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters, ) # Audit: graduation started. The terminal `graduate_complete` / - # `graduate_rollback` rows below close the linkable sequence. + # `graduate_failed` rows below close the linkable sequence. _audit( viewer.user_id, viewer.gitea_login, "graduate_start", rfc_slug=slug, - details={ - "rfc_id": rfc_id, "repo": repo_full, "owners": owners, - "blocking_prs": blocking, - }, + details={"rfc_id": rfc_id, "owners": owners}, ) # Test seam: `?_sync=1` awaits the orchestrator inline so # integration tests can assert post-conditions without driving - # the SSE. Production clients use the spec-described shape — - # POST returns immediately, the client subscribes to the - # progress SSE. + # the SSE. Production clients POST then subscribe to the SSE. coro = _orchestrate( config=config, gitea=gitea, bot=bot, actor=viewer.as_actor(), state=state, - super_draft_body=super_draft_entry.body, - super_draft_title=super_draft_entry.title, - super_draft_tags=list(super_draft_entry.tags), graduated_contents=graduated_contents, meta_file_sha=meta_sha, ) @@ -505,38 +392,31 @@ def make_router( "ok": True, "slug": slug, "rfc_id": rfc_id, - "repo": repo_full, "stream_url": f"/api/rfcs/{slug}/graduate/progress", "finished": state.finished, "succeeded": state.succeeded, } # ------------------------------------------------------------------- - # §13.3: GET /api/rfcs//graduate/progress - # SSE stream of the step transitions. One event per step transition - # (pending → running → done / failed), plus the trailing rollback - # step's events if any earlier step fails. + # GET /api/rfcs//graduate/progress + # SSE stream of the flip's step transitions (open_pr, merge_pr). # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/graduate/progress") async def graduate_progress(slug: str, request: Request): - # v0.6.0 (item #4): the progress SSE surfaces admin-internal step - # detail (repo name, PR number, rollback steps) that isn't part of - # the v0.3.0 anonymous-read contract for catalog/RFC bodies. The - # corresponding POST /graduate is gated to RFC owners/arbiters and - # app admins/owners via `_can_graduate`; the read SSE shares that - # operator-visible surface, so it requires at least an - # authenticated viewer. We keep the floor at require_user (not - # require_contributor) so a write-muted operator can still observe - # the progress of a graduation they kicked off before being muted. + # The progress SSE surfaces admin-internal step detail (PR number) + # that isn't part of the anonymous-read contract. POST /graduate is + # gated to RFC owners/arbiters and app admins/owners; the read SSE + # shares that operator-visible surface and requires an authenticated + # viewer. We keep the floor at require_user (not require_contributor) + # so a write-muted operator can still observe a graduation they + # kicked off before being muted. auth.require_user(request) state = _get_active(slug) if state is None: raise HTTPException(404, "No graduation in flight for this slug") async def event_stream(): - # Emit the current snapshot first so a late subscriber sees - # the steps already completed. yield _sse_event("snapshot", state.to_payload()) if state.finished: yield _sse_event("done", state.to_payload()) @@ -555,22 +435,16 @@ def make_router( # §13.1: POST /api/rfcs//claim # Opens a meta-repo PR adding the actor's gitea_login to the entry's # owners list. Anyone signed in may claim — the merge is gated to - # owners/admins per §13.1 (which collapses to admins for unclaimed - # entries since `owners` is empty). + # owners/admins per §13.1. # ------------------------------------------------------------------- @router.post("/api/rfcs/{slug}/claim") async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) rfc = _require_super_draft(slug) - # Refuse if the actor is already in owners — no-op claim. existing_owners = json.loads(rfc["owners_json"] or "[]") if viewer.gitea_login in existing_owners: return {"ok": True, "noop": True} - # Refuse if a claim PR for this actor is already open. The branch - # name `claim/` collides per actor implicitly since Gitea - # refuses duplicate branch creation; we surface a clean 409 here - # so the client doesn't see a 502. already = db.conn().execute( """ SELECT pr_number FROM cached_prs @@ -581,8 +455,6 @@ def make_router( if already: raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}") - # Compose the new entry contents — owners list with the claimant - # appended. fetched = await gitea.read_file( config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main", ) @@ -626,7 +498,7 @@ def make_router( # --------------------------------------------------------------------------- -# Orchestrator +# Orchestrator — the §13.3 in-place flip # --------------------------------------------------------------------------- @@ -637,57 +509,21 @@ async def _orchestrate( bot: Bot, actor: Actor, state: GraduationState, - super_draft_body: str, - super_draft_title: str, - super_draft_tags: list[str], graduated_contents: str, meta_file_sha: str, ) -> None: - """Run §13.3 step by step. Each step: + """Open the flip PR, then merge it. Two steps, no transaction: - - marks itself `running` and pushes an event - - calls the bot method (which writes to Gitea + audit log) - - marks itself `done` (or `failed`) and pushes another event + - open_pr fails → nothing was created; the entry stays a super-draft. + - merge_pr fails → close the open PR and delete its branch (the only + artifact a mid-flip failure can leave on the meta repo), then the + entry stays a super-draft. - On failure at step N, every later step is marked `not-reached` and - `_rollback` runs undoes in reverse from N-1 to 1. + There is no rollback of a *merged* flip — once the meta-repo merge has + landed, the path forward is §3's `withdraw` (§13.5). """ try: - # ----- Step 1: create per-RFC repo ----- - await _start(state, "create_repo", f"Creating `{state.repo_full}`…") - try: - await bot.create_rfc_repo_for_graduation( - actor, org=config.gitea_org, repo_name=state.repo_name, - slug=state.slug, title=super_draft_title, - ) - except GiteaError as e: - await _fail(state, "create_repo", f"Gitea: {e.detail}") - await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, - state=state, failed_at="create_repo") - return - await _done(state, "create_repo", state.repo_full) - - # ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml ----- - await _start(state, "seed_files", "Writing initial commit on main…") - try: - await bot.seed_graduated_rfc( - actor, - org=config.gitea_org, repo_name=state.repo_name, - slug=state.slug, title=super_draft_title, - rfc_body=super_draft_body, rfc_id=state.rfc_id, - meta_full=config.meta_repo_full, - meta_path=f"rfcs/{state.slug}.md", - owners=state.owners, arbiters=state.arbiters, - tags=super_draft_tags, - ) - except GiteaError as e: - await _fail(state, "seed_files", f"Gitea: {e.detail}") - await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, - state=state, failed_at="seed_files") - return - await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml") - - # ----- Step 3: open graduation PR ----- + # ----- Step 1: open the graduation PR (flip frontmatter) ----- await _start(state, "open_pr", "Opening graduation PR…") try: pr = await bot.open_graduation_pr( @@ -696,19 +532,18 @@ async def _orchestrate( slug=state.slug, new_file_contents=graduated_contents, prior_sha=meta_file_sha, - rfc_id=state.rfc_id, repo_full=state.repo_full, + rfc_id=state.rfc_id, owners=state.owners, ) except GiteaError as e: await _fail(state, "open_pr", f"Gitea: {e.detail}") - await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, - state=state, failed_at="open_pr") + await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login) return state.new_pr_number = pr["number"] state.graduation_branch = pr["head"]["ref"] await _done(state, "open_pr", f"PR #{state.new_pr_number}") - # ----- Step 4: merge the graduation PR ----- + # ----- Step 2: merge the graduation PR ----- await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}…") try: await bot.merge_graduation_pr( @@ -720,35 +555,28 @@ async def _orchestrate( ) except GiteaError as e: await _fail(state, "merge_pr", f"Gitea: {e.detail}") - await _rollback(config=config, gitea=gitea, bot=bot, actor=actor, - state=state, failed_at="merge_pr") + await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state) + await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login) return await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged") - # ----- Step 5: refresh the cache so the catalog flips immediately. - # Per §13.3 step 5 the webhook flow is the steady-state path, but - # we refresh inline so the dialog can transition to "graduation - # complete" with the catalog row already showing `active`. A - # cache-refresh failure does not unwind Git state — the - # reconciler will catch up per §4.1. - await _start(state, "refresh_cache", "Refreshing catalog and views…") + # Refresh the cache so the catalog flips immediately. The webhook + # flow is the steady-state path (§13.3); we refresh inline so the + # dialog can transition to "graduation complete" with the catalog + # row already showing `active`. A refresh failure does not unwind + # the merge — the reconciler catches up per §4.1. try: await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) - await cache.refresh_rfc_repo(config, gitea, state.slug) except Exception as e: - log.warning("graduate refresh_cache failed for %s: %s", state.slug, e) - await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})") - else: - await _done(state, "refresh_cache", "Catalog and main view updated") + log.warning("graduate cache refresh failed for %s: %s", state.slug, e) - # Terminal success row in the audit log. _audit( None, actor.gitea_login, "graduate_complete", rfc_slug=state.slug, details={ - "rfc_id": state.rfc_id, "repo": state.repo_full, + "rfc_id": state.rfc_id, "owners": state.owners, "pr_number": state.new_pr_number, }, ) @@ -757,109 +585,38 @@ async def _orchestrate( await state.queue.put({"event": "completed", "payload": state.to_payload()}) except Exception as e: log.exception("graduate: unexpected error for %s", state.slug) - # Best-effort: mark the in-flight step failed, then roll back. running = next((s for s in state.steps if s.status == "running"), None) if running is not None: await _fail(state, running.key, f"unexpected: {e}") - await _rollback( - config=config, gitea=gitea, bot=bot, actor=actor, - state=state, failed_at=running.key if running else "unknown", + await _finish_failed( + state, failed_at=running.key if running else "unknown", + on_behalf_of=actor.gitea_login, ) finally: # Push the sentinel so any open SSE handler returns. await state.queue.put(None) -async def _rollback( - *, - config: Config, gitea: Gitea, bot: Bot, actor: Actor, - state: GraduationState, failed_at: str, +async def _cleanup_unmerged( + *, config: Config, bot: Bot, actor: Actor, state: GraduationState, ) -> None: - """Run undoes in reverse order from the last completed step. Each - undo emits its own rollback-step event so the dialog can render the - cleanup as a visible step appended to the stack per §13.3.""" - state.rollback_started = True - # Mark every step after the failed one as not-reached so the rendered - # stack is honest about what didn't run. - seen_failure = False - for s in state.steps: - if s.status == "failed": - seen_failure = True - continue - if seen_failure and s.status == "pending": - s.status = "not-reached" - - # Walk completed steps in reverse and run their inverses. - for s in reversed(state.steps): - if s.status != "done": - continue - undo = _UNDO_BY_STEP.get(s.key) - if undo is None: - continue - rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}", - status="running", detail="") - state.rollback_steps.append(rb) - await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) - try: - detail = await undo( - config=config, gitea=gitea, bot=bot, actor=actor, state=state, - ) - except Exception as e: - rb.status = "failed" - rb.detail = f"{e}" - await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) - continue - rb.status = "done" - rb.detail = detail or "" - await state.queue.put({"event": "rollback_step", "payload": state.to_payload()}) - - _audit( - None, actor.gitea_login, "graduate_rollback", - rfc_slug=state.slug, - details={ - "failed_at": failed_at, - "error": state.error, - "rfc_id": state.rfc_id, - "repo": state.repo_full, - "undone": [s.key for s in state.rollback_steps if s.status == "done"], - }, - ) - state.finished = True - state.succeeded = False - await state.queue.put({"event": "rolled_back", "payload": state.to_payload()}) - - -async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str: - await bot.delete_rfc_repo( - actor, org=config.gitea_org, repo_name=state.repo_name, - slug=state.slug, reason="graduation rollback", - ) - return f"Deleted `{state.repo_full}`" - - -async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str: - # The seed commits live inside the per-RFC repo created in step 1; - # deleting the repo (step 1's undo) reclaims them at the same time. - # We surface a separate rollback step here so the rendered stack - # mirrors the forward steps, but the work is folded into _undo_create_repo. - return "Folded into repo deletion" - - -async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str: + """A merge failure leaves the flip PR open on its `graduate--` + branch. Close the PR and delete the branch so failed attempts don't + accumulate on the meta repo. Best-effort — failures here are logged, + not surfaced as a separate step (the entry already stays a super-draft). + """ if state.new_pr_number is None: - return "No PR opened" - await bot.close_graduation_pr( - actor, - org=config.gitea_org, meta_repo=config.meta_repo, - pr_number=state.new_pr_number, - head_branch=state.graduation_branch or "", - slug=state.slug, reason="graduation rollback", - ) - # Per the §19.2 "graduation rollback's branch cleanup" candidate - # that Slice 8 settles: delete the dash-suffixed branch on rollback - # so failed-graduation branches don't accumulate on the meta repo. - # The §12 hygiene sweep would catch this eventually, but closing - # the loop here removes the chance of pile-up across retries. + return + try: + await bot.close_graduation_pr( + actor, + org=config.gitea_org, meta_repo=config.meta_repo, + pr_number=state.new_pr_number, + head_branch=state.graduation_branch or "", + slug=state.slug, reason="graduation merge failed", + ) + except Exception: + log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number) branch_name = state.graduation_branch or "" if branch_name: try: @@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str: branch=branch_name, slug=state.slug, action_kind="delete_post_merge_branch", - reason="graduation rollback", + reason="graduation merge failed", ) except Exception: - log.exception("rollback: delete_branch failed for %s", branch_name) - return f"Closed PR #{state.new_pr_number}" + log.exception("graduate cleanup: delete_branch %s failed", branch_name) -# merge_pr's undo is intentionally absent — once the meta-repo merge has -# landed, graduation is irreversible per §13.5. If we ever reach a merged -# state and a later step fails (which can't happen — refresh_cache failures -# fold into success), there is no clean undo path; the user transitions -# via §3's `withdraw` instead. - -_UNDO_BY_STEP = { - "create_repo": _undo_create_repo, - "seed_files": _undo_seed_files, - "open_pr": _undo_open_pr, -} +async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None: + """Mark any step after the failure as not-reached, write the audit + row, and emit the terminal failed event.""" + seen_failure = False + for s in state.steps: + if s.status == "failed": + seen_failure = True + continue + if seen_failure and s.status == "pending": + s.status = "not-reached" + _audit( + None, on_behalf_of, "graduate_failed", + rfc_slug=state.slug, + details={ + "failed_at": failed_at, + "error": state.error, + "rfc_id": state.rfc_id, + "pr_number": state.new_pr_number, + }, + ) + state.finished = True + state.succeeded = False + await state.queue.put({"event": "failed", "payload": state.to_payload()}) # --------------------------------------------------------------------------- @@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool: def _audit( actor_user_id: int | None, - on_behalf_of: str, + on_behalf_of: str | None, action_kind: str, *, rfc_slug: str | None = None, @@ -918,7 +686,7 @@ def _audit( """Direct audit-log write for graduation lifecycle events that don't correspond to a single Gitea write. The per-step Gitea writes log themselves via the bot's `_log`; this is for the bracketing - `graduate_start` / `graduate_complete` / `graduate_rollback` rows.""" + `graduate_start` / `graduate_complete` / `graduate_failed` rows.""" db.conn().execute( """ INSERT INTO actions @@ -935,8 +703,7 @@ def _audit( json.dumps(details) if details else None, ), ) - # §15 chokepoint per Slice 6: the bracket rows (graduate_start, - # graduate_complete) drive their own notifications per §15.1. + # §15 chokepoint: the bracket rows drive their own notifications. from . import notify notify.fan_out_from_action( actor_user_id=actor_user_id, diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 21668dd..69630c8 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -603,7 +603,7 @@ def make_router( repo=repo, slug=slug, file_path=_file_path_for(rfc), - is_super_draft=_is_super_draft(rfc), + is_super_draft=_is_meta_resident(rfc), original_branch=original_branch, resolution_branch=resolution_branch, ) @@ -671,32 +671,36 @@ def make_router( """Used by the §10 PR-flow read and write paths. Per §17's routing- collapse rule, a super-draft RFC also routes here — its body-edit PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API - surface is identical.""" + surface is identical. Under the meta-only topology (§1) an active + RFC is meta-resident too (repo is null) — that is normal, not an + error, so there is no per-RFC-repo precondition.""" row = _require_rfc(slug) if row["state"] not in ("active", "super-draft"): raise HTTPException(409, f"RFC is {row['state']}") - if row["state"] == "active" and not row["repo"]: - raise HTTPException(409, "RFC has no repo") return row - def _is_super_draft(rfc) -> bool: - return rfc["state"] == "super-draft" + def _is_meta_resident(rfc) -> bool: + """Meta-only topology (§1): an entry lives in the meta repo's + `rfcs/.md` (super-draft or active-in-place) unless it carries + a legacy per-RFC `repo:` — which nothing does after the RFC-0001 + fold-back (§13.6). Drives the body/path/repo dispatch below.""" + return not rfc["repo"] def _owner_repo(rfc) -> tuple[str, str]: - if _is_super_draft(rfc): + if _is_meta_resident(rfc): return config.gitea_org, config.meta_repo owner, repo = rfc["repo"].split("/", 1) return owner, repo def _file_path_for(rfc) -> str: - if _is_super_draft(rfc): + if _is_meta_resident(rfc): return f"rfcs/{rfc['slug']}.md" return RFC_FILE_PATH def _extract_body(rfc, file_contents: str) -> str: - """For super-draft entries the file on disk is the full + """For meta-resident entries the file on disk is the full frontmatter+body envelope; the editable body is entry.body.""" - if not _is_super_draft(rfc): + if not _is_meta_resident(rfc): return file_contents try: entry = entry_mod.parse(file_contents) @@ -760,7 +764,7 @@ def make_router( return row["original_pr_number"] if row else None async def _refresh_after_pr_write(rfc) -> None: - if _is_super_draft(rfc): + if _is_meta_resident(rfc): await cache.refresh_meta_repo(config, gitea) await cache.refresh_meta_branches(config, gitea) await cache.refresh_meta_pulls(config, gitea) diff --git a/backend/app/bot.py b/backend/app/bot.py index 4e116ab..0a874b0 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -695,111 +695,7 @@ class Bot: ) return sha - # ----- §13 graduation: per-step primitives and rollback inverses ----- - - async def create_rfc_repo_for_graduation( - self, - actor: Actor, - *, - org: str, - repo_name: str, - slug: str, - title: str, - ) -> dict: - """§13.3 step 1: create the per-RFC repo. - - Empty repo (no auto-init) — `seed_graduated_rfc` writes the first - commit on `main`. Returns the Gitea repo payload.""" - repo = await self._gitea.create_org_repo( - org, repo_name, description=f"RFC: {title}" - ) - _log( - actor, - "graduate_repo_create", - rfc_slug=slug, - details={"repo": f"{org}/{repo_name}", "title": title}, - ) - return repo - - async def seed_graduated_rfc( - self, - actor: Actor, - *, - org: str, - repo_name: str, - slug: str, - title: str, - rfc_body: str, - rfc_id: str, - meta_full: str, - meta_path: str, - owners: list[str], - arbiters: list[str], - tags: list[str], - ) -> str: - """§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the - new repo's `main`. Three create_file calls; one audit row. - - Returns the final commit sha on main. - """ - import yaml as _yaml - - ae = actor.email or f"{actor.gitea_login}@users.noreply" - # 2a) RFC.md — the document. The super-draft's body is migrated - # verbatim per §13.3; if the body is empty we seed a minimal - # placeholder so the editor has something to render on first open. - body = rfc_body.strip() + "\n" if rfc_body.strip() else ( - f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n" - ) - rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor) - rfc_result = await self._gitea.create_file( - org, repo_name, "RFC.md", - content=body, message=rfc_msg, branch="main", - author_name=actor.display_name, author_email=ae, - ) - # 2b) README.md — header pointing back at the meta-repo entry. - readme = ( - f"# {rfc_id} — {title}\n\n" - f"This repository carries the canonical text of {rfc_id}.\n" - f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n" - f"The RFC body is in `RFC.md`. Contributions go through the\n" - f"app's §8 RFC view — open a branch, propose changes, land a PR.\n" - ) - readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor) - await self._gitea.create_file( - org, repo_name, "README.md", - content=readme, message=readme_msg, branch="main", - author_name=actor.display_name, author_email=ae, - ) - # 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for - # future tooling (linting, automation, CI lookups). - meta_yaml = _yaml.safe_dump( - { - "slug": slug, "title": title, "id": rfc_id, - "owners": owners, "arbiters": arbiters, "tags": list(tags), - }, - sort_keys=False, - ) - meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor) - meta_result = await self._gitea.create_file( - org, repo_name, ".rfc/metadata.yaml", - content=meta_yaml, message=meta_msg, branch="main", - author_name=actor.display_name, author_email=ae, - ) - last_sha = ( - meta_result.get("commit", {}).get("sha") - or rfc_result.get("commit", {}).get("sha") - or "" - ) - _log( - actor, - "graduate_repo_seed", - rfc_slug=slug, - branch_name="main", - bot_commit_sha=last_sha, - details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id}, - ) - return last_sha + # ----- §13 graduation (meta-only): open + merge the flip PR ----- async def open_graduation_pr( self, @@ -811,13 +707,14 @@ class Bot: new_file_contents: str, prior_sha: str, rfc_id: str, - repo_full: str, owners: list[str], ) -> dict: - """§13.3 step 3: open a PR against the meta repo that strips the - super-draft body and fills graduation frontmatter fields. Branch - name uses the `graduate--<6hex>` shape — dash-separated like - the other meta-repo branches per the §19.2 path-routing candidate. + """§13.3 (meta-only): open a PR against the meta repo that flips the + entry's frontmatter to `state: active` with the integer `id` and + graduation stamps — **keeping the body unchanged** (§1 meta-only + topology; no repo is created and no body is stripped). Branch name + uses the `graduate--<6hex>` shape — dash-separated like the + other meta-repo branches per the §19.2 path-routing candidate. """ import secrets @@ -844,11 +741,11 @@ class Bot: pr_body_text = ( f"Graduates super-draft `{slug}` to active.\n\n" f"- ID: `{rfc_id}`\n" - f"- Repo: `{repo_full}`\n" f"- Owners: {owners_str}\n\n" - f"The meta-repo entry becomes frontmatter-only; the canonical body\n" - f"moves to `RFC.md` in the new repo. The graduation sequence is\n" - f"transactional per §13.3." + 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"stays in the meta repo. Only the frontmatter changes — `state`,\n" + f"`id`, and the graduation stamps." ) _subject, pr_body = _stamp("", pr_body_text, actor) pr = await self._gitea.create_pull( @@ -862,7 +759,7 @@ class Bot: branch_name=branch, pr_number=pr["number"], bot_commit_sha=commit_sha, - details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full}, + details={"pr_title": pr_title, "rfc_id": rfc_id}, ) return pr @@ -912,27 +809,7 @@ class Bot: details={"rfc_id": rfc_id}, ) - # ----- §13.3 rollback inverses ----- - - async def delete_rfc_repo( - self, - actor: Actor, - *, - org: str, - repo_name: str, - slug: str, - reason: str, - ) -> None: - """Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete` - in the audit log with the rollback reason so the §13.3 stack's - rendered failure surface can be reconstructed from `actions`.""" - await self._gitea.delete_repo(org, repo_name) - _log( - actor, - "graduate_repo_delete", - rfc_slug=slug, - details={"repo": f"{org}/{repo_name}", "reason": reason}, - ) + # ----- §13.3 (meta-only): cleanup of an unmerged flip PR ----- async def close_graduation_pr( self, diff --git a/backend/app/cache.py b/backend/app/cache.py index 1fe0815..3771671 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -342,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: if not slug: continue rfc = db.conn().execute( - "SELECT state FROM cached_rfcs WHERE slug = ?", (slug,) + "SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() - if not rfc or rfc["state"] != "super-draft": + # Meta-only topology (§1): edit branches live on the meta repo for + # every meta-resident entry — super-drafts and active RFCs alike + # (active RFCs are graduated in place and keep editing here, §13). + # A legacy per-RFC repo (repo set) is the only thing excluded. + if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"): continue edit_keys_seen.add((slug, name)) db.conn().execute( @@ -365,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: # diverges from this single point. if meta_main_sha: super_drafts = db.conn().execute( - "SELECT slug FROM cached_rfcs WHERE state = 'super-draft'" + "SELECT slug FROM cached_rfcs " + "WHERE repo IS NULL AND state IN ('super-draft', 'active')" ).fetchall() for r in super_drafts: db.conn().execute( @@ -387,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: SELECT b.rfc_slug, b.branch_name FROM cached_branches b JOIN cached_rfcs r ON r.slug = b.rfc_slug - WHERE r.state = 'super-draft' + WHERE r.repo IS NULL + AND r.state IN ('super-draft', 'active') AND b.state != 'deleted' AND b.branch_name != 'main' """ diff --git a/backend/app/hygiene.py b/backend/app/hygiene.py index fad8db1..a742f81 100644 --- a/backend/app/hygiene.py +++ b/backend/app/hygiene.py @@ -284,9 +284,10 @@ async def _delete_branch_via_bot( reason: str, ) -> bool: """Call `bot.delete_branch` with the system actor. Resolves the - `(org, repo)` pair from the slug: super-draft edit branches and - graduation branches live on the meta repo; active-RFC branches - live on the per-RFC repo named by `cached_rfcs.repo`. + `(org, repo)` pair from the slug: under the meta-only topology (§1) + every meta-resident entry's edit branches and graduation branches + live on the meta repo; a legacy per-RFC repo (a `repo:` that survives + from before the fold-back, §13.6) is named by `cached_rfcs.repo`. Returns True on a clean delete; False if the rfc row is missing (we leave the branch row in place — a subsequent reconciler sweep @@ -297,12 +298,13 @@ async def _delete_branch_via_bot( if rfc is None: log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch) return False - if rfc["state"] == "super-draft": + if not rfc["repo"]: owner, repo = config.gitea_org, config.meta_repo - elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]: + elif "/" in rfc["repo"]: owner, repo = rfc["repo"].split("/", 1) else: - log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"]) + log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r", + slug, rfc["state"], rfc["repo"]) return False try: await bot.delete_branch( diff --git a/backend/tests/test_e2e_smoke.py b/backend/tests/test_e2e_smoke.py index 94213f8..e9b7f1c 100644 --- a/backend/tests/test_e2e_smoke.py +++ b/backend/tests/test_e2e_smoke.py @@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea): r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge") assert r.status_code == 200, r.text - # --- 7. Graduate the super-draft. --- + # --- 7. Graduate the super-draft (in-place flip, §13). --- r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0001", "owners": ["ben"]}, ) assert r.status_code == 200, r.text assert r.json()["succeeded"] is True d = client.get("/api/rfcs/ohm").json() assert d["state"] == "active" - assert d["repo"] == "wiggleverse/rfc-0001-ohm" + # Meta-only topology (§1): no per-RFC repo — the active RFC lives + # in its meta entry, `repo` stays null. + assert d["repo"] is None - # --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. --- + # --- 8. Alice opens a PR on the now-active RFC (meta repo). --- # v0.16.0 (item #12): ben is the RFC owner now; alice needs a # per-RFC contributor invitation to cut a branch. In the # production flow, ben would invite her via /invitations and @@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea): ) assert counters["deleted_post_merge"] >= 1, counters - # The branch is gone from FakeGitea + cached row flipped. - assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")] + # The branch is gone from FakeGitea (meta repo) + cached row flipped. + assert active_branch not in fake.branches[("wiggleverse", "meta")] cached = db.conn().execute( "SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?", (active_branch,), diff --git a/backend/tests/test_graduation_vertical.py b/backend/tests/test_graduation_vertical.py index bc042b0..5e03376 100644 --- a/backend/tests/test_graduation_vertical.py +++ b/backend/tests/test_graduation_vertical.py @@ -1,29 +1,29 @@ -"""End-to-end integration tests for the Slice 5 vertical (§13 in full). +"""End-to-end integration tests for the §13 graduation flow under the +meta-only topology (SPEC §1, ROADMAP #36). -Walks the §13.3 transactional sequence end-to-end against the in-process -FakeGitea from test_propose_vertical.py: +Graduation is an in-place state flip on the meta entry — no per-RFC repo +is created, the body is kept, and there is no multi-step transaction or +rollback (§13.3). These tests walk it against the in-process FakeGitea +from test_propose_vertical.py: - * Seed an owned super-draft (skipping the propose+merge + §13.1 claim - round-trips already proven by Slice 1 and exercised in - test_claim_opens_meta_pr below for the §13.1 surface itself). + * Seed an owned super-draft (the §13.1 claim flow is exercised + separately in test_claim_opens_meta_pr). * GET /api/rfcs//graduate/check returns per-field validity for - the dialog. - * GET /api/rfcs//blocking-prs returns the §9.8 precondition list. - * POST /api/rfcs//graduate?_sync=1 runs the five-step sequence - inline. On success: per-RFC repo exists with RFC.md / README.md / - .rfc/metadata.yaml, meta-entry body is stripped, frontmatter is - graduated, cached_rfcs.state is 'active'. - * §9.8 precondition gate refuses the start when a body-edit PR is open. - * Rollback on a mid-sequence failure unwinds repo creation cleanly. - * §13.4 chat migration: whole-doc threads under (slug, 'main') survive - graduation unchanged — the rfc_slug is the canonical key per §2.3, - so no data movement is needed. - * §9.8 pre-graduation history: the new RFC's /main response surfaces - edit-branch threads under `pre_graduation_history`. + the two-field dialog (integer id + owners; no repo name). + * POST /api/rfcs//graduate?_sync=1 opens + merges the flip PR + inline. On success: NO per-RFC repo, the meta entry is `state: + active` with the body KEPT and `repo` null, cached_rfcs.state flips + to 'active'. + * An open body-edit PR no longer blocks graduation (§9.8) — they + coexist. + * An open-PR failure leaves the entry a super-draft (nothing created); + a merge failure cleans up the half-open PR/branch and leaves the + entry a super-draft. + * §13.4: chat threads + edit branches stay put — the slug is the + canonical key per §2.3, so nothing moves at the flip. -The orchestrator's `?_sync=1` seam awaits the sequence inline so the -test can assert post-conditions on the same event loop tick. Production -clients use the spec-described SSE shape via `/graduate/progress`. +The orchestrator's `?_sync=1` seam awaits the flip inline so the test can +assert post-conditions on the same event loop tick. """ from __future__ import annotations @@ -110,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str # --------------------------------------------------------------------------- -def test_graduate_check_validates_three_fields(app_with_fake_gitea): +def test_graduate_check_validates_id_and_owners(app_with_fake_gitea): + """Two-field dialog under meta-only: integer id + owners. No repo + name to validate (§13.2).""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea @@ -121,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea): sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - # Happy: a fresh RFC-0001 + rfc-0001-ohm repo name. - r = client.get("/api/rfcs/ohm/graduate/check", - params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) + # Happy: a fresh RFC-0001. + r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}) assert r.status_code == 200, r.text d = r.json() assert d["id"]["ok"] is True - assert d["repo"]["ok"] is True assert d["owners"]["ok"] is True - assert d["blocking_prs"]["ok"] is True assert d["can_submit"] is True + # No repo field in the meta-only check response. + assert "repo" not in d # ID format error — non-numeric tail. - r = client.get("/api/rfcs/ohm/graduate/check", - params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"}) + r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"}) d = r.json() assert d["id"]["ok"] is False assert d["can_submit"] is False - # Repo name pattern error — leading dot. - r = client.get("/api/rfcs/ohm/graduate/check", - params={"id": "RFC-0001", "repo": ".bad"}) - d = r.json() - assert d["repo"]["ok"] is False - def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea): """An unclaimed super-draft fails the owners precondition; can_submit - flips false even with valid id+repo.""" + flips false even with a valid id.""" 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") - # No owners — simulates an unclaimed super-draft. seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[]) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - r = client.get("/api/rfcs/ohm/graduate/check", - params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) + r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}) d = r.json() assert d["owners"]["ok"] is False assert "No owners" in d["owners"]["error"] assert d["can_submit"] is False -def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea): - """The full §13.3 sequence: create repo, seed files, open PR, merge - PR, refresh cache. End state: cached_rfcs.state='active', the meta - entry's body is stripped, the per-RFC repo has RFC.md, the audit - log carries graduate_start → graduate_complete bracketing the - per-step rows.""" +def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea): + """The meta-only flip: open + merge a frontmatter PR. End state: + cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is + null, NO per-RFC repo exists, and the audit log carries graduate_start + → graduate_pr_open → graduate_pr_merge → graduate_complete.""" from fastapi.testclient import TestClient from app import db, entry as entry_mod @@ -186,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0042", "owners": ["ben"]}, ) assert r.status_code == 200, r.text d = r.json() assert d["finished"] is True assert d["succeeded"] is True - assert d["repo"] == "wiggleverse/rfc-0042-ohm" + # No repo in the response, no per-RFC repo on Gitea. + assert "repo" not in d + assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos + assert not any( + k[1].startswith("rfc-0042") for k in fake.repos + ), f"a per-RFC repo was created: {fake.repos}" - # 1. Per-RFC repo exists on Gitea. - assert ("wiggleverse", "rfc-0042-ohm") in fake.repos - # 2. Seed files landed on main. - assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files - assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files - assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files - rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"] - assert "Open Human Model is a framework" in rfc_md - # 3. Meta entry body is stripped + frontmatter graduated. + # Meta entry on main: state flipped, body KEPT, repo null. meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] graduated = entry_mod.parse(meta_text) assert graduated.state == "active" assert graduated.id == "RFC-0042" - assert graduated.repo == "wiggleverse/rfc-0042-ohm" + assert graduated.repo is None assert graduated.graduated_by == "ben" assert graduated.graduated_at # non-empty ISO date - assert graduated.body.strip() == "" - # 5. cached_rfcs.state flipped to active via the inline refresh. + assert "Open Human Model is a framework" in graduated.body + + # cached_rfcs flipped to active via the inline refresh; body intact. cached = db.conn().execute( "SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'" ).fetchone() assert cached["state"] == "active" assert cached["rfc_id"] == "RFC-0042" - assert cached["repo"] == "wiggleverse/rfc-0042-ohm" - # cached body now mirrors RFC.md from the per-RFC repo. + assert cached["repo"] is None assert "Open Human Model is a framework" in cached["body"] - # Audit log: graduate_start, graduate_repo_create, graduate_repo_seed, - # graduate_pr_open, graduate_pr_merge, graduate_complete, in order. kinds = [ - r["action_kind"] - for r in db.conn().execute( + row["action_kind"] + for row in db.conn().execute( "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" ) ] - for needed in ("graduate_start", "graduate_repo_create", - "graduate_repo_seed", "graduate_pr_open", + for needed in ("graduate_start", "graduate_pr_open", "graduate_pr_merge", "graduate_complete"): assert needed in kinds, f"missing audit row {needed}: {kinds}" + # The retired per-repo steps must NOT appear. + for gone in ("graduate_repo_create", "graduate_repo_seed", + "graduate_repo_delete", "graduate_rollback"): + assert gone not in kinds, f"retired audit row present: {gone}" -def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): - """§9.8: an open meta-repo body-edit PR against rfcs/.md blocks - graduation before the bot starts the sequence — §13.3's rollback - complexity does not grow.""" +def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea): + """§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks + graduation — the body is kept, so they coexist. /check stays + submittable and the flip succeeds.""" from fastapi.testclient import TestClient from app import db @@ -249,13 +237,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): provision_user_row(user_id=2, login="alice", role="contributor") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) - # v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC - # contributor invitation to cut an edit branch on the super-draft. grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") - # Cut an edit branch and open a body-edit PR (full Slice 4 path). + # Cut an edit branch and open a body-edit PR. branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() thread_id = view["main_thread_id"] @@ -279,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea): f"/api/rfcs/ohm/branches/{branch}/open-pr", json={"title": "Add harm", "description": "Adds harm dimension."}, ).json()["pr_number"] + assert pr_number # PR is open - # /blocking-prs surfaces it. + # /check stays submittable despite the open body-edit PR. sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - r = client.get("/api/rfcs/ohm/blocking-prs") - items = r.json()["items"] - assert len(items) == 1 - assert items[0]["pr_number"] == pr_number + d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json() + assert "blocking_prs" not in d + assert d["can_submit"] is True - # /check refuses can_submit. - r = client.get("/api/rfcs/ohm/graduate/check", - params={"id": "RFC-0001", "repo": "rfc-0001-ohm"}) - d = r.json() - assert d["blocking_prs"]["ok"] is False - assert d["can_submit"] is False - - # POST refuses with 409 — the bot never starts the sequence. + # The flip succeeds — coexists with the open body-edit PR. r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0001", "owners": ["ben"]}, ) - assert r.status_code == 409 - assert "blocking graduation" in r.text or "block" in r.text - - -def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea): - """Step 2 (seed files) fails partway → the orchestrator rolls back - step 1 (delete the repo) and records the rollback in the audit log. - The cached_rfcs row stays at 'super-draft'.""" - from fastapi.testclient import TestClient - from app import db - from app.bot import Bot - from app.gitea import Gitea, GiteaError - - 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") - - # Monkey-patch the bot to fail on seed_graduated_rfc. The repo - # has already been created in step 1; the rollback must delete it. - orig_seed = Bot.seed_graduated_rfc - async def boom(self, *args, **kwargs): - raise GiteaError(500, "simulated seed failure for rollback test") - Bot.seed_graduated_rfc = boom - try: - r = client.post( - "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm", - "owners": ["ben"]}, - ) - finally: - Bot.seed_graduated_rfc = orig_seed assert r.status_code == 200, r.text - d = r.json() - assert d["finished"] is True - assert d["succeeded"] is False - - # Repo deleted as the rollback inverse. - assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos - # Meta entry unchanged. + assert r.json()["succeeded"] is True cached = db.conn().execute( - "SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" + "SELECT state FROM cached_rfcs WHERE slug = 'ohm'" ).fetchone() - assert cached["state"] == "super-draft" - assert cached["rfc_id"] is None - # Audit log carries the rollback row. - kinds = [ - r["action_kind"] - for r in db.conn().execute( - "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" - ) - ] - assert "graduate_start" in kinds - assert "graduate_repo_create" in kinds - assert "graduate_repo_delete" in kinds - assert "graduate_rollback" in kinds - assert "graduate_complete" not in kinds + assert cached["state"] == "active" -def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea): - """Step 3 (open PR) fails → the orchestrator rolls back steps 2 and - 1 (deleting the repo, which reclaims the seed commits at the same - time). The meta-repo entry is untouched.""" +def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea): + """An open-PR failure creates nothing — the entry stays a super-draft + with its body intact and no graduation PR on the meta repo.""" from fastapi.testclient import TestClient from app import db from app.bot import Bot @@ -387,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea): try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0007", "owners": ["ben"]}, ) finally: Bot.open_graduation_pr = orig_open_pr assert r.status_code == 200, r.text assert r.json()["succeeded"] is False - # Repo torn down. - assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos - # Meta entry's body still has the pitch (not stripped). + + # Entry untouched: still super-draft, body intact on main. + cached = db.conn().execute( + "SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'" + ).fetchone() + assert cached["state"] == "super-draft" + assert cached["rfc_id"] is None meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] assert "Open Human Model is a framework" in meta_text + kinds = [ + row["action_kind"] + for row in db.conn().execute( + "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" + ) + ] + assert "graduate_start" in kinds + assert "graduate_failed" in kinds + assert "graduate_complete" not in kinds + + +def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea): + """A merge failure leaves the flip PR open on its dash-suffixed + branch; the orchestrator closes the PR and deletes the branch so + failed attempts don't accumulate. The entry stays a super-draft — + the flip PR's commit was on a branch, not on main.""" + from fastapi.testclient import TestClient + from app import db + from app.bot import Bot + from app.gitea import GiteaError + + 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") + + orig_merge = Bot.merge_graduation_pr + async def boom(self, *args, **kwargs): + raise GiteaError(502, "simulated merge failure") + Bot.merge_graduation_pr = boom + try: + r = client.post( + "/api/rfcs/ohm/graduate?_sync=1", + json={"rfc_id": "RFC-0009", "owners": ["ben"]}, + ) + finally: + Bot.merge_graduation_pr = orig_merge + assert r.status_code == 200, r.text + assert r.json()["succeeded"] is False + + # Entry stays super-draft on main (the flip never merged). + cached = db.conn().execute( + "SELECT state FROM cached_rfcs WHERE slug = 'ohm'" + ).fetchone() + assert cached["state"] == "super-draft" + meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"] + assert "state: super-draft" in meta_text + + # The dash-suffixed graduation branch was cleaned up. + grad_branches = [ + name for (o, repo), branches in fake.branches.items() + if (o, repo) == ("wiggleverse", "meta") + for name in branches + if name.startswith("graduate-ohm-") + ] + assert grad_branches == [], f"leftover graduation branch: {grad_branches}" + + kinds = [ + row["action_kind"] + for row in db.conn().execute( + "SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id" + ) + ] + assert "graduate_pr_open" in kinds + assert "graduate_failed" in kinds + assert "graduate_complete" not in kinds + def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): """A second graduation request for a slug already in-flight is refused.""" @@ -414,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - # Seed a synthetic in-flight state so the registry refuses the second. st = api_graduation._new_active( - "ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm", - repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"], + "ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"], ) st.finished = False try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0001", "owners": ["ben"]}, ) assert r.status_code == 409 finally: @@ -432,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea): def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea): - """§13.4: chat threads on the super-draft's canonical-body view - (`branch_name='main'`) are interpreted as the new RFC's main-thread - after graduation. The rows don't move — the rfc_slug is canonical - per §2.3 — so the same thread surfaces from both before and after - the graduation.""" + """§13.4: chat threads on the entry's main view (`branch_name='main'`) + stay put across the flip — the rfc_slug is canonical per §2.3 — so the + same thread surfaces from /branches/main before and after graduation.""" from fastapi.testclient import TestClient from app import db @@ -448,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - # Materialize a whole-doc main thread + a message on it. This - # mirrors what reading the canonical-body view would create - # lazily (§8.12 / api_branches._ensure_branch_chat_thread). cur = db.conn().execute( """ INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by) @@ -466,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git (thread_id,), ) - # Graduate. r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0099", "owners": ["ben"]}, ) assert r.status_code == 200, r.text - # The thread row's identity is unchanged. row = db.conn().execute( "SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,), ).fetchone() assert row["branch_name"] == "main" - # The new RFC's main view surfaces the same thread id as its - # whole-doc main thread (the entry is now active, the branch - # 'main' now points at the per-RFC repo's main, but the - # `(rfc_slug, branch_name)` key remains the canonical anchor). r = client.get("/api/rfcs/ohm/branches/main") assert r.status_code == 200, r.text assert r.json()["main_thread_id"] == thread_id -def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea): - """§9.8: after graduation, threads on meta-repo edit branches stay - attached to their original branch_name and surface from the new - RFC's /main response under `pre_graduation_history`.""" +def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea): + """§13.4 (meta-only): after graduation an edit branch is a *current* + branch of the now-active RFC — it surfaces in the normal `branches` + list, and there is no separate `pre_graduation_history` set (that + affordance is legacy per-repo only).""" from fastapi.testclient import TestClient from app import db @@ -501,10 +483,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea provision_user_row(user_id=2, login="alice", role="contributor") seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"]) - # v0.16.0 (item #12): alice needs per-RFC contributor access. grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") - # Alice cuts an edit branch and starts chatting on it. sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"] @@ -513,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea db.conn().execute( """ INSERT INTO thread_messages (thread_id, role, author_user_id, text) - VALUES (?, 'user', 2, 'pre-graduation note on an edit branch') + VALUES (?, 'user', 2, 'note on an edit branch') """, (thread_id,), ) - # Ben graduates. 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={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0100", "owners": ["ben"]}, ) assert r.status_code == 200, r.text - # /main on the now-active RFC surfaces the pre-graduation history. - r = client.get("/api/rfcs/ohm/main") - d = r.json() + d = client.get("/api/rfcs/ohm/main").json() assert d["state"] == "active" - hist = d["pre_graduation_history"] - assert len(hist) >= 1 - assert any(h["branch_name"] == branch for h in hist) - target = next(h for h in hist if h["branch_name"] == branch) - assert target["message_count"] >= 1 + # The edit branch is a current branch; no pre-graduation hop. + assert d["pre_graduation_history"] == [] + assert any(b["name"] == branch for b in d["branches"]), \ + f"edit branch not in branches: {[b['name'] for b in d['branches']]}" def test_claim_opens_meta_pr(app_with_fake_gitea): @@ -560,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea): d = r.json() assert d["branch_name"] == "claim/ohm" - # The PR body's diff carries Alice in owners. text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"] ent = entry_mod.parse(text) assert "alice" in ent.owners - # cached_prs records pr_kind='meta_claim' via refresh_meta_pulls. row = db.conn().execute( "SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],), ).fetchone() diff --git a/backend/tests/test_hygiene_vertical.py b/backend/tests/test_hygiene_vertical.py index 2ce680e..09944f6 100644 --- a/backend/tests/test_hygiene_vertical.py +++ b/backend/tests/test_hygiene_vertical.py @@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea): def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea): - """§19.2 candidate Slice 8 settles: when graduation rolls back - after step 3 (open_pr), the `graduate--<6hex>` branch is - deleted alongside the PR close so failed-graduation branches - don't accumulate on the meta repo across retries.""" + """Meta-only (§13.3): when the flip's merge fails after the PR is + open, the orchestrator closes the PR and deletes its + `graduate--<6hex>` branch so failed attempts don't accumulate + on the meta repo across retries.""" from fastapi.testclient import TestClient from app import db from app.bot import Bot @@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea): sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") - # Force a step-4 (merge_pr) failure so step 3 (open_pr) has - # already landed and the rollback exercises the branch cleanup. + # Force a merge_pr failure so the flip PR (open_pr) has already + # landed and the cleanup exercises the branch deletion. orig_merge = Bot.merge_graduation_pr async def boom(self, *args, **kwargs): - raise GiteaError(502, "simulated merge failure for rollback test") + raise GiteaError(502, "simulated merge failure for cleanup test") Bot.merge_graduation_pr = boom try: r = client.post( "/api/rfcs/ohm/graduate?_sync=1", - json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm", - "owners": ["ben"]}, + json={"rfc_id": "RFC-0099", "owners": ["ben"]}, ) finally: Bot.merge_graduation_pr = orig_merge assert r.status_code == 200, r.text assert r.json()["succeeded"] is False - # The dash-suffixed graduation branch was deleted on rollback. + # The dash-suffixed graduation branch was deleted on cleanup. meta_branches = fake.branches[("wiggleverse", "meta")] graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")] assert graduation_branches == [], ( diff --git a/frontend/package.json b/frontend/package.json index 3a9145a..a71e77b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.30.2", + "version": "0.31.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api.js b/frontend/src/api.js index 902eea1..f99e7e9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -530,18 +530,19 @@ export async function listBlockingPRs(slug) { return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`)) } -export async function graduateCheck(slug, { id, repo }) { +export async function graduateCheck(slug, { id }) { + // Meta-only topology (§13.2): two fields — integer id + owners. No + // repo name to validate. const params = new URLSearchParams() if (id != null) params.set('id', id) - if (repo != null) params.set('repo', repo) return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`)) } -export async function startGraduation(slug, { rfcId, repoName, owners }) { +export async function startGraduation(slug, { rfcId, owners }) { const res = await fetch(`/api/rfcs/${slug}/graduate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }), + body: JSON.stringify({ rfc_id: rfcId, owners }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/GraduateDialog.jsx b/frontend/src/components/GraduateDialog.jsx index 10a5003..2c0cdf7 100644 --- a/frontend/src/components/GraduateDialog.jsx +++ b/frontend/src/components/GraduateDialog.jsx @@ -1,70 +1,55 @@ -// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 step stack. +// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 flip. // -// Renders three editable fields (integer ID, repo name, initial owners) -// with debounced server-side validation per §13.2 and a precondition -// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate. -// -// On confirm, opens the §13.3 SSE stream and renders the five named -// steps with per-step states. On failure, the rollback step's events -// append to the stack and a "What happened" panel renders below until -// the admin dismisses it. +// Meta-only topology (SPEC §1): graduation is an in-place state flip on +// the meta entry — no per-RFC repo is created and the body is kept. The +// dialog renders two editable fields (integer ID + initial owners) with +// debounced server-side validation per §13.2. On confirm it opens the +// §13.3 SSE stream and renders the two named steps (open the flip PR, +// merge it). There is no rollback step and no repo-name field. import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { graduateCheck, - listBlockingPRs, openGraduationProgress, startGraduation, } from '../api' const CHECK_DEBOUNCE_MS = 250 -const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache'] +const STEP_KEY_ORDER = ['open_pr', 'merge_pr'] export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { // Suggest defaults from the catalog. const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry]) const [rfcId, setRfcId] = useState(suggestedId) - const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`) const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : []) const [newOwner, setNewOwner] = useState('') const [checkResult, setCheckResult] = useState(null) - const [blockingPRs, setBlockingPRs] = useState([]) - const [precondPopover, setPrecondPopover] = useState(false) - const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error + const [phase, setPhase] = useState('idle') // idle | running | done | failed | error const [streamState, setStreamState] = useState(null) const [submitError, setSubmitError] = useState(null) const esRef = useRef(null) - // Initial blocking-PRs probe + ongoing /check polling. - useEffect(() => { - listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {}) - }, [slug]) - useEffect(() => { const t = setTimeout(() => { - graduateCheck(slug, { id: rfcId, repo: repoName }) + graduateCheck(slug, { id: rfcId }) .then(setCheckResult) .catch(() => {}) }, CHECK_DEBOUNCE_MS) return () => clearTimeout(t) - }, [slug, rfcId, repoName]) + }, [slug, rfcId]) useEffect(() => () => { esRef.current?.close() }, []) const idError = checkResult?.id?.error || null - const repoError = checkResult?.repo?.error || null const ownersOk = owners.length > 0 const ownersError = ownersOk ? null : 'Add at least one initial owner' - const blockingError = blockingPRs.length > 0 - ? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation` - : null // First-blocker tooltip text per §13.2. - const firstBlocker = idError || repoError || ownersError || blockingError - const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok + const firstBlocker = idError || ownersError + const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk const handleAddOwner = useCallback(() => { const v = newOwner.trim().toLowerCase() @@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { setSubmitError(null) setPhase('running') try { - await startGraduation(slug, { rfcId, repoName, owners }) + await startGraduation(slug, { rfcId, owners }) } catch (err) { setPhase('idle') setSubmitError(err.message) @@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { // Short hold per §13.3, then dismiss. setTimeout(() => onCompleted?.(payload), 1500) } else { - setPhase('rolled_back') + setPhase('failed') } } }, @@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { setPhase('error') }, }) - }, [slug, rfcId, repoName, owners, onCompleted]) + }, [slug, rfcId, owners, onCompleted]) // ----- Render ----- @@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { {!showStack && (

- §13: graduate the super-draft to its own repo. The meta-repo entry - becomes frontmatter-only; the canonical body moves to `RFC.md` in - the new repo. The sequence runs as five transactional steps with - rollback per §13.3. + §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.

@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { {idError &&

{idError}

}
-
- - setRepoName(e.target.value.trim())} - placeholder="rfc-NNNN-slug" - disabled={phase !== 'idle'} - /> -

Becomes `<org>/{repoName || 'rfc-…'}` on Gitea.

- {repoError &&

{repoError}

} -
-
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{ownersError &&

{ownersError}

}
- - {blockingPRs.length > 0 && ( -
- - {precondPopover && ( -
- {blockingPRs.map(pr => ( -
-
- PR #{pr.pr_number} — {pr.title || '(no title)'} -
- {pr.author ? `by @${pr.author}` : ''} - {pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''} -
-
-
- Open ↗ -
-
- ))} -

- §9.8: open body-edit PRs would attempt to re-introduce a - body to a frontmatter-only entry after step 3. Resolve - them (merge or withdraw) and re-open this dialog. -

-
- )} -
- )}
)} {showStack && (
- - {phase === 'rolled_back' && ( + + {phase === 'failed' && (

What happened

- The graduation could not complete. The app rolled back the - steps that had already run; nothing was left half-applied on - Gitea. Error: {streamState?.error || 'unknown'}. + The graduation could not complete. Because it is a single + in-place flip, nothing was left half-applied — `{slug}` stays + a super-draft. Error: {streamState?.error || 'unknown'}.

- Read the failure detail next to the red step above. Resolve - the underlying cause (a repo-name collision, a network flake, - a concurrent PR landing on `rfcs/{slug}.md`) and try again. + Read the failure detail next to the red step above, resolve + the underlying cause (a concurrent PR landing on{' '} + `rfcs/{slug}.md`, a network flake), and try again.

)} @@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {

Graduation complete

- `{slug}` is now active as {streamState?.rfc_id}{' '} - at {streamState?.repo_full}. The catalog and the - RFC view reflect the new state. + `{slug}` is now active as {streamState?.rfc_id}. + The catalog and the RFC view reflect the new state.

)} @@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { disabled={!canSubmit} title={canSubmit ? '' : firstBlocker || ''} > - Graduate to RFC repo + Graduate )} {phase === 'running' && ( - Running graduation sequence… + Graduating… )} - {(phase === 'rolled_back' || phase === 'error') && ( + {(phase === 'failed' || phase === 'error') && ( )} {phase === 'done' && ( )}
- {submitError && phase !== 'rolled_back' && ( + {submitError && phase !== 'failed' && (
Error: {submitError}
)} @@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) { } -function StepStack({ steps, rollbackSteps }) { +function StepStack({ steps }) { return (
{steps.map(s => )} - {rollbackSteps.length > 0 && ( -
Rollback
- )} - {rollbackSteps.map(s => )}
) } @@ -350,8 +273,3 @@ function suggestNextRfcId(existing) { const next = used.size === 0 ? 1 : (Math.max(...used) + 1) return `RFC-${String(next).padStart(4, '0')}` } - - -function stripPrefix(rfcId) { - return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId -}