Slice 8: v1 ships — integration coverage, runbook, spec corrections

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 04:14:50 -07:00
parent 1a0c4428af
commit 36635049c7
11 changed files with 1585 additions and 410 deletions
+124 -60
View File
@@ -288,6 +288,120 @@ adds the `email_opt_out_all` column to `users` for the bounce
webhook. Topic 13 settled the rest of the §5 surface before the
build started, so no further migrations were needed.
### Slice 8 — shipped
The hardening pass — the last slice of the v1 build. §12 + §10.7
branch hygiene, the §19.2 candidates that cluster with the hygiene
work, the dev/prod deployment shape, and the end-to-end smoke pass.
The §12 30/90 timers live in
[`backend/app/hygiene.py`](../backend/app/hygiene.py) as a
`HygieneScheduler` that mirrors `DigestScheduler`'s shape — same
`start` / `stop` / `_loop` contract, same `HYGIENE_TICK_SECONDS`
env override (default 3600), same `run_tick(now=...)` test seam so
the integration tests compress the 30/90-day windows without
monkey-patching the clock. Each tick runs four queries in the order
"delete first, close second" — the 90-day boundary takes priority
when a single sweep crosses both, which is the cache-bootstrap and
clock-jump case the brief calls out. Order:
1. §10.7 90-day post-merge delete (against `state IN ('open', 'closed')`
joined to a merged PR past the cutoff)
2. §12 90-day stale-closed delete (closed branches past `closed_at +
60d` since the prior 30d close)
3. §11.5 30-day idle close (open branches with no PR past the
cutoff)
4. §10.7 30-day post-merge close (open branches with a merged PR
past the cutoff)
The bot gains a `delete_branch` method that accepts `actor: Actor |
None`; the timer paths pass `None`, the audit row lands with
`actor_user_id=NULL` and `on_behalf_of=<bot login>` per §15.9's
"system-generated events" rule — "the app" in the noun slot. The
three action kinds (`close_idle_branch`, `delete_stale_branch`,
`delete_post_merge_branch`) are intentionally absent from
`notify._AUTO_WATCH_ACTIONS` and `notify._ROUTING`, so no
notifications fire. The branches being touched are stale by
definition; the affected population would be churn-grade noise per
§15.4. Pinned branches skip both passes. Per-user `branch_chat_seen`
cursors survive branch deletion — chat history is app-canonical, not
cached.
The §19.2 candidates the hardening pass folded in:
- **Branch-name path routing.** Every `branches/<branch>` route in
[`api_branches.py`](../backend/app/api_branches.py) and
[`api_prs.py`](../backend/app/api_prs.py) is now `{branch:path}`.
The bare `GET /api/rfcs/{slug}/branches/{branch:path}` is declared
*last* among the branch-scoped GETs, so the deeper `threads` and
`threads/{thread_id}/messages` GETs match before the greedy path
matcher swallows their sub-paths. The literal-prefix POST
`branches/main/promote-to-branch` doesn't collide with any other
POST suffix; ordering there is incidental.
- **Cache bootstrap from a pre-existing meta repo.** Exercised
directly by `test_cache_bootstrap.py`: an audit-log-empty FakeGitea
with PRs whose `gitea_opener` is the bot, the trailer parsed from
the body, the raw login as last resort. The `_resolve_actor`
fallback chain Slice 1 introduced now has an explicit test surface
against history the bot did not author.
- **In-app merge for metadata PRs.** [`api_prs._require_pr`](../backend/app/api_prs.py)
extends to `pr_kind='meta_metadata'`. The diff-rendered review
surface degrades gracefully (a metadata PR has no body diff worth
reviewing); the merge gesture lands in-app rather than forcing the
Gitea round-trip.
- **Graduation rollback's branch cleanup.** [`api_graduation._undo_open_pr`](../backend/app/api_graduation.py)
now deletes the `graduate-<slug>-<6hex>` branch after closing the
PR, so failed-graduation branches don't accumulate on the meta
repo across retries.
- **Email bounce webhook authentication seam.** [`api_notifications.email_bounce`](../backend/app/api_notifications.py)
checks `WEBHOOK_EMAIL_BOUNCE_SECRET` when set, refusing unsigned
POSTs with 401. Unset preserves the v1 unauthenticated behavior
for dev.
The deployment shape: [`deploy/RUNBOOK.md`](../deploy/RUNBOOK.md) is
rewritten from the prior `DEPLOY.md` stub into a real runbook —
prerequisites, first-time bring-up, day-2 operations (logs, database
backup, secret rotation, the §12 hygiene cadence), rollback shape,
and a troubleshooting table. The README's `.env` table grows the
SMTP block, `HYGIENE_TICK_SECONDS`, and `WEBHOOK_EMAIL_BOUNCE_SECRET`.
[`backend/.env.example`](../backend/.env.example) carries the same
fields with inline commentary.
Slice 8 ships covered by:
- [`test_hygiene_vertical.py`](../backend/tests/test_hygiene_vertical.py)
— seven tests covering the 30d close, 90d delete, 90d post-merge
delete, pinned-branch exemption, per-user-cursor preservation, the
no-notification decision, and the graduation-rollback branch
cleanup.
- [`test_branch_path_routing.py`](../backend/tests/test_branch_path_routing.py)
— four tests covering the slashed-branch GET, the deeper threads
GET still routing for both slashed and unslashed branches, and a
POST against a slashed branch.
- [`test_metadata_pr_merge.py`](../backend/tests/test_metadata_pr_merge.py)
— three tests covering the in-app merge of a `meta_metadata` PR,
the contributor refusal, and the withdraw path.
- [`test_cache_bootstrap.py`](../backend/tests/test_cache_bootstrap.py)
— four tests covering the audit-log / trailer / raw-login fallback
chain in `_resolve_actor`.
- [`test_e2e_smoke.py`](../backend/tests/test_e2e_smoke.py) — three
tests: the full lifecycle walk (propose → super-draft → edit
branch → body-edit PR → graduate → active-RFC PR → merge →
notification → hygiene-sweep deletion), the bounce-webhook signing
seam refusing unsigned POSTs when the secret is set, and the
unauthenticated open path when the secret is unset.
The full Slices 18 test suite is 96/96 green. The FakeGitea grew a
`DELETE /repos/{owner}/{repo}/branches/{branch:path}` handler and a
slashed-branch `GET /branches/{branch:path}` for these tests.
No schema migrations. Two minor spec corrections — §12 grew an
explicit note that the per-user `branch_chat_seen` cursor survives
branch deletion (the §11.5 contract made implicit; running code
asked for the load-bearing line to live in §12 too), and §10.7
grew a one-line pointer that the timer rides on §12's sweep rather
than its own schedule.
### Slice 7 — shipped
The §14 chrome, the §15 settings neighborhood, and the §6/§17 admin
@@ -590,65 +704,15 @@ spec:
renames — these are not shipped in any slice. They earn their own
topic sessions when use surfaces evidence they matter.
## Next slice
## After v1
**Slice 8: hardening — the last slice of the v1 build.**
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.
With Slice 7 shipped, every structural beat the spec commits to is
live and every surface the framework exposes has chrome around it.
What remains is the hardening pass that lets a single-operator
deployment actually run end-to-end without hand-holding. Three
pieces hang together:
- **The §12 30/90 branch-hygiene timers.** §11.5 names the branch
lifecycle (open → merged → 30d read-only → 90d deleted-by-bot,
with the per-user message-cursor preservation contract); §12
formalizes the policy. The wiring is a scheduled task next to
the existing `DigestScheduler` — same `run_tick` test-seam shape.
The §10.7 90-day deletion timer Slice 3 left explicitly deferred
lives here too. Touches `cache.Reconciler` (the natural place to
fire the hygiene sweep), `bot.delete_branch` (the §12 actuator,
not yet exercised), and the §19.2 cache-bootstrap topic if the
hygiene sweep also rebuilds branch state from Gitea after a
cache wipe.
- **An end-to-end smoke pass** over the working surfaces. Propose
→ super-draft → branch → PR → merge → graduate → active-RFC PR
→ notification → inbox → email — one or two `test_e2e_smoke.py`
cases that exercise the seams a per-slice test wouldn't. Plus
the §19.2 follow-ons the hardening pass is the natural place to
fold in: branch-name path routing (`{branch:path}` everywhere
with route-ordering discipline), cache bootstrap from a
pre-existing meta repo (the audit-log-first attribution shape
exercised against history the bot did not author), in-app merge
for metadata PRs, the graduation rollback's branch cleanup, and
the small Slice-2-onward follow-ons that are deferred until the
hardening pass demands them.
- **The dev/prod deployment shape.** `deploy/` already carries an
nginx vhost, a systemd unit, and a runbook stub. Slice 8 proves
the bring-up against a fresh host, settles the secret-material
handling (the existing `.env.example` plus the §15.4 SMTP
wiring), wires the §6 / §15.4 SMTP credentials, and lands the
README updates that take a new operator from `git clone` to a
signed-in browser.
What Slice 8 does NOT own:
- New surfaces. The v1 surface is complete; the hardening pass is
about making what's there resilient, observable, and operable.
- The §16 deferred items.
- The §19.2 candidate set as a whole — the hardening pass folds in
the candidates that naturally cluster with hygiene timers, cache
rebuild, and deployment; the rest stay queued for post-v1
sessions.
The carryovers Slice 8 inherits — the full §11.5 / §12 spec text,
the existing `cache.Reconciler` and `DigestScheduler` shape, the
deploy/ infrastructure, and the 75/75 green test suite.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 8 cleanly
without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new
candidate topics in §19.2, do not extend the spec beyond what the
slice requires.
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.