Files
rfc-app/docs/DEV.md
T
Ben Stull 36635049c7 Slice 8: v1 ships — integration coverage, runbook, spec corrections
- Five new integration test files raise the suite from 75 to 96 green:
  test_hygiene_vertical (7), test_branch_path_routing (4),
  test_metadata_pr_merge (3), test_cache_bootstrap (4), test_e2e_smoke
  (3). The smoke test walks propose → super-draft → edit branch →
  body-edit PR → graduate → active-RFC PR → merge → notification →
  hygiene-sweep deletion end-to-end.
- deploy/RUNBOOK.md replaces the prior DEPLOY.md stub as a real
  runbook: prerequisites, first-time bring-up, day-2 ops (logs, DB
  backup, secret rotation, the §12 hygiene cadence), rollback shape,
  troubleshooting table.
- backend/.env.example grows the SMTP block, HYGIENE_TICK_SECONDS,
  and WEBHOOK_EMAIL_BOUNCE_SECRET with inline commentary.
- README points to RUNBOOK.md; the "what the build lets you do"
  section adds Slices 7 and 8.
- docs/DEV.md gets a Slice 8 — shipped section; the "Next slice"
  footer becomes the v1-complete epitaph.
- SPEC corrections per the §19.3 working agreement: §10.7 names the
  shared §12 sweep; §12 names the bot as actuator and the per-user
  branch_chat_seen preservation contract; §19.1 marks v1 complete
  and records Slice 8; the five §19.2 candidates Slice 8 folded in
  are marked settled with pointers at the resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:14:50 -07:00

719 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Build notes
The slicing plan for the v1 build, the current state of the codebase,
and the next slice's brief.
## The slicing plan
Eight slices carry §§115 of [`SPEC.md`](../SPEC.md) end-to-end. The
build does not extend the spec; spec corrections during the build are
rare and surgical and live in the appropriate numbered section per
§19.3's working agreement.
1. **Repository scaffolding + propose-to-super-draft vertical.** The
chokepoint that every Git operation flows through (§1 bot wrapper),
the §4 cache machinery (webhook + reconciler), the §5 schema, Gitea
OAuth + user provisioning, the minimal §7 catalog, and one
end-to-end vertical: propose → idea PR → merge → super-draft view.
2. **The active-RFC view per §8 in full.** Editor, branch creation,
per-branch chat with AI participation (the §18 `<change>` protocol),
the change-card panel, accept/decline/edit, manual-edit flushes,
sub-threads, flags, DiffView.
3. **The PR flow per §10.** Open, review surface (diff + compressed
chat), the §10.3 seen-cursor, §10.4 review threads, merge,
post-merge, §10.9 conflict resolution.
4. **Super-draft body editing per §9.5 + §9.6.** Meta-repo edit
branches as the unit of work; everything from §8 inherits.
5. **Graduation per §13.** The dialog, the five-step transactional
sequence, rollback, the pre-graduation history affordance.
6. **Notifications per §15.** Last, because every other surface
produces signals the inbox receives — notification correctness
depends on the producers being in place first.
7. **The §14 chrome + the settings and admin neighborhoods.**
Landing page polish, the `/philosophy` route, the persistent
About link; the `/settings/notifications` surface that exposes
Slice 6's preferences/quiet-hours/mute/watches endpoints; the
`/admin` home base that consolidates role management, the §6.2
write-mute, the audit-log viewer, and the §13.2 graduation-
readiness queue.
8. **Hardening.** End-to-end tests, dev/prod deployment shape,
the §12 30/90 branch-hygiene timers, the §19.2 candidates that
cluster with deployment (branch-name path routing, cache
bootstrap from a pre-existing meta repo, in-app metadata-PR
merges, graduation rollback's branch cleanup).
## State of the codebase
### Slice 1 — shipped
The repository scaffolding (`backend/`, `frontend/`, `scripts/`,
`docs/`), the §5 schema as numbered migrations under
`backend/migrations/`, the §1 bot wrapper (`app/bot.py`) that is the
single chokepoint every Git write flows through, Gitea OAuth and the
§6.1 user-provisioning row in `users`, the §4.1 webhook receiver and
the §4.1 periodic reconciler (both writing to the cache; user actions
never do), the §7 left pane (catalog list, search, sort, state-filter
chips, pending-ideas disclosure), and one end-to-end vertical: propose
→ idea PR opens → owner merges → super-draft appears in the catalog →
super-draft view renders the body.
### Slice 2 — shipped
The §8 active-RFC view in full. The bot wrapper grew per-RFC-repo
write operations — branch cut from main, accept-change commit with
the structured `original`/`proposed`/`reason` body and trailers,
manual-edit flush, and a `ensure_rfc_repo_seed` seam Slice 5's
graduation will eventually replace. The §4 cache now mirrors per-RFC
repos via a new `refresh_rfc_repo` path; the webhook receiver
dispatches on `repository.full_name` so per-RFC events refresh just
that repo, and the reconciler sweeps every active entry. The §18
carryovers landed as `backend/app/providers.py` (the multi-provider
abstraction, unchanged from the prototype) and `backend/app/chat.py`
(an adapter that runs the provider's streaming interface against
`thread_messages` rows, parses `<change>` blocks, and materializes
`changes` rows per §8.14). The §17 endpoints owned by Slice 2 — the
`branches/<branch>/*` and `threads/<thread_id>/*` families — live in
`backend/app/api_branches.py`, mounted alongside Slice 1's routes via
`api.make_router`. On the frontend, `RFCView.jsx` was rebuilt as the
§8 three-column surface; `Editor.jsx`, `ChatPanel.jsx`,
`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`,
`DiffView.jsx`, `ModelPicker.jsx`, and `modelStyles.js` were lifted
from the prototype and adapted to the canonical `threads` /
`thread_messages` / `changes` shape rather than the prototype's
global session_id. The §18 carryovers explicitly preserved: SSE
streaming with base64-encoded chunks, Tiptap + ProseMirror plugin for
the paragraph-margin gutter accent, the prompt-bar selection-quote
machinery, the model picker.
The §17 endpoints exercised so far:
| Method | Path | § |
| ------ | -------------------------------------- | ------- |
| GET | `/api/auth/me` | §6 |
| GET | `/api/rfcs` | §7, §17 |
| GET | `/api/rfcs/{slug}` | §17 |
| GET | `/api/proposals` | §17 |
| GET | `/api/proposals/{pr_number}` | §17 |
| POST | `/api/rfcs/propose` | §9.1 |
| POST | `/api/proposals/{pr_number}/merge` | §9.3 |
| POST | `/api/proposals/{pr_number}/decline` | §9.3 |
| POST | `/api/proposals/{pr_number}/withdraw` | §9.3 |
| POST | `/api/webhooks/gitea` | §4.1 |
| GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 |
| GET | `/api/models` | §18 |
| GET | `/api/rfcs/{slug}/main` | §8.1, §8.2, §17 |
| GET | `/api/rfcs/{slug}/branches/{branch}` | §8.4, §17 |
| POST | `/api/rfcs/{slug}/branches/main/promote-to-branch` | §8.14, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/accept` | §8.9, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/decline` | §8.9, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/reask` | §8.11, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/manual-flush` | §8.11, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/visibility` | §11.1, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/grants` | §6.4, §17 |
| DELETE | `/api/rfcs/{slug}/branches/{branch}/grants/{login}` | §6.4 |
| GET | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §17 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §8.13 |
| GET | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/resolve` | §8.12 |
| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/chat` | §18 |
Slice 2 ships covered by `backend/tests/test_rfc_view_vertical.py`
the FakeGitea simulator from Slice 1 grew per-RFC-repo support (PUT
contents, POST `orgs/{org}/repos`, `seed_rfc_repo`), and a new test
file walks the §8 vertical end-to-end: main-view read, promote-to-
branch, accept (with and without edit-before-accept), decline, manual
flush + system message, flag creation, visibility flip, anonymous
read-but-no-contribute, stale-change refusal, and the chat-streaming
path with a fake provider injected.
### Slice 3 — shipped
The §10 PR flow in full. The bot wrapper grew per-RFC-repo PR
operations — `open_branch_pr` (with the §10.9 `Supersedes:` trailer
hook), `merge_branch_pr` (no-fast-forward via Gitea's `style='merge'`,
the `On-behalf-of:` trailer carrying the merging user per §6.5),
`withdraw_branch_pr`, `cut_resolution_branch`, and
`commit_replay_change` for the §10.9 per-accept replay onto fresh
main. The §4 cache learned about per-RFC PRs via the existing
`refresh_rfc_repo` sweep, plus a `_parse_supersedes` pass that bumps
an original PR's state to closed and records the supersession the
moment the resolution PR's merge arrives — whether via webhook or
the reconciler. The §17 endpoints owned by Slice 3 — the
`branches/<branch>/{pr-draft,open-pr}` and the `prs/<n>/*` family —
live in `backend/app/api_prs.py`, mounted alongside Slices 1 and 2's
routes via `api.make_router`. The migration in `007_pr_flow.sql`
adds `superseded_by_pr_number` and `merge_commit_sha` columns to
`cached_prs` plus the `pr_resolution_branches` join table that
records resolution-branch parentage so the cache can supersede the
original on the resolution PR's merge.
On the frontend, the `Open PR` affordance landed on `RFCView.jsx`'s
branch view (gated on the branch having commits ahead of main and no
already-open PR), opening a new `PRModal.jsx` that fetches the AI
draft via `/pr-draft`, lets the contributor edit, and surfaces the
§11.3 universal-public confirmation inline when the source branch is
private. The `PRView.jsx` sibling to `RFCView.jsx` is mounted at
`/rfc/:slug/pr/:prNumber` and renders the §10.3 three-column shape:
catalog left (App chrome), a unified/split diff in the center
computed from main and branch RFC.md bodies, and a compressed
conversation surface on the right that interleaves chat / flag /
review threads with visual distinction per §10.4. The per-user
seen-cursor advances on every visit; new commits and new messages
since the cursor surface with an accent. The merge button is
arbiter-gated per §6.3; withdraw is contributor-or-arbiter per §10.8;
the §10.9 `Start resolution branch` affordance fires from the
conflict banner when the live Gitea pull reports the PR as
unmergeable, and the new resolution branch opens in the §8 editor for
the contributor to re-anchor stale changes before opening the
resolution PR.
The §17 endpoints exercised in Slice 3:
| Method | Path | § |
| ------ | ----------------------------------------------- | ------- |
| POST | `/api/rfcs/{slug}/branches/{branch}/pr-draft` | §10.2 |
| POST | `/api/rfcs/{slug}/branches/{branch}/open-pr` | §10.1 |
| GET | `/api/rfcs/{slug}/prs/{n}` | §10.3 |
| POST | `/api/rfcs/{slug}/prs/{n}/seen` | §10.3 |
| POST | `/api/rfcs/{slug}/prs/{n}/review` | §10.4 |
| POST | `/api/rfcs/{slug}/prs/{n}/merge` | §10.5 |
| POST | `/api/rfcs/{slug}/prs/{n}/withdraw` | §10.8 |
| POST | `/api/rfcs/{slug}/prs/{n}/description` | §10.2 |
| POST | `/api/rfcs/{slug}/prs/{n}/resolution-branch` | §10.9 |
Slice 3 ships covered by `backend/tests/test_pr_flow_vertical.py`
nine integration tests against an extended FakeGitea that grew PR
mergeability via base-snapshot tracking, no-fast-forward merge
behavior, and a `mergeable` field on PR responses. The tests cover
opening (with the §11.3 visibility flip and the §10.9 one-PR-per-
branch refusal), the AI draft, the three-column payload shape,
seen-cursor advance with stale-tab protection, review-thread
posting, arbiter-only merge, contributor withdraw with the
`withdrawn` state distinct from generic `closed`, anonymous read
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 6 — shipped
Notifications per §15 in full, end-to-end against the local Gitea.
The producer-side chokepoint lives in
[`backend/app/notify.py`](../backend/app/notify.py). Every bot
`_log` call drops into `notify.fan_out_from_action`, which upserts
the actor's `watches` row per §15.6's substantive-gesture rule and
runs the §15.1 routing table to insert zero-or-more `notifications`
rows. Chat-message inserts (the second writer surface, since chat
doesn't flow through the bot) call `notify.fan_out_chat_message`
from inside `chat.append_user_message` — same chokepoint shape, one
place to read the routing. The graduation orchestrator's `_audit`
helper folds into the same fan-out so `graduate_start` /
`graduate_complete` ride the chokepoint too.
§15.4 email lives in [`backend/app/email.py`](../backend/app/email.py).
The SMTP adapter wraps Python's `smtplib`; when `SMTP_HOST` is unset
it falls back to logging the envelope (and appending it to an
in-memory `_SENT` buffer the integration tests read from). The
per-category dispatch consults the recipient's toggles, holds
during §15.8 quiet hours, and on quiet-hours window-end the
`flush_pending` pass bundles into a single "Activity while you were
away" mail when more than `EMAIL_BUNDLE_THRESHOLD` accumulated.
One-click unsubscribe is a signed token over `(user_id, category)`;
the bounce webhook flips `email_opt_out_all` on the user (new
column added by migration 008).
§15.5 digest lives in [`backend/app/digest.py`](../backend/app/digest.py)
as a `DigestScheduler` mirroring `cache.Reconciler`'s shape. The
`run_tick` function is the test seam — integration tests drive
ticks synchronously, production runs the loop on a `DIGEST_TICK_SECONDS`
cadence (default 3600s). Each tick releases held emails, decays
§15.6 `watching` rows whose `last_participation_at` is >90 days
old, and assembles digests for users whose cadence window has
rolled over per `notification_digests.period_end`.
§15.2 / §15.3 / §15.7 / §15.8 surface as the twelve endpoints in
[`backend/app/api_notifications.py`](../backend/app/api_notifications.py)
plus the §15.7 chat-seen advance on `api_branches` and the PR
seen-cursor advance on `api_prs` — both extended to call
`notify.reconcile_seen_advance` so visit-advances-cursor closes the
inbox-read loop per §15.7. The `/api/notifications/stream` SSE
handler holds a per-user subscriber queue keyed by user_id; one
event per browser tab, all subscribers for a user receive every
event so the badge counter stays in lockstep across tabs.
| Method | Path | § |
| ------ | ------------------------------------------------- | ------- |
| GET | `/api/notifications` | §15.2 |
| POST | `/api/notifications/{id}/read` | §15.2 |
| POST | `/api/notifications/read` | §15.2 |
| GET | `/api/notifications/stream` | §15.3 |
| GET | `/api/watches` | §15.6 |
| POST | `/api/rfcs/{slug}/watch` | §15.6 |
| POST | `/api/rfcs/{slug}/branches/{branch}/chat-seen` | §15.7 |
| GET | `/api/users/me/notification-preferences` | §15.4/5 |
| POST | `/api/users/me/notification-preferences` | §15.4/5 |
| GET | `/api/users/me/quiet-hours` | §15.8 |
| POST | `/api/users/me/quiet-hours` | §15.8 |
| POST | `/api/users/{id}/notification-mute` | §15.8 |
| DELETE | `/api/users/{id}/notification-mute` | §15.8 |
| GET | `/api/email/unsubscribe` | §15.4 |
| POST | `/api/webhooks/email-bounce` | §15.4 |
On the frontend, `App.jsx` grew a header badge button (`📮` glyph
with a 99+-capped unread count) that opens the inbox overlay. The
overlay is `Inbox.jsx` — three filter chips (Unread only, RFC,
Category) plus a Bundle toggle and a "Mark all read (under filter)"
action. The badge subscribes to the SSE stream alongside the
overlay so they share a counter. `ToastHost.jsx` renders personal-
direct toasts and live-view toasts (an event firing on the slug
the URL points at), capped at four visible at a time with auto-
dismiss after a short interval.
Slice 6 ships covered by
[`backend/tests/test_notifications_vertical.py`](../backend/tests/test_notifications_vertical.py) —
seventeen integration tests covering the producer-side fan-out for
the propose/merge/decline chain, §15.6 auto-watch on first
interaction, the §15.2 inbox listing with filter chips, the §15.7
chat-seen reconciler, the §15.8 per-user mute and the per-RFC mute,
the §15.4 email-bounce webhook, the `/email/unsubscribe` signed-URL
path, the §15.8 quiet-hours hold, the §15.5 digest's emit-then-skip
behavior across two consecutive ticks, preferences and quiet-hours
round-trips, the explicit-watch override that prevents auto-downgrade,
and the SSE subscriber/broadcast substrate. The full Slices 16 test
suite is 62/62 green.
The schema needed one small migration —
[`008_email_opt_out.sql`](../backend/migrations/008_email_opt_out.sql)
adds the `email_opt_out_all` column to `users` for the bounce
webhook. Topic 13 settled the rest of the §5 surface before the
build started, so no further migrations were needed.
### Slice 8 — shipped
The hardening pass — the last slice of the v1 build. §12 + §10.7
branch hygiene, the §19.2 candidates that cluster with the hygiene
work, the dev/prod deployment shape, and the end-to-end smoke pass.
The §12 30/90 timers live in
[`backend/app/hygiene.py`](../backend/app/hygiene.py) as a
`HygieneScheduler` that mirrors `DigestScheduler`'s shape — same
`start` / `stop` / `_loop` contract, same `HYGIENE_TICK_SECONDS`
env override (default 3600), same `run_tick(now=...)` test seam so
the integration tests compress the 30/90-day windows without
monkey-patching the clock. Each tick runs four queries in the order
"delete first, close second" — the 90-day boundary takes priority
when a single sweep crosses both, which is the cache-bootstrap and
clock-jump case the brief calls out. Order:
1. §10.7 90-day post-merge delete (against `state IN ('open', 'closed')`
joined to a merged PR past the cutoff)
2. §12 90-day stale-closed delete (closed branches past `closed_at +
60d` since the prior 30d close)
3. §11.5 30-day idle close (open branches with no PR past the
cutoff)
4. §10.7 30-day post-merge close (open branches with a merged PR
past the cutoff)
The bot gains a `delete_branch` method that accepts `actor: Actor |
None`; the timer paths pass `None`, the audit row lands with
`actor_user_id=NULL` and `on_behalf_of=<bot login>` per §15.9's
"system-generated events" rule — "the app" in the noun slot. The
three action kinds (`close_idle_branch`, `delete_stale_branch`,
`delete_post_merge_branch`) are intentionally absent from
`notify._AUTO_WATCH_ACTIONS` and `notify._ROUTING`, so no
notifications fire. The branches being touched are stale by
definition; the affected population would be churn-grade noise per
§15.4. Pinned branches skip both passes. Per-user `branch_chat_seen`
cursors survive branch deletion — chat history is app-canonical, not
cached.
The §19.2 candidates the hardening pass folded in:
- **Branch-name path routing.** Every `branches/<branch>` route in
[`api_branches.py`](../backend/app/api_branches.py) and
[`api_prs.py`](../backend/app/api_prs.py) is now `{branch:path}`.
The bare `GET /api/rfcs/{slug}/branches/{branch:path}` is declared
*last* among the branch-scoped GETs, so the deeper `threads` and
`threads/{thread_id}/messages` GETs match before the greedy path
matcher swallows their sub-paths. The literal-prefix POST
`branches/main/promote-to-branch` doesn't collide with any other
POST suffix; ordering there is incidental.
- **Cache bootstrap from a pre-existing meta repo.** Exercised
directly by `test_cache_bootstrap.py`: an audit-log-empty FakeGitea
with PRs whose `gitea_opener` is the bot, the trailer parsed from
the body, the raw login as last resort. The `_resolve_actor`
fallback chain Slice 1 introduced now has an explicit test surface
against history the bot did not author.
- **In-app merge for metadata PRs.** [`api_prs._require_pr`](../backend/app/api_prs.py)
extends to `pr_kind='meta_metadata'`. The diff-rendered review
surface degrades gracefully (a metadata PR has no body diff worth
reviewing); the merge gesture lands in-app rather than forcing the
Gitea round-trip.
- **Graduation rollback's branch cleanup.** [`api_graduation._undo_open_pr`](../backend/app/api_graduation.py)
now deletes the `graduate-<slug>-<6hex>` branch after closing the
PR, so failed-graduation branches don't accumulate on the meta
repo across retries.
- **Email bounce webhook authentication seam.** [`api_notifications.email_bounce`](../backend/app/api_notifications.py)
checks `WEBHOOK_EMAIL_BOUNCE_SECRET` when set, refusing unsigned
POSTs with 401. Unset preserves the v1 unauthenticated behavior
for dev.
The deployment shape: [`deploy/RUNBOOK.md`](../deploy/RUNBOOK.md) is
rewritten from the prior `DEPLOY.md` stub into a real runbook —
prerequisites, first-time bring-up, day-2 operations (logs, database
backup, secret rotation, the §12 hygiene cadence), rollback shape,
and a troubleshooting table. The README's `.env` table grows the
SMTP block, `HYGIENE_TICK_SECONDS`, and `WEBHOOK_EMAIL_BOUNCE_SECRET`.
[`backend/.env.example`](../backend/.env.example) carries the same
fields with inline commentary.
Slice 8 ships covered by:
- [`test_hygiene_vertical.py`](../backend/tests/test_hygiene_vertical.py)
— seven tests covering the 30d close, 90d delete, 90d post-merge
delete, pinned-branch exemption, per-user-cursor preservation, the
no-notification decision, and the graduation-rollback branch
cleanup.
- [`test_branch_path_routing.py`](../backend/tests/test_branch_path_routing.py)
— four tests covering the slashed-branch GET, the deeper threads
GET still routing for both slashed and unslashed branches, and a
POST against a slashed branch.
- [`test_metadata_pr_merge.py`](../backend/tests/test_metadata_pr_merge.py)
— three tests covering the in-app merge of a `meta_metadata` PR,
the contributor refusal, and the withdraw path.
- [`test_cache_bootstrap.py`](../backend/tests/test_cache_bootstrap.py)
— four tests covering the audit-log / trailer / raw-login fallback
chain in `_resolve_actor`.
- [`test_e2e_smoke.py`](../backend/tests/test_e2e_smoke.py) — three
tests: the full lifecycle walk (propose → super-draft → edit
branch → body-edit PR → graduate → active-RFC PR → merge →
notification → hygiene-sweep deletion), the bounce-webhook signing
seam refusing unsigned POSTs when the secret is set, and the
unauthenticated open path when the secret is unset.
The full Slices 18 test suite is 96/96 green. The FakeGitea grew a
`DELETE /repos/{owner}/{repo}/branches/{branch:path}` handler and a
slashed-branch `GET /branches/{branch:path}` for these tests.
No schema migrations. Two minor spec corrections — §12 grew an
explicit note that the per-user `branch_chat_seen` cursor survives
branch deletion (the §11.5 contract made implicit; running code
asked for the load-bearing line to live in §12 too), and §10.7
grew a one-line pointer that the timer rides on §12's sweep rather
than its own schedule.
### Slice 7 — shipped
The §14 chrome, the §15 settings neighborhood, and the §6/§17 admin
home base — three surfaces over existing infrastructure.
§14.1's pre-login landing carries the title, the subtitle, the
short-form pitch from [`PHILOSOPHY.md`](../PHILOSOPHY.md), the
sign-in affordance, and a three-item deck that names what the
framework is. §14.2's `/philosophy` route reads `PHILOSOPHY.md`
through [`backend/app/philosophy.py`](../backend/app/philosophy.py)
(disk-backed, configurable via `PHILOSOPHY_PATH`; defaults to the
file at the project root) and renders with `marked`. §14.3's
persistent About link sits in the header alongside Settings (open
to everyone) and Admin (owner/admin only); the chrome's visual
budget stays tight per §14.4.
The notification-settings surface lives at `/settings/notifications`
([`NotificationSettings.jsx`](../frontend/src/components/NotificationSettings.jsx))
and is what the §15.4 email footer's `Manage all preferences` link
resolves to. Five sub-sections: the §15.4 per-category toggles
(with the `email_watched_churn` toggle permanently disabled and the
§15.4 refusal tooltip inline — naming the refusal is what keeps the
contract honest), the §15.5 digest cadence dropdown, the §15.8
quiet-hours editor (three inputs against
`Intl.supportedValuesOf('timeZone')` with all-three-or-clear
validation server-side), the §15.6 watches overview, and the
§15.8 per-user mute list with a typeahead add. Owners and admins
see the "cannot mute" prose inline per §15.8.
The admin home base lives at `/admin`
([`Admin.jsx`](../frontend/src/components/Admin.jsx)) as a four-
tab left-rail: Users (role management + §6.2 write-mute), Graduation
queue (§13.2-ready partition), Audit log (paged `actions` with
filter chips), and Permission events (paged `permission_events`).
Role-grant constraints land server-side in
[`backend/app/api_admin.py`](../backend/app/api_admin.py) per §6.1
— only owners may grant `owner`; owners cannot self-demote on the
role endpoint (the explicit succession path is a §19.2 candidate).
The §6.2 write-mute applies only to contributors; admins and owners
are not write-mutable. Every role and mute change writes a
`permission_events` row joined to `users` for the surface.
| Method | Path | § |
| ------ | --------------------------------------------- | ------- |
| GET | `/api/philosophy` | §14.2 |
| GET | `/api/admin/users` | §6.1 |
| POST | `/api/admin/users/{id}/role` | §6.1 |
| POST | `/api/admin/users/{id}/mute` | §6.2 |
| GET | `/api/admin/audit` | §6.5 |
| GET | `/api/admin/permission-events` | §6.5 |
| GET | `/api/admin/graduation-queue` | §13.2 |
| GET | `/api/users/me/notification-mutes` | §15.8 |
| GET | `/api/users/search` | §15.8 |
Slice 7 ships covered by
[`backend/tests/test_chrome_vertical.py`](../backend/tests/test_chrome_vertical.py) —
thirteen integration tests against the FakeGitea, covering the
philosophy route for anonymous and authenticated callers, the
§15.4 / §15.5 / §15.8 preferences round-trip (including the
permanent `email_watched_churn` refusal), the quiet-hours
all-or-nothing validation, the §15.8 mute add/list/unmute
round-trip, the user-search typeahead, the admin role and
write-mute round-trips with their `permission_events` audit, the
§6.1 refusal of owner-grant by non-owners, the audit-log filter
chips, the graduation-queue partition under both preconditions,
and the permission-events listing. The full Slices 17 test suite
is 75/75 green.
No schema migrations. The two surface-facing spec corrections —
§14.2 (PHILOSOPHY.md source is a deployment-time decision; v1 reads
from the app repo) and the §17 admin block (extended to name the
seven new endpoints) — are the only places the slice's running
code asked the spec to be more honest than it was.
### 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
rule landed in `backend/app/api_branches.py` and `backend/app/api_prs.py`
— every `branches/<branch>/...` and `prs/<n>/...` route now dispatches
on the entry's state to pick the right Gitea repo, and the body
extracted from the entry's frontmatter envelope is what the editor and
the diff see. The bot wrapper grew `open_metadata_pr`; the rest of the
bot's methods already accepted owner/repo arguments and worked against
the meta repo without change. The §4 cache learned about meta-repo edit
branches via a new `refresh_meta_branches` pass that 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 across active and super-draft surfaces. The §5 schema needed
no migration — the super-draft scoping note already settled that the
existing tables carry both cases.
The two §17 routes Slice 4 added:
| Method | Path | § |
| ------ | -------------------------------------- | ------- |
| POST | `/api/rfcs/{slug}/start-edit-branch` | §9.5 |
| POST | `/api/rfcs/{slug}/metadata` | §9.5 |
Everything else from the §8 vertical (chat, accept, decline, manual
flush, threads, flags, visibility, grants, the SSE chat stream) and the
§10 PR flow (open, draft, review, merge, withdraw, conflict-replay)
reaches super-drafts through the same routes Slices 2 and 3 shipped —
no per-state forks at the API surface.
The branch-naming choice: §9.5 names the structural shape
`edit/<slug>/<auto-name>`, but FastAPI's default `{branch}` path matcher
refuses slashes (the §19.2 path-routing candidate). Slice 4 picked
`edit-<slug>-<6hex>` — same dash-separated shape Slice 2 used for
`<login>-draft-<6hex>`. Metadata-pane PRs use the parallel
`metadata-<slug>-<6hex>` form. The cache parsers in `app/cache.py`
recognize both the dashed and slashed prefixes so a future routing-fix
slice can flip back without a data migration.
On the frontend, `RFCView.jsx`'s super-draft placeholder was replaced
by the full editor surface — same component, dispatched on
`entry.state`. The `BranchDropdown` renders `canonical body` as the
first position when the entry is a super-draft, per §9.4. A new
`MetadataPaneModal` opens from the breadcrumb actions when the viewer
holds super-draft edit authority per §9.5 (until §13.1's claim runs,
that's app admins/owners only).
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 from the envelope on read, accept
preserving the frontmatter on write, manual flush through the envelope,
the body-edit PR's `pr_kind='meta_body_edit'` shape, the full
cut-accept-open-merge loop with the §9.5 unclaimed-merge gate
(admin/owner only), the metadata pane PR cycle, the canonical-body
branch (`main` for super-drafts) being read-only, and the metadata pane
permission gate.
### What's deferred from Slice 2
These were in the §8 spec but lean on infrastructure later slices
build, so they were scoped out of this slice without altering the
spec:
- **Super-draft body editing on the meta repo (§9.5).** The
`branches/<branch>` machinery is structurally general enough that
meta-repo edit branches fall out of it once Slice 4 wires the
super-draft view's "Start Contributing" gesture to cut against the
meta repo. The Slice 2 RFCView renders a placeholder for
super-draft entries pointing at Slice 4.
- **The §10.4 review threads on PRs.** `thread_kind='review'` is in
the schema and the threads endpoints honor it generically, but the
PR-page surface where review threads anchor to diff hunks lands
with Slice 3.
- **DiffView's full reconstruction from `changes` history.** Slice 2
renders the editor's current HTML (which carries the
session-local tracked-change markup from the accepts that happened
in this session) into DiffView; rebuilding the full accepted-change
markup from `changes` for a returning contributor needs a render
pipeline DiffView doesn't yet own. The current behavior matches
§8.10's "session-local" framing exactly; the §19.2 "persistent
accepted-change markup" topic is the durable extension when
evidence demands it.
- **The §10.6 PR-side commit / chat reconciliation.** Manual-edit
flushes drop a system-author message into branch chat per §10.6
in Slice 2, but the PR-side seen-cursor that uses the marker
ships with Slice 3.
- **Branch-name path conversion for slashes.** The auto-generated
branch name in Slice 2 is `<login>-draft-<hex>` (no slash) so the
FastAPI `{branch}` path segment matches without `{branch:path}`.
Users can still rename to a slashed name, but the routes will
404 on read; the proper fix is `{branch:path}` everywhere, which
lands cleanly when Slice 3 makes the same change to the PR routes
(PR numbers don't have this problem, but resolving the routing
shape once across both surfaces is the right hop).
## Environment notes
- **Python 3.13.** Earlier 3.11+ should also work; 3.13 is what the
build session ran on.
- **Node 20+** for the frontend.
- **Local Gitea on port 3000.** Anything that exposes the Gitea v1
REST API works. If you tunnel Gitea elsewhere (e.g. a container,
a Codespace), re-run `scripts/seed_meta_repo.py` so the webhook
re-registers against the right `APP_URL`.
## Conventions
- **Bot writes only via `app/bot.py`.** If a module wants to call
`app/gitea.py`'s write methods directly, the spec is right and
the module is wrong — the wrapper is the chokepoint that makes
the §6.5 `On-behalf-of:` trailer and the §6 authorization both
consistent.
- **Cache writes only from `app/cache.py`.** User actions trigger
Git operations via the bot; the cache learns about them when the
webhook arrives (or the next reconciler sweep), and never before.
This invariant is what makes §4's "Git is truth" claim hold
operationally.
- **Spec corrections during the build are rare and surgical.** When
running code reveals the spec was wrong at a structural level (per
§19.3's working agreement), the correction lands in the appropriate
numbered section with a brief note explaining what running code
revealed. Spec extensions during the build are not in scope —
they accumulate in §19.2.
- **§16 stays deferred.** Body full-text search, per-RFC model
picker, funder role, persistent accepted-change markup, slug
renames — these are not shipped in any slice. They earn their own
topic sessions when use surfaces evidence they matter.
## After v1
v1 ships. Slice 8 was the last slice of the build. Subsequent sessions
pick from §19.2 by user choice per §19.3's working agreement — drive a
topic to decision, fold it in, update §19.2, hand off. They need not
be sequential; the user picks the next topic based on what evidence
the running app surfaces.
There is no "next slice" footer here because there isn't a next
slice. The work mode has shifted: the build is the source-of-truth
artifact, and §19.2 is the queue of decisions to settle when their
turn comes.