Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 1–5
|
||||
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 1–6 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,
|
||||
|
||||
Reference in New Issue
Block a user