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
+169 -48
View File
@@ -186,6 +186,106 @@ posting, arbiter-only merge, contributor withdraw with the
of a public PR, and the full §10.9 conflict-replay path including
the auto-close of the original PR on the resolution PR's merge.
### Slice 5 — shipped
Graduation per §13 in full. The §13.3 five-step transactional sequence
flips a super-draft to active: create the per-RFC repo, seed
`RFC.md` / `README.md` / `.rfc/metadata.yaml`, open a meta-repo PR
that strips the entry's body and fills the graduation frontmatter
(`state: active`, `id: RFC-NNNN`, `repo`, `graduated_at`,
`graduated_by`), auto-merge that PR with the admin as merge actor,
refresh the cache so the catalog row and the new RFC view reflect
`active` immediately. Each step goes through a new bot primitive —
`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
`open_graduation_pr`, `merge_graduation_pr` — that records its own
row in `actions`, bracketed by `graduate_start` and
`graduate_complete` for the linkable sequence the §13.3 audit shape
calls for. The orchestrator in
[`backend/app/api_graduation.py`](../backend/app/api_graduation.py)
runs the sequence as an asyncio task fed by an in-memory queue; the
§17 SSE endpoint subscribes to that queue and emits one event per
step transition, plus the trailing rollback step's events if any
earlier step fails.
Rollback is per-step and runs in reverse. Each forward step has a
paired undo registered in `_UNDO_BY_STEP`: `create_repo` → delete the
repo, `seed_files` → folded into the repo deletion (the seed commits
live inside the same repo), `open_pr` → close 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 path forward is
`withdraw` via §3. The rollback also records `graduate_rollback` in
`actions` with the failed-at step name, the error, and the list of
undone steps, so the failure surface in the dialog and the `actions`
log carry the same record.
The §9.8 precondition gate — open body-edit PRs against
`rfcs/<slug>.md` would attempt to re-introduce a body to a
frontmatter-only entry after step 3 — is enforced before the bot
starts the sequence, so the §13.3 rollback complexity does not grow.
The check runs both client-side as the dialog probes
`GET /api/rfcs/<slug>/blocking-prs` and server-side at the top of
`POST .../graduate` as an atomic re-check.
§13.4 chat migration is a database semantic no-op. The whole-doc
main thread on the super-draft (`rfc_slug=<slug>`, `branch_name='main'`)
is the same row interpreted as the super-draft's canonical-body
thread before graduation and as the new RFC's main thread after —
the slug is the canonical key per §2.3, the branch_name 'main' now
points at the per-RFC repo's main, no data movement is needed. Range
and paragraph sub-threads on the canonical-body view migrate the
same way per §9.8. Edit-branch chats stay attached to their original
`branch_name` on the meta repo 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.
The §13.1 claim flow landed alongside graduation since claiming is
the prerequisite for non-admin graduation. The bot grew
`open_claim_pr`; the existing `api_prs` merge endpoint 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 if they intend to graduate without further
ceremony.
The five §17 routes Slice 5 added:
| Method | Path | § |
| ------ | ----------------------------------------------- | ------- |
| POST | `/api/rfcs/{slug}/claim` | §13.1 |
| GET | `/api/rfcs/{slug}/blocking-prs` | §13.2 |
| GET | `/api/rfcs/{slug}/graduate/check` | §13.2 |
| POST | `/api/rfcs/{slug}/graduate` | §13.3 |
| GET | `/api/rfcs/{slug}/graduate/progress` | §13.3 |
On the frontend, `RFCView.jsx`'s breadcrumb actions grew a
`Graduate to RFC repo` button (admins/owners and entry owners) and
a `Claim ownership` button (signed-in non-owners). `GraduateDialog.jsx`
owns the three-field surface with debounced `/check` polling, the
precondition popover backed by `/blocking-prs`, and the live step
stack fed by an `EventSource` on the progress SSE. The `BranchDropdown`
gains a `Pre-graduation history (N)` 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`](../backend/tests/test_graduation_vertical.py) —
ten integration tests against the FakeGitea (extended with
`DELETE /repos/{owner}/{repo}` for the rollback inverse). The tests
cover the dialog validator's per-field checks, the no-owners
refusal, the §9.8 open-body-edit-PR 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), the
concurrent-graduation refusal, §13.4's chat-row-survives-without-
data-movement 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.
The orchestrator's `?_sync=1` test seam on `POST .../graduate`
awaits the sequence inline so integration tests can assert
post-conditions without driving the SSE. Production clients use the
spec-described shape — POST returns immediately and the client
subscribes to the progress SSE.
### Slice 4 — shipped
Super-draft body editing per §9.5 + §9.6 + §9.7. The §17 routing-collapse
@@ -316,65 +416,86 @@ spec:
## Next slice
**Slice 5: graduation per §13.**
**Slice 6: notifications per §15.**
A super-draft becomes an active RFC through the §13 graduation
sequence — the dialog (§13.2), the five-step transactional sequence
with rollback (§13.3), the chat-follows-the-work migration (§13.4),
the pre-graduation history affordance for the new RFC view (§9.8),
and the precondition gate that refuses to graduate while body-edit
PRs are open (§9.8 / §13.3).
Every other vertical now produces signals: propose, claim, merge,
graduate, body edits, manual flushes, PR open/withdraw/merge,
review threads, conflict-replay, super-draft chat. Slice 6 builds
the inbox, the fan-out, the digest, and the email loop that turn
those signals into a contributor's surface. 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 signal-surface
stack), 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.
Slice 4 left this clean: the §9.5 metadata pane, the body-edit PR
flow, and the active-RFC PR flow all converge on the same dispatch.
Graduation is the act that flips an entry's state from `super-draft`
to `active`, creates the per-RFC repo via `bot.ensure_rfc_repo_seed`
(which Slice 2 added as a forward-looking seam), copies the body
from the frontmatter envelope into the new repo's `RFC.md`, strips
the body field from the meta-repo entry, mints the integer ID and
fills the `repo`/`graduated_at`/`graduated_by` fields, and migrates
the whole-doc main thread's chat to the new RFC's `branch_name=null`
thread per §13.4.
Slices 15 left this clean: every user gesture goes through the
bot wrapper and lands an `actions` row with the underlying actor.
The producer-side hook is "after a write succeeds, evaluate watches
and fan-out notification rows." The consumer-side hook is the
header badge, the inbox panel, the toast surface, and the per-row
read-state machinery.
What Slice 5 owns specifically:
What Slice 6 owns specifically:
- The §13.2 Graduate dialog — three fields (integer ID, repo name,
initial owners), the inline-validation endpoint
`GET /api/rfcs/{slug}/graduate/check`, the blocking-PRs popover
via `GET /api/rfcs/{slug}/blocking-prs`, and the merge-actor set
per §13's authority rules.
- The §13.3 transactional sequence — five steps emitted as an SSE
stream via `GET /api/rfcs/{slug}/graduate/progress`, with each
step's `pending → running → done/failed` transitions surfacing in
the dialog, and a trailing `rollback` step if any earlier step
fails. The bot grows `graduate` plus the rollback primitives the
sequence needs.
- The §13.4 chat migration — the whole-doc main thread on the
super-draft (`rfc_slug=<slug>`, `branch_name='main'`) re-anchors
onto the new RFC's main thread; range and paragraph sub-threads
on the canonical-body view migrate too per §9.8's clarification.
Edit-branch chats stay attached to their original `branch_name`
on the meta repo per §9.8 — no data movement, surfaced by the
pre-graduation history affordance.
- The §9.8 pre-graduation history affordance on the new RFC view —
the slug remains the canonical key per §2.3, so the query is a
straightforward lookup of `threads` and `changes` rows where
`rfc_slug = <slug>` and `branch_name` begins with the meta-repo
edit prefix.
- **The producer fan-out.** Every `actions` row whose event maps to a
§15 signal produces zero-or-more `notifications` rows by joining
against `watches` and applying the §15.1 priority rules. The
fan-out lives as a small module that the bot wrapper invokes
inline after each write — same chokepoint shape Slice 1's
`_log` uses.
- **The §15.2 inbox.** `GET /api/notifications` with the
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
`POST /api/notifications/<id>/read` for per-row marking,
`POST /api/notifications/read` for the bulk filter mark, and the
SSE `GET /api/notifications/stream` that backs the live badge.
- **The §15.3 surface.** The header badge counter (live via the SSE),
the toast on personal-direct events while the user is active, and
the ambient signal — a colored dot per row on the §7 catalog
pointing at watched RFCs with unseen activity.
- **The §15.4 email loop.** Per-category opt-in/out preferences on
the users table (already in the schema), the `/api/users/me/notification-preferences`
endpoints, the email-send adapter that routes a notification's
category through the user's category toggle, and the
`POST /api/webhooks/email-bounce` receiver that sets the global
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
one-click flow.
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
to roll up unseen notifications into a single email, with the
`notification_digests` table tracking what was included so the
next digest skips what already shipped.
- **The §15.6 watch model.** Auto-watch on first interaction with
an RFC, the per-row state column (`watching` / `following` /
`muted`), the 90-day auto-decay for unset rows, and the explicit
`POST /api/rfcs/<slug>/watch` overrides.
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
cursor on every branch read, reconcile inbox notifications to
read when their underlying surface is consumed.
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
per-user notification mute list, the orthogonality vs §6.2's
app-wide write-mute.
What Slice 5 does NOT own:
What Slice 6 does NOT own:
- The §15 notification surface (still Slice 6).
- The §14 chrome polish (still Slice 7).
- The §12 30/90 branch-hygiene timers (still Slice 8).
- The §16 deferred items.
The carryovers Slice 5 inherits — the `ensure_rfc_repo_seed`
primitive Slice 2 added, the body-edit-PR precondition gate
(checked against the same `cached_prs` shape Slice 4 wired), and
the existing `actions` audit-log shape for the rollback record.
The carryovers Slice 6 inherits — the existing `actions` audit log
(every signal traces back to a row there per §15.9), the SSE
machinery from Slices 2 and 5 (chat-stream and graduate-progress
respectively), and the §5 schema's notification tables (already
in place from Topic 13).
The §15 surface depends on the producers being in place; with
Slice 5 landing the last structural producer (graduation events,
specifically `graduate_complete` as a personal-direct event for
the proposer per §15.4), every signal a contributor needs to see
is now in the audit log waiting to be fanned out.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 5 cleanly
`docs/DEV.md`, and `SPEC.md`'s §19.1 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