ee6e3491e7
SPEC, DEV docs, and code comments still talked about the codebase as a rewrite-in-progress against an external prototype. With v1 shipped the framing reads oddly — it implies code is provisional when it's the production thing. Recast §18 as "the technical stack," strip "carryover from the prototype" comments across backend (api.py, chat.py, providers.py) and frontend (DiffView, PromptBar, SelectionTooltip, modelStyles), and rework SPEC §1 / §18 to introduce OHM up front rather than as a follow-on to a prototype reference. Also: - RUNBOOK: bump Python prereq to 3.11+ to match the production VM (was 3.13). - Remove IMPLEMENTATION-PROMPT.md — the original implementation brief is no longer load-bearing. - Add deploy/DEPLOY-NEW-SESSION-PROMPT.md as the durable deploy-handoff prompt for new sessions.
925 lines
50 KiB
Markdown
925 lines
50 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
|
||
|
||
### Post-v1: §6.7 per-RFC credential delegation (funder role + grant shape)
|
||
|
||
The second §19.2 candidate settled after v1 shipped. The original
|
||
per-RFC credential-delegation topic explicitly subdivided into "funder
|
||
role + delegation grant" (schema/permissions-shaped) and "operational
|
||
realities" (mid-call failure, rotation, billing, rate-limit
|
||
attribution); this session folded in the lighter half and left the
|
||
heavier one as its own §19.2 entry. The natural follow-on to §6.6 —
|
||
§6.6 names *which* models are permitted, §6.7 names *whose credentials
|
||
pay*.
|
||
|
||
The settlement: a new optional `funder:` frontmatter field on the
|
||
meta-repo entry, naming a single gitea_login (parallel in shape to
|
||
`owners:` / `arbiters:` / `models:`). The frontmatter is RFC-side
|
||
approval (edited via the meta-repo PR flow). A `funder_consents`
|
||
app-db row is the funder-side approval (the user opts in per-slug
|
||
from `/settings/funder`). Both halves are required for the binding to
|
||
be operationally active — the spec calls this the "frontmatter +
|
||
consent hybrid" two-key rule. Either side can veto by withdrawing
|
||
their half.
|
||
|
||
When a funder is in effect, the funder universe **replaces** the
|
||
operator universe for the RFC's picker — it does not augment.
|
||
Resolution is attribution-clean: a call on a funder-active RFC is
|
||
paid entirely from funder credentials, never blended per-call. If
|
||
the funder cannot serve the resolved default model, the §10.2
|
||
draft falls back to its deterministic stub (same shape as §6.6's
|
||
opt-out); the §8.12 chat refuses with 503. Three revocation paths
|
||
each restore the operator-credentials status quo: funder withdraws
|
||
consent (instant), frontmatter edit removes/changes the field
|
||
(PR flow), funder's account disabled (the §6.1 future affordance —
|
||
when it lands, the two-key rule extends to a three-key check).
|
||
|
||
§6.7 carries the structural rule. §2.1's frontmatter example shows
|
||
the field. §6.6's last paragraph now points at §6.7 for the
|
||
credentials half. §17 grows five funder endpoints (self-read,
|
||
credential register/delete, consent add/withdraw). §18 reframes the
|
||
operator-credentials default as one of two cases (the other being a
|
||
named funder per §6.7). §19.2's credential-delegation entry is split
|
||
— the lighter half is marked *settled* with a pointer to §6.7; the
|
||
operational-realities half lives on as its own entry.
|
||
|
||
Code changes:
|
||
|
||
- [`backend/migrations/010_funder.sql`](../backend/migrations/010_funder.sql)
|
||
adds `cached_rfcs.funder_login` (nullable, NULL meaning absent),
|
||
the `user_funder_credentials` table (per-user, per-provider-family
|
||
API key, primary key `(user_id, provider)`), and the
|
||
`funder_consents` table (per-user, per-slug opt-in, primary key
|
||
`(user_id, rfc_slug)`).
|
||
- [`backend/app/entry.py`](../backend/app/entry.py) grows an optional
|
||
`funder: str | None` field on the `Entry` dataclass. The parser
|
||
treats empty strings and `None` as absent (one set of semantics —
|
||
unlike `models:`, there is no second "explicit opt-out" meaning).
|
||
The serializer emits `funder:` only when truthy.
|
||
- [`backend/app/cache.py`](../backend/app/cache.py)'s
|
||
`_upsert_cached_rfc` writes `funder_login` from frontmatter on the
|
||
round-trip; the existing `refresh_meta_repo` and reconciler sweep
|
||
carry the column through transparently.
|
||
- [`backend/app/funder.py`](../backend/app/funder.py) is the new
|
||
module that holds the §6.7 runtime — three responsibilities:
|
||
the consenting-funder lookup (`consenting_funder_user_id(slug)`
|
||
enforces the two-key rule); the universe + per-(slug,
|
||
picker_key) provider resolver (`resolve_funder_universe`,
|
||
`provider_for_rfc`); the credential and consent CRUD that the
|
||
§17 endpoints in `api.py` call into. The module deliberately
|
||
does not handle per-call fallback, instance caching, or
|
||
rotation — those are the operational-realities §19.2 candidate.
|
||
- [`backend/app/providers.py`](../backend/app/providers.py) grows
|
||
three §6.7 helpers: `FUNDER_PROVIDER_FAMILIES` enumerates the
|
||
family names (`anthropic` / `google` / `openai`),
|
||
`provider_family_for_picker_key` maps a picker key to its family,
|
||
and `construct_for_funder` instantiates a fresh provider from a
|
||
funder-supplied API key (mirrors the variant table
|
||
`load_providers` uses, without the env contract).
|
||
- [`backend/app/models_resolver.py`](../backend/app/models_resolver.py)'s
|
||
resolver consults `funder.resolve_funder_universe` first; when a
|
||
consenting funder is in effect, the funder universe replaces the
|
||
operator universe as the base set the §6.6 frontmatter intersects
|
||
against. The replace-not-augment rule lives here.
|
||
- [`backend/app/api_branches.py`](../backend/app/api_branches.py)'s
|
||
chat-stream and reask paths route their provider lookup through
|
||
`funder.provider_for_rfc(slug, model_key, providers)` instead of
|
||
the direct `providers[model_key]` access. Same shape as before
|
||
when no funder is named; constructs fresh on each call when one is.
|
||
- [`backend/app/api_prs.py`](../backend/app/api_prs.py)'s
|
||
`_draft_with_provider` takes the slug and routes through
|
||
`funder.provider_for_rfc`. The §6.6 stub-fallback path now also
|
||
catches the case where the funder cannot serve the default model.
|
||
- [`backend/app/api.py`](../backend/app/api.py) adds the five
|
||
§17 endpoints: `GET /api/users/me/funder` (lists registered
|
||
provider families without exposing the API keys themselves plus
|
||
the consented slugs), `POST /api/users/me/funder/credentials`
|
||
(refused if the provider family is not in the operator's enabled
|
||
set — the §6.7 cannot-expand-operator rule),
|
||
`DELETE /api/users/me/funder/credentials/<provider>`,
|
||
`POST /api/rfcs/<slug>/funder/consent` (refused if the user has
|
||
no registered credentials — a consent without a universe would
|
||
be inert), and `DELETE /api/rfcs/<slug>/funder/consent`.
|
||
|
||
Settlement ships covered by
|
||
[`test_funder_vertical.py`](../backend/tests/test_funder_vertical.py)
|
||
— nineteen integration tests across the two-key rule's four
|
||
combinations (frontmatter-only inert, consent-only inert, both
|
||
present active, neither present operator), the universe-replaces-
|
||
not-augments rule, the §6.6 + §6.7 composition with an empty
|
||
intersection falling into the opt-out shape, the funder-with-no-
|
||
matching-family case, the consent-withdrawal and frontmatter-removal
|
||
revocation paths, the API surface's read-without-API-key contract,
|
||
the cannot-expand-operator gate, the consent-needs-credentials gate,
|
||
the full opt-in/opt-out cycle, the delete-credential-leaves-consents-
|
||
intact rule, the §10.2 PR-draft routing through funder credentials
|
||
(with the API key the funder registered), the §10.2 fallback to
|
||
stub when the funder cannot serve the default, the §8.12 chat
|
||
routing through funder credentials, the cache round-trip from
|
||
meta-repo frontmatter through `refresh_meta_repo`, and the entry
|
||
parser/serializer round-trip preserving the field.
|
||
|
||
The full test suite is 125/125 green (106 prior + 19 new). No
|
||
behavioral change for RFCs without `funder:` in frontmatter — the
|
||
operator-credentials path is preserved as the default, so existing
|
||
deployments see no surface change until a funder field lands.
|
||
|
||
### Post-v1: §6.6 per-RFC model availability (UX half)
|
||
|
||
The first §19.2 candidate settled after v1 shipped. The heavier
|
||
per-RFC-model topic explicitly subdivided into "model availability"
|
||
(UX-shaped) and "credential delegation + funder role" (security,
|
||
billing, rotation); this session folded in the lighter half and
|
||
left the heavier one as its own §19.2 entry.
|
||
|
||
The settlement: a new optional `models:` frontmatter field on the
|
||
meta-repo RFC entry, in the same shape as `owners:` / `arbiters:`.
|
||
Absent inherits the operator's universe (the §18 `ENABLED_MODELS`
|
||
env contract, now reframed as the operator's provisioned set).
|
||
Populated lists narrow the picker to the intersection with that
|
||
universe, preserving the entry's stated order. An empty list
|
||
(`models: []`) opts the RFC out of AI participation entirely —
|
||
every AI surface honors the refusal honestly. The first entry of
|
||
the resolved list is the RFC's default model; the §10.2 PR-draft
|
||
uses it when present and falls back to its existing deterministic
|
||
stub when the resolved list is empty.
|
||
|
||
§6.6 carries the structural rule. §2.1's frontmatter example shows
|
||
the field. §8.12 names the picker's option-list source. §10.2
|
||
names the model used for drafting. §16 narrows its "model picker"
|
||
deferral to the chrome only. §18 reframes `ENABLED_MODELS` as the
|
||
operator universe. §19.2's per-RFC-model entry is split — the UX
|
||
half is marked *settled* with a pointer to §6.6; the credential
|
||
half lives on as its own entry.
|
||
|
||
Code changes:
|
||
|
||
- [`backend/migrations/009_per_rfc_models.sql`](../backend/migrations/009_per_rfc_models.sql)
|
||
adds `cached_rfcs.models_json` as a nullable column — NULL means
|
||
the frontmatter field is absent (inherit the universe), `'[]'`
|
||
means the explicit opt-out, `'[...]'` is the populated list. The
|
||
absent-vs-empty distinction is load-bearing per §6.6, so this
|
||
column cannot collapse to a `NOT NULL DEFAULT '[]'` the way the
|
||
other `*_json` columns do.
|
||
- [`backend/app/entry.py`](../backend/app/entry.py) grows an
|
||
optional `models: list[str] | None` field on the `Entry`
|
||
dataclass. The parser uses a sentinel to distinguish "key not in
|
||
YAML" from "key set to null" so both round-trip cleanly. The
|
||
serializer emits `models:` only when set, preserving the absent
|
||
case in canonical entry text.
|
||
- [`backend/app/cache.py`](../backend/app/cache.py)'s
|
||
`_upsert_cached_rfc` writes `NULL` or `json.dumps(entry.models)`
|
||
on the round-trip from frontmatter through the cache.
|
||
- [`backend/app/models_resolver.py`](../backend/app/models_resolver.py)
|
||
is the new small module that holds the rule. Two functions:
|
||
`resolve_models_for_rfc(slug, providers)` returns the resolved
|
||
list, and `default_model_for_rfc(slug, providers)` returns the
|
||
first entry or empty string. Every AI surface calls into one of
|
||
these; the rule lives in one place.
|
||
- [`backend/app/api_branches.py`](../backend/app/api_branches.py)
|
||
replaces the slug-less `GET /api/models` with the per-RFC
|
||
`GET /api/rfcs/{slug}/models` per §6.6. The chat-stream and
|
||
reask-change paths now resolve through the per-RFC list and
|
||
refuse with 503 when it is empty. Both `default_model` references
|
||
inside the module are removed; the resolver replaces them.
|
||
- [`backend/app/api_prs.py`](../backend/app/api_prs.py)'s §10.2
|
||
`pr-draft` endpoint resolves the RFC's default via the new
|
||
helper. The internal `_draft_with_provider` falls back to the
|
||
deterministic stub when `default_model` is empty (the §6.6
|
||
opt-out case), extending the prior "no providers configured"
|
||
fallback symmetrically.
|
||
- [`frontend/src/api.js`](../frontend/src/api.js) and
|
||
[`frontend/src/components/RFCView.jsx`](../frontend/src/components/RFCView.jsx)
|
||
pass the slug to `listModels(slug)`, hitting the new per-RFC
|
||
endpoint.
|
||
|
||
Settlement ships covered by
|
||
[`test_per_rfc_models.py`](../backend/tests/test_per_rfc_models.py)
|
||
— ten integration tests: absent-frontmatter inheriting the
|
||
operator universe, populated-frontmatter narrowing, the
|
||
empty-list opt-out, intersection with the operator's provisioned
|
||
set, intersection-empty matching the opt-out shape, the §10.2
|
||
draft using the RFC default, the §10.2 stub fallback on opt-out,
|
||
the chat surface's 503 refusal on opt-out, the cache round-trip
|
||
from meta-repo frontmatter through `refresh_meta_repo`, and the
|
||
entry parser/serializer round-trip preserving the absent /
|
||
empty / populated distinction.
|
||
|
||
The full test suite is 106/106 green (96 prior + 10 new). No
|
||
behavioral change for RFCs without `models:` in frontmatter — the
|
||
operator universe is preserved as the default, so existing
|
||
deployments see no surface change until a frontmatter edit lands.
|
||
|
||
### 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
|
||
stack landed as `backend/app/providers.py` (the multi-provider
|
||
abstraction) 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` is the §8
|
||
three-column surface; `Editor.jsx`, `ChatPanel.jsx`,
|
||
`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`,
|
||
`DiffView.jsx`, `ModelPicker.jsx`, and `modelStyles.js` make up the
|
||
center column. The §18 stack: 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.
|