docs: HANDOFF pass-56, Phase 4 notification breadth + migration 047

Adds an ARCHITECTURE section "Notification breadth: bookings, classes,
forms" describing each new event type, the triggering action, the
recipient rule, and the dedupe-key scheme. Updates Known Limitations #5
to reflect that booking + class + form notifications are no longer "not
wired yet."

API_REFERENCE: extends the Transactional notifications section with the
shared enqueue helper, every new event type, its trigger and recipient,
and the new members_with_permission RPC used for the forms.manage
fan-out.

DATABASE_SCHEMA + DB_SCHEMA_MAP: adds the migration 047 row and the new
helper function to the helper-functions list.

HANDOFF: pass-56 entry covers the four locked design forks, the four
sub-phase commits, test growth (532 -> 565, +33 cases), state (local,
inert post-deploy until owner provisions Resend + CRON_SECRET), the
known fan-out volume tradeoff, and the remaining backlog.
This commit is contained in:
Moheeb Zara
2026-05-19 15:32:39 -07:00
parent a2ea7675e7
commit 6abac53380
5 changed files with 102 additions and 4 deletions
+2 -1
View File
@@ -412,6 +412,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 044 | `prevent_member_self_role_change` also blocks self-change of payment_status, payment_note, dues_paid_until, last_paid_at, last_payment_at, stripe_customer_id, joined_at (member could otherwise forge a dues-good signal via PostgREST). Service-client/privileged writes still bypass. No new table/column | | 044 | `prevent_member_self_role_change` also blocks self-change of payment_status, payment_note, dues_paid_until, last_paid_at, last_payment_at, stripe_customer_id, joined_at (member could otherwise forge a dues-good signal via PostgREST). Service-client/privileged writes still bypass. No new table/column |
| 045 | `class_signup_tx` / `class_cancel_tx` SECURITY DEFINER functions: capacity-decision+insert and cancel+waitlist-promote run under a per-session `pg_advisory_xact_lock` (concurrent signups can't over-enroll; concurrent cancels can't double-promote). Mirrors the pure computeSignupStatus/pickPromotion rule; RPC is runtime authority. No new table/column | | 045 | `class_signup_tx` / `class_cancel_tx` SECURITY DEFINER functions: capacity-decision+insert and cancel+waitlist-promote run under a per-session `pg_advisory_xact_lock` (concurrent signups can't over-enroll; concurrent cancels can't double-promote). Mirrors the pure computeSignupStatus/pickPromotion rule; RPC is runtime authority. No new table/column |
| 046 | `user_has_role_in_space` + `user_has_permission` gated on `status IN ('current','late')` (RLS-layer defense-in-depth for D2): unverified/inactive can no longer exercise a role/permission via direct PostgREST. `get_user_space_ids` + `user_effective_roles` intentionally NOT gated (would break unverified self-reads / onboarding). No new table/column | | 046 | `user_has_role_in_space` + `user_has_permission` gated on `status IN ('current','late')` (RLS-layer defense-in-depth for D2): unverified/inactive can no longer exercise a role/permission via direct PostgREST. `get_user_space_ids` + `user_effective_roles` intentionally NOT gated (would break unverified self-reads / onboarding). No new table/column |
| 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, set-returning inverse of `user_has_permission` (same current/late gate, same admin shortcut + `space_role_permissions`/`user_effective_roles` fallback). Lets Phase 4 fan-outs (e.g. `form_submission_admin`) resolve permission holders in one query. No new table/column/policy |
--- ---
@@ -452,4 +453,4 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`. Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`.
Helper functions: `user_effective_roles(uid,sid)`, `user_has_permission(uid,sid,perm)` (both SECURITY DEFINER, search_path=public), plus governance quorum/tally/SLA functions. Helper functions: `user_effective_roles(uid,sid)`, `user_has_permission(uid,sid,perm)`, `members_with_permission(sid,perm)` (all SECURITY DEFINER, search_path=public), plus governance quorum/tally/SLA functions.
+6 -2
View File
@@ -962,9 +962,13 @@ Per-space OWN Stripe account (NOT Connect). Pure mapping in `lib/stripe-logic.ts
- `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view. - `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view.
- Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; `proxy.ts` whitelists `/api/stripe/webhook` so the unauthenticated call is not redirected to `/login`): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. It also enqueues dues-lifecycle notifications (see below). Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe). - Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; `proxy.ts` whitelists `/api/stripe/webhook` so the unauthenticated call is not redirected to `/login`): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. It also enqueues dues-lifecycle notifications (see below). Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe).
### Transactional notifications: `notifications.ts` + `lib/email/*` + dispatcher (migration 041) ### Transactional notifications: `notifications.ts` + `lib/email/*` + dispatcher (migrations 041, 047)
Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `lib/notifications-logic.ts` (`renderDuesEmail`, `duesDedupeKey`, `isTerminalAttempt`, `MAX_NOTIFICATION_ATTEMPTS`) is unit-tested. `lib/email/send.ts` (`sendEmail`) is the transport seam over Resend's HTTP API (no SDK; `fetch`), returning `{ ok, id } | { ok:false, error, retryable }`; unset `RESEND_API_KEY`/`EMAIL_FROM` is a non-retryable no-op. Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `lib/notifications-logic.ts` (`renderDuesEmail`, `duesDedupeKey`, `isTerminalAttempt`, `MAX_NOTIFICATION_ATTEMPTS`, plus the Phase 4 additions below) is unit-tested. `lib/email/send.ts` (`sendEmail`) is the transport seam over Resend's HTTP API (no SDK; `fetch`), returning `{ ok, id } | { ok:false, error, retryable }`; unset `RESEND_API_KEY`/`EMAIL_FROM` is a non-retryable no-op. A shared best-effort helper `lib/notifications/enqueue.ts` (`resolveMemberContact`, `enqueueNotification`, `getSpaceName`, `buildManageUrl`) is the only enqueue path; it never throws into the calling action.
- The Stripe webhook enqueues into `notifications` (`ignoreDuplicates` on the `(space_id, dedupe_key)` unique index, so an event replay is a no-op): `invoice.paid` → `dues_renewed`, `invoice.payment_failed` → `dues_payment_failed`, transition to `late` (in `applySubscription`) → `dues_lapsed`. Recipient + space name resolved server-side; never sends inline. - The Stripe webhook enqueues into `notifications` (`ignoreDuplicates` on the `(space_id, dedupe_key)` unique index, so an event replay is a no-op): `invoice.paid` → `dues_renewed`, `invoice.payment_failed` → `dues_payment_failed`, transition to `late` (in `applySubscription`) → `dues_lapsed`. Recipient + space name resolved server-side; never sends inline.
- Phase 4 event types (no schema change to `notifications`; the `type` column is `text`):
- `reserveEquipment` → `booking_confirmed` (always, to the booked-for member, so a manager booking on someone's behalf still emails the target). `cancelReservation` → `booking_cancelled` ONLY when the actor is not the affected member (self-cancels stay silent).
- `signUpForClass` → `class_signup_registered` or `class_signup_waitlisted` (picked from `class_signup_tx`'s returned status, so a manager signing someone up still emails the target with the right copy). `cancelMySignup` → `class_signup_promoted` to whoever `class_cancel_tx` returned as `promoted_id`. `updateSession` setting `status='cancelled'` → `class_session_cancelled` fanned out to every still-active signup (dedupe by `(session, member)`).
- `submitForm` → `form_submission_received` ONLY when the submitter is authenticated (recipient = verified `user.email`, never the typed body email; anonymous public submissions skip the confirmation because confirming to a typed address is a victim-spam vector). Plus `form_submission_admin` fanned out to every member who holds `forms.manage` (link goes to `/forms/<id>/results`; dedupe by `(submission, admin)`). The fan-out uses the new `members_with_permission(sid, perm)` SQL helper (migration 047), the inverted set-returning form of `user_has_permission`.
- Dispatcher `POST /api/cron/notifications` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer; 503 if unset, 401 on mismatch; `proxy.ts` whitelists `/api/cron`). Drains ≤20 oldest `pending` email rows with `attempts < MAX`, sends with the row id as Resend `Idempotency-Key` (overlap-safe), ~4.5/s spacing. Success → `sent`+`sent_at`; failure → `attempts++`, `pending` if retryable and budget remains else `failed`. Returns `{ scanned, sent, failed, retried }`. Hit once a minute by the droplet crontab. - Dispatcher `POST /api/cron/notifications` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer; 503 if unset, 401 on mismatch; `proxy.ts` whitelists `/api/cron`). Drains ≤20 oldest `pending` email rows with `attempts < MAX`, sends with the row id as Resend `Idempotency-Key` (overlap-safe), ~4.5/s spacing. Success → `sent`+`sent_at`; failure → `attempts++`, `pending` if retryable and budget remains else `failed`. Returns `{ scanned, sent, failed, retried }`. Hit once a minute by the droplet crontab.
- `getMyNotifications()` — the caller's own last 15 notifications (service client; `notifications` is admin-RLS, mirrors `getMyBilling`). Surfaced read-only on `/me`. - `getMyNotifications()` — the caller's own last 15 notifications (service client; `notifications` is admin-RLS, mirrors `getMyBilling`). Surfaced read-only on `/me`.
- `getMyPayments()` — the caller's own last 50 payments (service client; `payments` SELECT is treasurer-scoped, so the self-view is strictly scoped to the caller's `space_id` + `member_id`, same convention as `getMyBilling`). Surfaced read-only in the `/me` Membership tab. - `getMyPayments()` — the caller's own last 50 payments (service client; `payments` SELECT is treasurer-scoped, so the self-view is strictly scoped to the caller's `space_id` + `member_id`, same convention as `getMyBilling`). Surfaced read-only in the `/me` Membership tab.
+57 -1
View File
@@ -715,6 +715,62 @@ response. The status write is guarded with `.eq('status','pending')`
so two overlapping dispatcher runs cannot flip a row another already so two overlapping dispatcher runs cannot flip a row another already
marked sent. No new permission code. marked sent. No new permission code.
### Notification breadth: bookings, classes, forms (product spine Phase 4)
Phase 2 (dues lifecycle) shipped the outbox + dispatcher. Phase 4 extends
event coverage to bookings, class signups, and form submissions, reusing
the same machinery unchanged (no new table, no dispatcher change, no new
env var). The `notifications.type` column is `text`, so adding event types
costs no migration.
A shared `lib/notifications/enqueue.ts` helper centralizes member-contact
lookup and the idempotent best-effort upsert; the Stripe webhook and every
new call site now go through it instead of inlining the same pattern.
New event types and their triggers:
- **Equipment** (`lib/actions/equipment.ts`)
- `booking_confirmed` on `reserveEquipment` (always; a manager booking
on behalf still emails the target member).
- `booking_cancelled` on `cancelReservation` **only when the actor is
not the affected member** (self-cancels stay silent; the actor
already saw the UI confirm).
- **Classes** (`lib/actions/classes.ts`)
- `class_signup_registered` / `class_signup_waitlisted` on
`signUpForClass`, picked from the RPC's returned signup status.
- `class_signup_promoted` on `cancelMySignup` when
`class_cancel_tx` returns a `promoted_id` (the bumped waitlist
member).
- `class_session_cancelled` on `updateSession` when status flips to
`cancelled`. Fans out one row per still-active signup (registered or
waitlisted), dedupe by `(session, member)` so a re-cancel is a no-op.
- **Forms** (`lib/actions/forms.ts`, `submitForm`)
- `form_submission_received` **only when the submitter is
authenticated** (members or `public_auth`). Recipient is the
verified Supabase auth email, never the typed `body.email`. Pure
anonymous public submissions skip this confirmation (typed emails
could belong to anyone; confirming to them would be a victim-spam
vector).
- `form_submission_admin` fans out to every member who holds
`forms.manage`, dedupe by `(submission, admin)`. The admin link
goes to `/forms/<id>/results`. Finding the recipient set efficiently
is what migration 047 (`members_with_permission(sid, perm)`)
exists for: an inverted, set-returning form of `user_has_permission`
that respects the same current/late status gate (046) and the same
admin shortcut + space-role-permissions / effective-roles fallback.
Additive; existing `user_has_permission` callers unchanged.
Dedupe-key scheme (all keyed under `(space_id, dedupe_key)`):
`booking_*:<reservation_id>`; signup-keyed class events
`class_signup_*:<signup_id>` (stable across promotion);
`class_session_cancelled:<session_id>:<member_id>`;
`form_submission_received:<submission_id>`;
`form_submission_admin:<submission_id>:<admin_id>`.
Every enqueue is best-effort and wrapped: a notifications-table or
permission-lookup failure can never throw into the calling action, so the
underlying domain mutation always finalizes.
### Member self-serve portal (`/me`; product spine Phase 3) ### Member self-serve portal (`/me`; product spine Phase 3)
`/me` is a 3-tab portal (Profile / Membership / Activity). The server page does all data fetching and passes plain data to a single client portal component (`me-portal-client.tsx`) that owns tab state and rendering; the read-only sections were moved verbatim from the prior flat page (no behavior change in the shell). Profile editing and inline cancels reuse existing server actions (`updateMyProfile`, `discloseAffiliations`, `cancelMySignup`, `cancelReservation`) — no new mutation surface, server-side ownership/space scoping unchanged. `getMyPayments` follows the established service-client self-view convention (treasurer-scoped RLS, strictly scoped to the caller). Self-service email change goes through Supabase Auth (`updateUser` + `/auth/confirm` `verifyOtp`); the denormalized `space_members.email` is synced only post-verification. The email-change flow depends on Supabase project config (template + redirect allowlist; see DEPLOYMENT) and is inert until that is set. No new permission code, no schema change. `/me` is a 3-tab portal (Profile / Membership / Activity). The server page does all data fetching and passes plain data to a single client portal component (`me-portal-client.tsx`) that owns tab state and rendering; the read-only sections were moved verbatim from the prior flat page (no behavior change in the shell). Profile editing and inline cancels reuse existing server actions (`updateMyProfile`, `discloseAffiliations`, `cancelMySignup`, `cancelReservation`) — no new mutation surface, server-side ownership/space scoping unchanged. `getMyPayments` follows the established service-client self-view convention (treasurer-scoped RLS, strictly scoped to the caller). Self-service email change goes through Supabase Auth (`updateUser` + `/auth/confirm` `verifyOtp`); the denormalized `space_members.email` is synced only post-verification. The email-change flow depends on Supabase project config (template + redirect allowlist; see DEPLOYMENT) and is inert until that is set. No new permission code, no schema change.
@@ -727,6 +783,6 @@ marked sent. No new permission code.
2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated. 2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated.
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags. 3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`). 4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
5. **Email notifications** - Dues-lifecycle transactional email is implemented (migration 041: notifications outbox + Resend transport + dispatcher cron). Other domains (bookings, forms) are not wired yet but reuse the same outbox. 5. **Email notifications** - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (per-type opt-in/out) and an in-app notification center are the next planned passes; today every event is enqueued unconditionally.
6. **Search** - Client-side filtering only, no server-side full-text search. 6. **Search** - Client-side filtering only, no server-side full-text search.
7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed. 7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.
+1
View File
@@ -727,6 +727,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 044 | `prevent_member_self_role_change` extended to also block a non-privileged member changing their own `payment_status`, `payment_note`, `dues_paid_until`, `last_paid_at`, `last_payment_at`, `stripe_customer_id`, `joined_at` (RLS lets a member UPDATE their own row, so these were self-settable to forge a dues-good signal). Service-client (`auth.uid()` NULL) and privileged-on-another-member writes still bypass. No new table/column | | 044 | `prevent_member_self_role_change` extended to also block a non-privileged member changing their own `payment_status`, `payment_note`, `dues_paid_until`, `last_paid_at`, `last_payment_at`, `stripe_customer_id`, `joined_at` (RLS lets a member UPDATE their own row, so these were self-settable to forge a dues-good signal). Service-client (`auth.uid()` NULL) and privileged-on-another-member writes still bypass. No new table/column |
| 045 | `class_signup_tx(p_session_id,p_space_id,p_member_id)` + `class_cancel_tx(...)` SECURITY DEFINER, `search_path=public`. Each takes `pg_advisory_xact_lock(hashtext(session_id))` then does the capacity-decision+dup-check+insert (signup) or cancel+conditional waitlist-promote (cancel) atomically. Closes the concurrent over-enroll / double-promote races (dynamic capacity can't be a constraint). Mirrors `computeSignupStatus`/`pickPromotion`; the SQL is the runtime authority. No new table/column/policy | | 045 | `class_signup_tx(p_session_id,p_space_id,p_member_id)` + `class_cancel_tx(...)` SECURITY DEFINER, `search_path=public`. Each takes `pg_advisory_xact_lock(hashtext(session_id))` then does the capacity-decision+dup-check+insert (signup) or cancel+conditional waitlist-promote (cancel) atomically. Closes the concurrent over-enroll / double-promote races (dynamic capacity can't be a constraint). Mirrors `computeSignupStatus`/`pickPromotion`; the SQL is the runtime authority. No new table/column/policy |
| 046 | `user_has_role_in_space` and `user_has_permission` extended with `status IN ('current','late')` (mirrors `lib/permissions` `PRIVILEGE_STATUSES`). RLS-layer defense-in-depth for D2 (the require_approval / unverified-admin escalation): a non-privilege-eligible member who somehow bypassed the app guards still cannot exercise a role or permission. `get_user_space_ids` and `user_effective_roles` are deliberately NOT gated so unverified members keep their own /me + onboarding reads (the legitimate require_approval flow). No new table/column/policy | | 046 | `user_has_role_in_space` and `user_has_permission` extended with `status IN ('current','late')` (mirrors `lib/permissions` `PRIVILEGE_STATUSES`). RLS-layer defense-in-depth for D2 (the require_approval / unverified-admin escalation): a non-privilege-eligible member who somehow bypassed the app guards still cannot exercise a role or permission. `get_user_space_ids` and `user_effective_roles` are deliberately NOT gated so unverified members keep their own /me + onboarding reads (the legitimate require_approval flow). No new table/column/policy |
| 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, `search_path=public`. Inverted, set-returning form of `user_has_permission`: returns `member_id` for every member of `sid` who holds `perm` (same current/late status gate as 046, same admin shortcut, same `space_role_permissions` + `user_effective_roles` fallback). Lets the Phase 4 notification fan-outs (e.g. `form_submission_admin`) find permission holders in one query instead of N round-trips. Additive; existing `user_has_permission` callers unchanged. No new table/column/policy |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+36
View File
@@ -4,6 +4,42 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## 2026-05-19 (pass 56): Product spine Phase 4: notification breadth (LOCAL, awaiting one reviewed deploy)
Branch `main`. Took item 3 from the pass-55 backlog: booking, class signup, and form-submission notifications, reusing the Phase 2 outbox + dispatcher unchanged. Built per sub-phase, gated each, small commit per sub-phase, single deploy at the end of the set. Suite 565 (was 532; +33 new tests), build clean. NOT deployed.
### Design forks (locked via AskUserQuestion)
- Anonymous form submitter confirmation: **auth-only**. Recipient is the verified Supabase `user.email`, never the typed `body.email`; anonymous public submissions skip the submitter confirmation entirely (typed emails could belong to anyone, so confirming to them is a victim-spam vector). Admin alert still fires.
- Form admin recipient set: **`forms.manage` permission holders** (same gate the forms-guard / forms RLS use).
- Cancel confirmations: **only when someone other than the affected member cancelled** (self-cancels stay silent; the actor already saw the UI confirm).
- Session-cancellation: **fan out**: when an instructor flips a session to `cancelled`, every still-active signup gets an email (dedupe by `(session, member)`).
### Shipped (commits 84d31ee, 64ffa4e, 105b62f, a2ea767, + this docs)
- **P4a (84d31ee)** Shared best-effort enqueue helper `lib/notifications/enqueue.ts` (`resolveMemberContact`, `enqueueNotification`, `getSpaceName`, `buildManageUrl`). Stripe webhook's `enqueueDues` refactored to compose them; zero behavior change (same recipient resolution, same upsert shape, same idempotency via `(space_id, dedupe_key)`, same best-effort wrap). 12 new helper unit tests.
- **P4b (64ffa4e)** Equipment: `reserveEquipment` → `booking_confirmed` (always, to the booked-for member). `cancelReservation` → `booking_cancelled` only when actor ≠ affected member. `renderBookingEmail` + `bookingDedupeKey` added; existing `renderDuesEmail` quietly refactored onto a shared `renderShell` (one place to fix the HTML wrapper across 6 templates).
- **P4c (105b62f)** Classes: `signUpForClass` → `class_signup_registered` or `class_signup_waitlisted` from the RPC's returned status. `cancelMySignup` → `class_signup_promoted` to whoever `class_cancel_tx` returned as `promoted_id`. `updateSession` setting `status='cancelled'` → `class_session_cancelled` fanned out to every still-active signup. No `class_signup_cancelled` type: `cancelMySignup` is self-only, so under the rule it has no caller.
- **P4d (a2ea767)** Forms: `submitForm` → `form_submission_received` only when authenticated; `form_submission_admin` fanned out to every member with `forms.manage`. Migration 047 adds `members_with_permission(sid, perm)` SECURITY DEFINER (the inverted, set-returning form of `user_has_permission`, same current/late gate as 046) so the fan-out resolves recipients in one query, not N. Additive; existing `user_has_permission` callers unchanged. `types/database.ts` + `schema.sql` + `docs/DATABASE_SCHEMA.md` + `DB_SCHEMA_MAP.md` updated same-change.
### Test coverage
33 new `__tests__/` cases (suite 565). Per-render unit tests cover subject lines, body copy, location + range formatting, the "someone" fallback in admin alerts, HTML escaping of injected names/titles. Per-dedupe-key tests pin shape and per-member fan-out distinctness. The shared enqueue helper has hit/miss/error/throw coverage. Integration suite (31 vs real Postgres) unchanged: the existing Stripe-webhook integration test already exercises the outbox semantics end to end via the same shared upsert; P4 actions enqueue through the same path so the runtime contract is the same.
### State
- **Not deployed.** Awaiting one reviewed deploy for the whole set (migration 047 runs as part of `deploy.sh`).
- **Inert post-deploy** until owner provisions Resend (`RESEND_API_KEY` + `EMAIL_FROM` on a verified domain) and `CRON_SECRET` + the once-a-minute droplet crontab. Until then the outbox fills but nothing sends, same as Phase 2 today. New-event volume is bounded by user activity; the dispatcher's per-attempt Resend `Idempotency-Key` and the `(space_id, dedupe_key)` collapse already make replay-safe.
- **No volume governor.** Every form submission fans out one row per `forms.manage` holder; a popular waiver could create N rows per submission. Acceptable today (the unique index makes it idempotent); a digest / throttle is a separate phase (lines up with member preferences and an in-app inbox).
### Backlog after this
The pass-55 list minus item 3. Next-most-leverage items remain:
1. Owner-gated end-to-end spine validation (the shipped≠proven gap).
2. Production observability (Sentry-equivalent for the webhook + dispatcher + actions).
3. Phase 4-adjacent: member notification preferences (per-type opt-in/out; the dispatcher checks prefs before sending; natural next pass now that breadth exists).
4. In-app notification center on `/me` (unread/read state).
5. Extend the integration harness (payments link/import, role/permission management, presence, forms).
6. Door epic Phases 4-5.
7. Owner product question (forms attribution).
---
## 2026-05-19 (pass 55) — Doc drift fix + session closeout + new-session backlog ## 2026-05-19 (pass 55) — Doc drift fix + session closeout + new-session backlog
Branch `main`. Final pass of an extraordinarily long arc. Suite 532; integration suite 31 vs real Postgres; build clean. Branch `main`. Final pass of an extraordinarily long arc. Suite 532; integration suite 31 vs real Postgres; build clean.