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
+135 -48
View File
@@ -1391,10 +1391,12 @@ After merge, the PR page renders identically with a "Merged" banner in
the header strip. The diff, the compressed conversation, and all review
threads become read-only. The branch enters the closed state per
§12's hygiene table (the 90-day deletion timer starts; owners and
arbiters can still pin to disable it). The branch chat persists per
§8.4 as historical record, with new posts disabled. The PR page remains
at its stable URL indefinitely — it is the canonical surface for "show
me how this definition came to be."
arbiters can still pin to disable it). The 90-day timer rides on §12's
hygiene sweep rather than its own schedule — the actuator is one extra
branch in the sweep that already handles meta-repo edit branches. The
branch chat persists per §8.4 as historical record, with new posts
disabled. The PR page remains at its stable URL indefinitely — it is
the canonical surface for "show me how this definition came to be."
Post-merge questions about the merged work belong on main's chat (§8.4),
not on the merged branch's. Main is the surface for ongoing argument
@@ -1492,6 +1494,22 @@ work is paused but legitimately ongoing.
| Deleted | 60 days after close (90 from last activity) | branch removed from Gitea, row remains |
| Pinned | owner/arbiter pins | auto-close disabled |
The bot is the actuator on the deletion: per §1 the bot is the only
Git writer, and the §12 sweep fires through it. The audit row lands
with `actor_user_id = NULL` and `on_behalf_of = <bot login>` per §15.9
— the timer is system-generated, "the app" in the noun slot. No
notification fires on a hygiene gesture; the affected population would
be churn-grade noise per §15.4.
The per-user message-cursor preservation contract: chat history
survives the branch's deletion in Gitea because the chat tables
(`thread_messages`, `branch_chat_seen`) are app-canonical, not cached.
Only the Gitea branch and the `cached_branches` row are touched by
the sweep; every `branch_chat_seen` row that points at a message on
the deleted branch stays intact, so a returning contributor sees the
same cursor when the conversation surfaces in the §9.8 pre-graduation
history affordance (or wherever else the app renders archived chat).
Future story (not v1): out-of-band reopening of a deleted branch by
email request to an owner.
@@ -2435,15 +2453,32 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: hardening
### 19.1 v1 is complete
Slice 7 of the build has landed. The §14 chrome, the
`/settings/notifications` neighborhood, and the `/admin` home base
all run end-to-end against the local Gitea, and the next slice has
the v1 surface fully wrapped — what remains is the hardening pass
that lets a single-operator deployment actually run.
Slice 8 — the hardening pass — has landed. With it, every slice of the
v1 build is in: the bot wrapper, the §4 cache, Gitea OAuth + user
provisioning, the §5 schema, the §7 catalog, the §8 active-RFC view in
full, the §9.4–§9.7 super-draft vertical, the §10 PR flow against both
per-RFC repos and meta-repo edit branches, the §13 graduation flow
with rollback, the §15 notifications surface in full, the §14 chrome
plus the `/settings/notifications` and `/admin` neighborhoods, and the
§12 + §10.7 branch hygiene with the §19.2 candidates the hardening
pass clustered in.
The §14.1 landing page now carries the title, the subtitle, the
Subsequent sessions pick from §19.2 by user choice per §19.3's working
agreement. They need not be sequential — the user picks the next topic
based on what evidence the running app surfaces. The build itself is
the source-of-truth artifact; §19.2 is the queue of decisions to
settle when their turn comes.
The Slice 8 entry below is preserved for the record. Future sessions
that fold a §19.2 candidate into the spec should mark that candidate
*settled* with a brief pointer at the section that resolved it — the
same shape Topic 13's resolution used.
### 19.1.1 Slice 8 record
**Slice 7 background.** The §14.1 landing page now carries the title, the subtitle, the
short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the
secondary "Read the full philosophy" link, and a three-item deck
underneath the pitch that names what the framework is — one word per
@@ -2507,42 +2542,60 @@ filter chips, the graduation-queue partition under both
preconditions, and the permission-events listing. The full Slices
17 test suite is 75/75 green.
**Slice 8 is the hardening pass — the last slice of the v1 build.**
Three pieces hang together:
**Slice 8 the hardening pass — completed the v1 build.** Three
pieces hang together:
The §12 30/90 branch-hygiene timers — the formalized policy that
closes the loop on §11.5's branch lifecycle (open → merged → 30d
read-only → 90d deleted-by-bot, with the per-user-message-cursor
preservation contract). The wiring is a scheduled task next to the
§15.5 digest scheduler; the §10.7 90-day deletion timer Slice 3
left deferred lives here too.
The §12 30/90 branch-hygiene timers ride on `HygieneScheduler` in
`backend/app/hygiene.py`, modeled on `DigestScheduler`'s shape. The
sweep runs hourly by default, exposes a `run_tick(now=...)` test
seam, and orders its queries delete-first so a single sweep crossing
both the 30d and 90d boundaries (the cache-bootstrap and clock-jump
case) lands the delete rather than spending one tick at `closed` with
a fresh `closed_at` that would defer the delete by another 90 days.
The §10.7 90-day deletion timer Slice 3 deferred is one branch of the
same sweep. The bot grew a `delete_branch` method that takes
`actor: Actor | None`; the timer paths pass `None` so the audit row
lands `actor_user_id = NULL` and `on_behalf_of = <bot login>` per
§15.9. No notifications fire on hygiene gestures (the action kinds
are absent from `_AUTO_WATCH_ACTIONS` and `_ROUTING`).
An end-to-end smoke pass over the working surfaces — propose →
super-draft → branch → PR → merge → graduate → active-RFC PR →
notification fans out → inbox → email — to catch the integration
seams a per-slice test wouldn't. Plus the §19.2 candidates the
hardening pass is the natural place to fold in: cache bootstrap
from a meta repo (the audit-log-first attribution shape Slice 1
chose, exercised against a meta repo with history the bot did not
author), branch-name path routing (converting every
`branches/<branch>` to `{branch:path}` with route-ordering
discipline), and the small Slice-2-onward follow-ons that are
deferred until the hardening pass demands them.
The §19.2 candidates folded in:
The dev/prod deployment shape — the `deploy/` directory already
has the nginx vhost, the 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), and lands the README updates that let a new operator
get from `git clone` to a signed-in browser.
- **Cache bootstrap from a pre-existing meta repo** — the
`_resolve_actor` fallback chain now has explicit integration
coverage against history the bot did not author (audit-log row,
trailer-parse, raw-login last-resort).
- **Branch-name path routing** — every `branches/<branch>` route is
`{branch:path}` with the bare GET declared last among the
branch-scoped GETs so deeper routes match first.
- **In-app merge for metadata PRs** — `api_prs._require_pr` accepts
`pr_kind='meta_metadata'`; the existing merge endpoint handles them
uniformly.
- **Graduation rollback's branch cleanup** —
`api_graduation._undo_open_pr` deletes the
`graduate-<slug>-<6hex>` branch after closing the PR.
- **Email bounce webhook authentication** — `WEBHOOK_EMAIL_BOUNCE_SECRET`
is the signing seam; when set, the webhook requires the same value
in `X-Webhook-Secret`.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry 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.
The dev/prod deployment shape: `deploy/RUNBOOK.md` is rewritten from
the prior stub into a real runbook (prerequisites, first-time
bring-up, day-2 operations, rollback, troubleshooting); the README's
`.env` table grows the SMTP block, `HYGIENE_TICK_SECONDS`, and
`WEBHOOK_EMAIL_BOUNCE_SECRET`; the `.env.example` carries the same
fields with inline commentary.
Slice 8 ships covered by `test_hygiene_vertical.py` (seven cases),
`test_branch_path_routing.py` (four cases), `test_metadata_pr_merge.py`
(three cases), `test_cache_bootstrap.py` (four cases), and
`test_e2e_smoke.py` (three cases including the full lifecycle walk).
The full Slices 18 test suite is 96/96 green.
§12 grew an explicit note that the bot is the actuator and 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. §10.7 grew a one-line pointer that the timer
rides on §12's sweep rather than its own schedule.
### 19.2 Candidate topics for sessions after the v1 build lands
@@ -2634,7 +2687,13 @@ binding.
null-system, and how the bot-vs-user distinction in §15.9
extends. Will surface during build if AI turn-times grow large
enough to warrant.
- **Cache bootstrap from a pre-existing meta repo.** §4.1 covers
- **Cache bootstrap from a pre-existing meta repo.** *Settled in
Slice 8 — the `_resolve_actor` fallback chain Slice 1 chose
(audit log → `On-behalf-of:` trailer → raw Gitea login) now has
explicit integration coverage in `test_cache_bootstrap.py`
against history the bot did not author. The disaster-recovery
and transferred-meta-repo cases work without the cache thinking
the bot proposed everything pre-app.* §4.1 covers
steady-state cache freshness — the webhook is the fast path, the
reconciler the safety net — but assumes the cache grew up
alongside the bot. If the cache is rebuilt from scratch against
@@ -2648,7 +2707,14 @@ binding.
topic once the cost of "the cache thinks the bot proposed
everything pre-app" becomes concrete. Touches §4.1 (the
reconciler's job description) and §15.9 (the attribution rule).
- **Branch-name path routing.** Slice 2's `branches/<branch>`
- **Branch-name path routing.** *Settled in Slice 8 — every
`branches/<branch>` route in `api_branches.py` and `api_prs.py` is
now `{branch:path}`. The bare GET is declared last among the
branch-scoped GETs so deeper routes (`threads`,
`threads/{thread_id}/messages`) match before the greedy path
matcher swallows their sub-paths. `test_branch_path_routing.py`
covers the slashed-branch read and the deeper-route ordering.*
Slice 2's `branches/<branch>`
endpoints use FastAPI's default `{branch}` path-segment matcher,
which refuses slashes. The Slice 2 auto-generated branch name
steered around this with `<login>-draft-<hex>`, but a user who
@@ -2721,12 +2787,21 @@ binding.
the app — but anyone reading the PR directly on Gitea sees the
pre-edit text. A small follow-on that propagates the edit
through the bot wrapper closes the loop.
- **The §10.7 90-day deletion timer wiring.** Slice 3 lands the
- **The §10.7 90-day deletion timer wiring.** *Settled in Slice 8 —
the timer rides on `HygieneScheduler`'s sweep alongside the §12
30/90 idle timers. See §10.7 and §12 for the spec text and
`backend/app/hygiene.py` for the implementation.* Slice 3 lands the
PR-merged state and the read-only treatment but does not wire
the §12 hygiene timer that fires the deletion. Slice 8
("Hardening") owns the §12 30/90 timers as a whole; calling out
here so the dependency is explicit.
- **In-app merge for metadata PRs.** Slice 4's metadata pane opens
- **In-app merge for metadata PRs.** *Settled in Slice 8 —
`api_prs._require_pr` now accepts `pr_kind='meta_metadata'`. The
existing `prs/<n>/merge` endpoint handles them uniformly; the
diff-rendered review surface degrades gracefully (a metadata PR
has no body diff to render). `test_metadata_pr_merge.py`
covers the merge, the contributor refusal, and the withdraw path.*
Slice 4's metadata pane opens
a meta-repo PR per §9.5; the merge surface for those PRs is the
Gitea web UI for now, because `api_prs.py`'s merge endpoint is
scoped to body-changing PRs (`rfc_branch` and `meta_body_edit`).
@@ -2759,6 +2834,10 @@ binding.
hits cases where a single sequence runs long enough that the
reload-during-graduation path matters.
- **Graduation PR auto-close on rollback's `close_graduation_pr`.**
*Settled in Slice 8 — `api_graduation._undo_open_pr` now deletes
the `graduate-<slug>-<6hex>` branch via `bot.delete_branch` after
closing the PR. `test_hygiene_vertical.test_graduation_rollback_deletes_dash_suffixed_branch`
covers it.*
Slice 5's rollback closes the graduation PR via `gitea.close_pull`
but leaves the dash-suffixed branch (`graduate-<slug>-<6hex>`)
in place. The branch is short-lived in steady-state — graduation
@@ -2824,7 +2903,15 @@ binding.
actor verb form so each row reads naturally without picking up
an apparent personification. Defer-able until contributor
feedback surfaces an irritating render.
- **Email bounce webhook authentication.** Slice 6's
- **Email bounce webhook authentication.** *Settled in Slice 8 — the
`WEBHOOK_EMAIL_BOUNCE_SECRET` env var is the shared-secret seam.
When set, the webhook requires the same value in the
`X-Webhook-Secret` header; when unset, the v1 unauthenticated
behavior holds for dev. The per-provider signature scheme
(Sendgrid signed events, SES SNS signatures, Postmark HMAC) maps
cleanly onto the same seam: the operator picks one when wiring a
real SMTP provider.*
Slice 6's
`/api/webhooks/email-bounce` accepts unauthenticated POSTs for
v1 — the SMTP provider's callback URL is the contract. When an
actual provider is wired in, the webhook needs a shared secret