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
+131 -71
View File
@@ -186,6 +186,100 @@ posting, arbiter-only merge, contributor withdraw with the
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 16 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 5 — shipped
Graduation per §13 in full. The §13.3 five-step transactional sequence
@@ -416,86 +510,52 @@ spec:
## Next slice
**Slice 6: notifications per §15.**
**Slice 7: the §14 chrome.**
Every other vertical now produces signals: propose, claim, merge,
graduate, body edits, manual flushes, PR open/withdraw/merge,
review threads, conflict-replay, super-draft chat. Slice 6 builds
the inbox, the fan-out, the digest, and the email loop that turn
those signals into a contributor's surface. 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 signal-surface
stack), 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.
With Slice 6 shipped, every structural and notification beat the
framework commits to is live: propose, claim, super-draft body
editing, the §10 PR flow against both repo shapes, graduation, and
the §15 inbox/email/digest stack. What remains for v1 is the chrome
that wraps the whole thing — the landing page that brings an
unauthenticated visitor in, the `/philosophy` route that surfaces
[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
link in the header per §14.3, plus the natural neighbors that
Slice 6 left as API-only and that §19.2 names as candidates:
Slices 15 left this clean: every user gesture goes through the
bot wrapper and lands an `actions` row with the underlying actor.
The producer-side hook is "after a write succeeds, evaluate watches
and fan-out notification rows." The consumer-side hook is the
header badge, the inbox panel, the toast surface, and the per-row
read-state machinery.
- **The notification-settings surface** — the actual UI for the
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
settled the schema and the per-category rules; the surface
where a contributor finds the per-category email toggles, the
digest cadence dropdown, the quiet-hours editor, the watches
overview, and the per-user mute list is the natural follow-on.
Likely lives at `/settings/notifications` (the link Slice 6's
emails already point at).
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
Role management, the §6.2 app-wide write-mute, the audit-log
viewer, the graduation-readiness queue. Topics 12 and 13 both
expanded the admin's repertoire without giving it a centralized
home; Slice 7 picks the framing.
- **Landing page polish.** Slice 1 stood up a minimal landing for
the unauthenticated path; §14 commits a richer shape — what the
framework is, why it exists, what the visitor's first read should
be, and the sign-in affordance.
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
rendered inline, reachable from the header on every page, so the
reader can return to the framing without leaving the app.
What Slice 6 owns specifically:
What Slice 7 does NOT own:
- **The producer fan-out.** Every `actions` row whose event maps to a
§15 signal produces zero-or-more `notifications` rows by joining
against `watches` and applying the §15.1 priority rules. The
fan-out lives as a small module that the bot wrapper invokes
inline after each write — same chokepoint shape Slice 1's
`_log` uses.
- **The §15.2 inbox.** `GET /api/notifications` with the
`unread` / `rfc_slug` / `category` / `bundled` filter chips,
`POST /api/notifications/<id>/read` for per-row marking,
`POST /api/notifications/read` for the bulk filter mark, and the
SSE `GET /api/notifications/stream` that backs the live badge.
- **The §15.3 surface.** The header badge counter (live via the SSE),
the toast on personal-direct events while the user is active, and
the ambient signal — a colored dot per row on the §7 catalog
pointing at watched RFCs with unseen activity.
- **The §15.4 email loop.** Per-category opt-in/out preferences on
the users table (already in the schema), the `/api/users/me/notification-preferences`
endpoints, the email-send adapter that routes a notification's
category through the user's category toggle, and the
`POST /api/webhooks/email-bounce` receiver that sets the global
opt-out. Plus the `GET /api/email/unsubscribe` signed-URL
one-click flow.
- **The §15.5 digest.** A scheduled-job that runs daily and weekly
to roll up unseen notifications into a single email, with the
`notification_digests` table tracking what was included so the
next digest skips what already shipped.
- **The §15.6 watch model.** Auto-watch on first interaction with
an RFC, the per-row state column (`watching` / `following` /
`muted`), the 90-day auto-decay for unset rows, and the explicit
`POST /api/rfcs/<slug>/watch` overrides.
- **The §15.7 unread mechanism.** Advance the `branch_chat_seen`
cursor on every branch read, reconcile inbox notifications to
read when their underlying surface is consumed.
- **The §15.8 do-not-disturb.** Quiet-hours config on the user, the
per-user notification mute list, the orthogonality vs §6.2's
app-wide write-mute.
What Slice 6 does NOT own:
- The §14 chrome polish (still Slice 7).
- The §12 30/90 branch-hygiene timers (still Slice 8).
- The §16 deferred items.
- New §15 capabilities — Slice 6 shipped the surface; settings UI
is exposure of what's already there, not new behavior.
The carryovers Slice 6 inherits — the existing `actions` audit log
(every signal traces back to a row there per §15.9), the SSE
machinery from Slices 2 and 5 (chat-stream and graduate-progress
respectively), and the §5 schema's notification tables (already
in place from Topic 13).
The §15 surface depends on the producers being in place; with
Slice 5 landing the last structural producer (graduation events,
specifically `graduate_complete` as a personal-direct event for
the proposer per §15.4), every signal a contributor needs to see
is now in the audit log waiting to be fanned out.
The carryovers Slice 7 inherits — the existing §14 spec text, the
§17 endpoint set including Slice 6's settings endpoints, and the
React Router layout already in place.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 6 cleanly
`docs/DEV.md`, and `SPEC.md`'s §19.1 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