36635049c7
- 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>
719 lines
39 KiB
Markdown
719 lines
39 KiB
Markdown
# 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 §§1–15 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 1–6 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 1–8 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 1–7 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 1–5 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.
|