Slice 6: notifications per §15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:09:04 -07:00
parent 1b0968a9a2
commit f67d0aa0db
21 changed files with 3588 additions and 168 deletions
+129 -82
View File
@@ -2405,96 +2405,93 @@ 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: notifications per §15
### 19.1 Next slice: the §14 chrome and the settings neighborhood
Slice 5 of the build has landed. The §13 graduation flow runs
end-to-end against the local Gitea — the Graduate dialog renders
the three editable fields (integer ID, repo name, initial owners)
with the debounced `GET /api/rfcs/<slug>/graduate/check` lighting
up per-field validity inline, the precondition popover surfaces
open body-edit PRs via `GET /api/rfcs/<slug>/blocking-prs` (the
§9.8 gate enforced before the sequence starts), and confirming the
dialog kicks off the §13.3 five-step sequence streamed via
`GET /api/rfcs/<slug>/graduate/progress`. The orchestrator in
`api_graduation.py` runs the sequence as an asyncio task fed by an
in-memory queue; each step's bot primitive
(`create_rfc_repo_for_graduation`, `seed_graduated_rfc`,
`open_graduation_pr`, `merge_graduation_pr`) lands its own row in
`actions`, bracketed by `graduate_start` and `graduate_complete`
for the linkable sequence. Rollback is per-step and runs in
reverse: each forward step has a paired undo registered in
`_UNDO_BY_STEP` — the create-repo undo deletes the repo (which
also reclaims the seed commits, so seed-files' undo folds into
it), the open-pr undo closes the graduation PR. There is no
merge-pr undo by design; once the meta-repo merge has landed,
graduation is irreversible per §13.5.
Slice 6 of the build has landed. The §15 notifications surface runs
end-to-end against the local Gitea — every `actions` row whose
`action_kind` maps to a §15.1 event fans out through
`notify.fan_out_from_action`, called inline from `bot._log` and
from the graduation orchestrator's `_audit`. Chat-message inserts
take a parallel path through `notify.fan_out_chat_message` from
inside `chat.append_user_message`, since chat doesn't flow through
the bot wrapper. The §15.6 auto-watch upsert sits in the same
chokepoint — every substantive gesture either creates a `watching`
row or bumps `last_participation_at` for the 90-day decay timer.
§13.4's chat migration landed as a database semantic no-op —
the whole-doc main thread on the super-draft
(`rfc_slug=<slug>`, `branch_name='main'`) is the same row before
and after graduation; only the interpretation changes (canonical-
body view becomes per-RFC repo's main). The slug is the canonical
key per §2.3, so no data movement is needed. Edit-branch chats
stay attached to their original `branch_name` 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, with the read path
dispatching against the meta repo via a new `_is_meta_target(rfc,
branch)` helper that handles both super-draft branches and active-
RFC pre-graduation meta-repo branches uniformly.
The §15.4 email loop runs through an SMTP adapter with a stdout
fallback for dev — the in-memory `_SENT` buffer is what the
integration tests read from. The per-category dispatch holds during
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
above the §15.4 threshold into a single "Activity while you were
away" mail. The signed-URL unsubscribe path flips a single category
column to zero; the bounce webhook flips the new `email_opt_out_all`
column (migration `008_email_opt_out.sql`).
The §13.1 claim flow landed alongside graduation since it's the
prerequisite for non-admin graduation. The bot grew `open_claim_pr`;
`api_prs._require_pr` 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).
The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
shape, with a `run_tick` seam the tests drive synchronously. Each
tick releases held emails, runs the §15.6 90-day decay sweep, and
assembles per-cadence digests where the window has rolled over.
The §15.5 exclusion rules (already-emailed, already-read,
personal-direct-excluded) keep two consecutive ticks idempotent.
The five §17 routes Slice 5 added — `claim`, `blocking-prs`,
`graduate/check`, `graduate`, and `graduate/progress` — live in
`backend/app/api_graduation.py`. The §5 schema needed no migration.
On the frontend, `RFCView.jsx`'s breadcrumb actions grew
`Graduate to RFC repo` and `Claim ownership` buttons;
`GraduateDialog.jsx` owns the three-field surface, the precondition
popover, and the live step stack fed by an `EventSource` on the
progress SSE; the `BranchDropdown` gains a `Pre-graduation history`
disclosure that surfaces edit-branch threads on the new RFC view
per §9.8.
§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
`backend/app/api_notifications.py`, plus the chat-seen advance on
`api_branches` and the existing PR seen-cursor on `api_prs` — both
extended to call `notify.reconcile_seen_advance` so the §15.7
visit-advances-cursor loop closes back into the inbox-row read
state. The SSE stream holds a per-user subscriber queue keyed by
user_id; multiple browser tabs see the same events.
Slice 5 ships covered by `backend/tests/test_graduation_vertical.py`
— ten integration tests against the FakeGitea (extended with
`DELETE /repos/{owner}/{repo}` for the rollback inverse) covering
the dialog validator's per-field checks, the no-owners refusal,
the §9.8 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), concurrent-graduation refusal,
§13.4's chat-row-survives contract, the §9.8 pre-graduation
history surface, and the §13.1 claim PR cycle. The full Slices 15
test suite is 45/45 green.
On the frontend, `App.jsx` grew a header badge (cap "99+",
clicking opens the inbox overlay), an SSE-driven counter that
surfaces personal-direct toasts (own-name signals) and live-view
toasts (events landing on the slug the user is viewing). The
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
Category), a Bundle toggle, and a "Mark all read (under filter)"
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
**Slice 6 is notifications per §15.** Every other vertical now
produces signals — propose, claim, merge, graduate, body edits,
manual flushes, PR open/withdraw/merge, review threads, conflict-
replay — and Slice 6 builds the surface that turns those signals
into a contributor's inbox. The §5 schema already carries the
notifications, watches, branch_chat_seen, notification_user_mutes,
and notification_digests tables; Topic 13's session settled the
producer-side rules per §15.1, the §15.2 inbox grouping, §15.3
badges and toasts, §15.4 email categories, §15.5 digest cadence,
§15.6 watch/subscription, §15.7 unread mechanism, §15.8 do-not-
disturb, and §15.9 attribution. The producer-side hook is "after
a write succeeds, evaluate watches and fan-out notification rows"
— same chokepoint shape Slice 1's `_log` uses, invoked inline
from the bot wrapper. The consumer-side hook is the header badge,
the inbox panel, the toast surface, and the per-row read-state
machinery. The §15.4 email loop and the §15.5 digest are the
heavier sub-pieces — the digest needs a scheduled-job runner;
the email loop needs a transactional-email adapter and the
`POST /api/webhooks/email-bounce` receiver.
The §15.9 attribution rule fell out cleanly: every `notifications`
row carries `actor_user_id` resolved from the `actions.actor_user_id`
in the originating audit row (the underlying user, never the bot).
System-generated events (digest emission, 90-day decay) leave
`actor_user_id` NULL and render as "the app." AI participation
events landed as null-system per §19.2's candidate naming — when a
chat message authored by an AI provider goes through, no actor row
is written, since the LLM call doesn't have a user_id; the topic
folder for "AI participation as a notification source" in §19.2
remains open for explicit settling.
Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
— seventeen integration tests covering the producer-side fan-out
on the propose/merge/decline chain, §15.6 auto-watch, 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 flipping the global opt-out, the `/email/unsubscribe`
signed-URL path, the §15.8 quiet-hours email hold, the §15.5
digest's emit-then-skip behavior across two consecutive ticks,
preferences and quiet-hours round-trips, the explicit-watch
override that prevents auto-downgrade, and the SSE subscriber/
broadcast substrate. The full Slices 16 test suite is 62/62 green.
**Slice 7 is the §14 chrome plus the natural notification-settings
neighbor.** With every structural beat live, what remains for v1
is the chrome the framework wraps itself in. §14 commits the
landing page (the unauthenticated visitor's first read), the
`/philosophy` route (PHILOSOPHY.md surfaced inline), and the
persistent About link in the header. Slice 6 left the §15
preferences / quiet-hours / mute / watches endpoints in place
but with no chrome — the natural follow-on is `/settings/notifications`
exposing the per-category toggles, the digest cadence dropdown,
the quiet-hours editor, the watches overview, and the per-user
mute list. The §19.2 "admin surfaces" candidate is the second
natural neighbor — role management, the §6.2 app-wide write-mute,
the audit-log viewer, the graduation-readiness queue, all
consolidated where the chrome can hold them. Slice 7 picks the
framing and ships the three pieces together since they share an
information architecture.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry and pick up Slice 6 cleanly
`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 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
@@ -2747,6 +2744,56 @@ binding.
minimum that keeps the test surface terse without adding a
separate test-only module.
- **Body full-text search.** When the time comes.
- **The §15.2 inbox grouping's per-RFC + per-event-kind bundle's
represent-row choice.** Slice 6's bundle implementation collapses
rows under the (rfc_slug, event_kind) key and picks the most-recent
constituent as the representative. The §15.2 spec voice ("3 new
commits on PR #4 / RFC-0042" as a single bundle row) names the
count but not which representative's verb-phrase the bundle reads
as. A future session may settle whether the bundle reads in the
voice of the most-recent actor ("alice + 2 others added commits")
or a structural verb ("3 new commits on …"), and how the bundle
expands to its constituents (inline disclosure, modal, navigation).
Defer-able until usage shows the per-row shape doesn't suffice.
- **AI participation as a notification source — confirmed.** §19.2
already named this as a candidate; Slice 6 didn't settle it. The
build chose null-system for AI-generated content for now (no
`actor_user_id` since the LLM call has no user row), but the
§15.9 framing of "the system did not invent attribution" reads
cleanly only for genuinely unattributed events (auto-close,
digest emission). An AI-authored chat reply produces a chat
message and could fire a chat_message_in_participated_thread
signal to other thread participants, with the actor reading as
"the AI participant" — a candidate distinct entity. Touches
§15.9 (the actor slot in inbox prose), §8.12 (the AI participant's
authored-message shape), and the §19.2 per-RFC model availability
topic (which AI participant is the right noun for a row coming
out of that RFC?).
- **Inbox row prose for null-actor events.** Slice 6 renders
null-actor rows with the literal noun "the app" per §15.9's
"absence of an actor is the honest signal" framing. The phrase
works for some events (the digest emission email body) but
reads awkwardly for others ("the app started a resolution
branch"). A future session may settle a per-event-kind null-
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
`/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
or signature verification (Sendgrid's signed events, AWS SES's
SNS topic signature, etc.). Trivial to add per provider; the
routing-and-flip-the-column logic doesn't change.
- **Per-user mute exemption checks for arbiters.** §15.8 commits
that arbiters cannot mute participants on RFCs where they hold
authority. Slice 6's check uses "the muted user has a watches
row on the same RFC where the muter is an arbiter" as the
participation proxy. The spec doesn't define "active" precisely
for this check; the watches-row proxy is generous (a user with a
read-only relationship counts as active). A future session may
settle a tighter definition (e.g., has any `actions` row on the
RFC) if the generous proxy refuses too many legitimate mutes.
Topic 13 (notifications) is settled and folded into §5 (the
notifications, watches, branch_chat_seen, notification_user_mutes,