Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+132 -74
View File
@@ -2405,86 +2405,96 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: graduation per §13
### 19.1 Next slice: notifications per §15
Slice 4 of the build has landed. Super-draft body editing per §9.5
runs end-to-end against the local Gitea — the §9.4 super-draft view
replaces the Slice 2 placeholder and renders through the same
`RFCView.jsx` surface as an active RFC, dispatched on `entry.state`.
The §9.5 `Start Contributing` gesture cuts a meta-repo edit branch
via `POST /api/rfcs/<slug>/start-edit-branch`, re-anchors pending
main-scoped `changes` rows, and lands the contributor in contribute
mode on the new branch. From there everything in §8 — chat, AI
participation, accept/decline/edit, manual-edit flushes, range and
paragraph sub-threads, flags, DiffView, stale-change handling —
reaches the super-draft surface through the same routes Slice 2
shipped, with the dispatch sitting in `api_branches.py`'s helpers:
when `cached_rfcs.state = 'super-draft'`, the bot writes to the
meta repo and the file is `rfcs/<slug>.md` (the body wrapped in
frontmatter); when `state = 'active'`, it writes to the per-RFC
repo and the file is `RFC.md`. The body extracted from the entry's
frontmatter envelope is what the editor and the diff see; the
serializer re-wraps on every commit. The §10 PR flow against
meta-repo edit branches falls out structurally unchanged, with
`pr_kind='meta_body_edit'` distinguishing the cache row — the
§10.3 review page, the §10.4 review threads, the §10.5 merge, the
§10.8 withdraw, and the §10.9 conflict-replay path all dispatch the
same way. §9.7's visibility and contribute grants on edit branches
reuse the Slice 2 `branch_visibility` / `branch_contribute_grants`
machinery, keyed on the meta repo. The §9.5 metadata pane lands as
`POST /api/rfcs/<slug>/metadata` — title and tag edits open a
small meta-repo PR via the bot's new `open_metadata_pr` primitive;
slug renames remain deferred per §9.5 and the §19.2 candidate. The
§9.5 unclaimed-merge gate — only app admins/owners can merge a
body-edit PR until §13.1's claim runs — falls out of the existing
`_can_merge` rule against an empty `owners_json` / `arbiters_json`.
Slice 5 of the build has landed. The §13 graduation flow runs
end-to-end against the local Gitea — the Graduate dialog renders
the three editable fields (integer ID, repo name, initial owners)
with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting
up per-field validity inline, the precondition popover surfaces
open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the
§9.8 gate enforced before the sequence starts), and confirming the
dialog kicks off the §13.3 five-step sequence streamed via
`GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in
`api_graduation.py` runs the sequence as an asyncio task fed by an
in-memory queue; each step's bot primitive
(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
`open_graduation_pr`, `merge_graduation_pr`) lands its own row in
`actions`, bracketed by `graduate_start` and `graduate_complete`
for the linkable sequence. Rollback is per-step and runs in
reverse: each forward step has a paired undo registered in
`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which
also reclaims the seed commits, so seed-files' undo folds into
it), the open-pr undo closes the graduation PR. There is no
merge-pr undo by design; once the meta-repo merge has landed,
graduation is irreversible per §13.5.
The two §17 routes Slice 4 added — `start-edit-branch` and
`metadata` — live in `backend/app/api_branches.py`. The bot grew
`open_metadata_pr`. The §4 cache grew `refresh_meta_branches`
which mirrors `edit-<slug>-<6hex>` branches into `cached_branches`
and synthesizes a per-slug `main` row so the §10.1 has-commits-
ahead check works uniformly. The §5 schema needed no migration —
the super-draft scoping note already settled that the existing
tables carry both cases. On the frontend, `RFCView.jsx`'s
super-draft placeholder is replaced by the full editor surface;
the `BranchDropdown` renders `canonical body` as the first
position per §9.4; a `MetadataPaneModal` opens from the breadcrumb
actions for viewers holding super-draft edit authority.
§13.4's chat migration landed as a database semantic no-op —
the whole-doc main thread on the super-draft
(`rfc_slug=<slug>`, `branch_name='main'`) is the same row before
and after graduation; only the interpretation changes (canonical-
body view becomes per-RFC repo's main). The slug is the canonical
key per §2.3, so no data movement is needed. Edit-branch chats
stay attached to their original `branch_name` per §9.8's
no-data-movement framing; the §9.8 pre-graduation history
affordance on the new RFC view surfaces them as a distinct
disclosure in the breadcrumb dropdown, with the read path
dispatching against the meta repo via a new `_is_meta_target(rfc,
branch)` helper that handles both super-draft branches and active-
RFC pre-graduation meta-repo branches uniformly.
Slice 4 ships covered by `backend/tests/test_super_draft_vertical.py`
— ten integration tests against the FakeGitea covering main-view
read, start-edit-branch, body extraction on read, accept and manual
flush both preserving the frontmatter envelope, the body-edit PR's
`pr_kind='meta_body_edit'` cache shape, the full cut-accept-open-
merge loop with the §9.5 admin-only unclaimed-merge gate, the
metadata pane PR cycle, the canonical-body branch (`main` for
super-drafts) refusing contribute writes, and the metadata pane
permission gate refusing plain contributors. The full Slices 14
test suite is 35/35 green.
The §13.1 claim flow landed alongside graduation since it's the
prerequisite for non-admin graduation. The bot grew `open_claim_pr`;
`api_prs._require_pr` broadened to accept `pr_kind='meta_claim'`
so the merge surface inherits structurally from §10. Until §13.1's
claim runs, the dialog refuses the start when `owners=[]` and the
popover surfaces "Claim ownership yourself" as a remediation
affordance (admins are contributors per §6.1 and can claim solo).
**Slice 5 is graduation per §13.** The five-step transactional
sequence flips a super-draft to active: validate the dialog's
`id`/`repo`/`owners` inputs against the catalog and Gitea, create
the per-RFC repo via `bot.ensure_rfc_repo_seed` (which Slice 2
added as a forward-looking seam), copy the body from the entry's
frontmatter envelope into the new repo's `RFC.md` on main, strip
the body from the meta-repo entry and fill the `id` / `repo` /
`graduated_at` / `graduated_by` frontmatter fields, and migrate
the chat per §13.4 — the whole-doc main thread and the canonical-
body view's range/paragraph sub-threads re-anchor onto the new
RFC's main thread; edit-branch chats stay attached to their
original `branch_name` on the meta repo per §9.8, surfaced by the
pre-graduation history affordance on the new RFC view. The §9.8
precondition gate — open body-edit PRs block graduation — is
enforced before the bot starts the sequence, so the §13.3 rollback
complexity does not grow. The Graduate dialog opens a stream
handle for the §17 SSE progress endpoint and renders the step
stack from `pending → running → done/failed` transitions, with a
trailing `rollback` step's events if any earlier step fails.
The five §17 routes Slice 5 added — `claim`, `blocking-prs`,
`graduate/check`, `graduate`, and `graduate/progress` — live in
`backend/app/api_graduation.py`. The §5 schema needed no migration.
On the frontend, `RFCView.jsx`'s breadcrumb actions grew
`Graduate to RFC repo` and `Claim ownership` buttons;
`GraduateDialog.jsx` owns the three-field surface, the precondition
popover, and the live step stack fed by an `EventSource` on the
progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
disclosure that surfaces edit-branch threads on the new RFC view
per §9.8.
Slice 5 ships covered by `backend/tests/test_graduation_vertical.py`
— ten integration tests against the FakeGitea (extended with
`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering
the dialog validator's per-field checks, the no-owners refusal,
the §9.8 precondition refusing the start, the §13.3 happy path
end-to-end with audit-log verification, mid-sequence rollback at
step 2 (seed) and step 3 (PR open), concurrent-graduation refusal,
§13.4's chat-row-survives contract, the §9.8 pre-graduation
history surface, and the §13.1 claim PR cycle. The full Slices 15
test suite is 45/45 green.
**Slice 6 is notifications per §15.** Every other vertical now
produces signals — propose, claim, merge, graduate, body edits,
manual flushes, PR open/withdraw/merge, review threads, conflict-
replay — and Slice 6 builds the surface that turns those signals
into a contributor's inbox. The §5 schema already carries the
notifications, watches, branch_chat_seen, notification_user_mutes,
and notification_digests tables; Topic 13's session settled the
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3
badges and toasts, §15.4 email categories, §15.5 digest cadence,
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not-
disturb, and §15.9 attribution. The producer-side hook is "after
a write succeeds, evaluate watches and fan-out notification rows"
— same chokepoint shape Slice 1's `_log` uses, invoked inline
from the bot wrapper. The consumer-side hook is the header badge,
the inbox panel, the toast surface, and the per-row read-state
machinery. The §15.4 email loop and the §15.5 digest are the
heavier sub-pieces — the digest needs a scheduled-job runner;
the email loop needs a transactional-email adapter and the
`POST /api/webhooks/email-bounce` receiver.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry and pick up Slice 5 cleanly
`docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly
without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new
@@ -2688,6 +2698,54 @@ binding.
cheap, but a dedicated index on `cached_branches.branch_name`
would shorten the join-against-`cached_rfcs`-state for very
large super-draft fleets. Trivial; defer until the cost shows up.
- **Graduation progress persistence across page reloads.** Slice 5's
orchestrator holds the per-step state in a process-local dict
keyed by slug, fed by an asyncio.Queue the §17 SSE drains. A page
reload while the sequence is in flight loses the dialog but the
sequence continues to completion on the server; the user can
reopen the dialog and the snapshot event re-renders the current
state (the in-memory entry persists until cleanup). What is not
yet settled: how long to retain the entry after `finished=True`,
whether to persist enough on `actions` to reconstruct the step
stack from the audit log for a returning admin who missed the
live stream, and whether the dialog should re-open automatically
on a slug whose registry entry is still present. Touches §13.3
(the rendered step stack's durability) and §15.2 (a graduation-
in-progress signal as an inbox row would be a natural alternative
surface for follow-along). Earns its own topic once the build
hits cases where a single sequence runs long enough that the
reload-during-graduation path matters.
- **Graduation PR auto-close on rollback's `close_graduation_pr`.**
Slice 5's rollback closes the graduation PR via `gitea.close_pull`
but leaves the dash-suffixed branch (`graduate-<slug>-<6hex>`)
in place. The branch is short-lived in steady-state — graduation
succeeds — but accumulated failed-graduation branches over time
could clutter the meta repo's branch list. The §12 30/90 hygiene
timers (Slice 8) would naturally sweep them, but a graduation-
specific cleanup that deletes the branch on rollback would close
the loop faster. Trivial to add when evidence shows the branches
pile up.
- **The `_is_meta_target(rfc, branch)` dispatch helper.** Slice 5
generalized the super-draft routing collapse (`_is_super_draft`
alone) to also handle active-RFC reads against pre-graduation
meta-repo branches per §9.8. The helper checks state plus a name-
prefix list (`edit-`, `edit/`, `metadata-`, `metadata/`, `claim/`,
`propose/`, `graduate-`). The prefix list is right for v1's
surface, but a contributor renaming an active-RFC per-RFC branch
to one of those prefixes would have reads route to the meta repo
(and likely 404). The `_validate_branch_name` guard refuses
reserved prefixes on creation, so the only way to reach this
edge is a hand-renamed branch — defer-able until evidence shows
it happens.
- **Test seam for synchronous graduation.** Slice 5's `?_sync=1`
query param on `POST .../graduate` awaits the orchestrator inline
so integration tests can assert post-conditions without driving
the SSE. The seam is documented in code; it does not affect the
production path. A cleaner long-term shape is to expose the
orchestrator as importable for tests and remove the query-param
branch from the route handler, but the current shape is the
minimum that keeps the test surface terse without adding a
separate test-only module.
- **Body full-text search.** When the time comes.
Topic 13 (notifications) is settled and folded into §5 (the