Problem
A private document must reach its owner only. Supabase cannot decide that.
join_workspace (packages/supabase/scripts/10-functions.sql:833) adds any signed-in visitor to workspace_members and never looks at isPrivate, ownerId or deletedAt. It cannot: those three live in Prisma, and Supabase has no handle on them. So workspace_members is untrustworthy for privacy by construction.
Only the hocuspocus worker holds both database handles. If the worker does not gate, a document that turns private still emails every person who ever opened it.
Part of #169.
What to do
Blocked by Part D. git grep -n enrichDigestDocuments returns no match. Neither does content_change, p_only_user or email_content_changes. Part D supplies the content_changes block on a document entry. The send-time gate and the empty-digest skip both act on that block. Land Part D first, then this.
Two gates, both in the worker.
Gate one, at fan-out. Before calling the RPC, read the live Prisma metadata for the document and decide the audience:
deletedAt set — nobody. Return before the RPC call.
isPrivate with no ownerId — nobody.
isPrivate with an ownerId — that owner only, passed as p_only_user.
- otherwise — the full audience,
p_only_user null.
Put the call in the post-commit region of the save processor, after the 'Document stored successfully' log at apps/hocuspocus.server/src/lib/queue.ts:525. Copy the fire-and-forget shape of the setImmediate block at :530, but do not extend that block. :528 gates it on isFirstCreation && createdSlug, and a content change fires on every save. The doc:<id>:saved publish at :556 is a second example of the same shape.
The fan-out must never throw into the save job. A null Supabase client, a rejected RPC and a failed metadata read each log and continue.
Gate two, at send time. processDigestMessage (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:142) re-reads the metadata. A document can turn private between the carrier insert and the send. For a recipient who is not the owner, drop the content_changes block. Drop the whole document entry when a carrier seeded it.
Force-digest is a privacy invariant, not a rendering choice. In queue_email_notification (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:285), a content_change must always take the digest branch. The function writes its local queue_type into the email_type column (column list at :381, value at :386). So the assignment to make is queue_type := 'digest'. Only email_type = 'digest' rows are picked up by compile_digest_emails (:535, filter at :575).
The immediate path, processNotificationMessage (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:210), has no re-check and no enrichment. An immediate-typed carrier would email a line about a document that is now private.
Order matters. Put the force-digest assignment before the quiet-hours block. The frequency case is at packages/supabase/scripts/07-5-email-notifications-pgmq.sql:327-351. The quiet-hours gate is at :353-376. Quiet hours only tests queue_type = 'immediate' and then returns without inserting an email_queue row. If a content_change reached it, one quiet-hours window would drop the email. The sibling database issue suppresses re-fan-out for 24 hours, so one evening edit would silence a whole day. Name the test for this so it reads as a privacy pin.
Add the preference case in the same function (:309-318): when 'content_change' then should_queue := coalesce((prefs->>'email_content_changes')::boolean, true);. Default true. That is safe because the master email_enabled defaults to false (:305).
Consumer shape.
buildDigestDocuments (apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:78): a content_change raw row seeds the document entry, then continues before the channel grouping. Otherwise the row falls into the channel map at :105-122 and renders as a chat line.
- Empty-digest skip: drop a document entry that has neither real chat lines nor a
content_changes block. If no document remains, skip the email entirely and mark the queue ids 'skipped'. Widen the local updateEmailStatus union (:61-65) — it accepts 'sent' | 'failed' today.
- No database change is needed for
'skipped'. The email_queue status constraint already allows it (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:31), and update_email_status (:743) writes p_status straight through with no validation (:754-758). Still compare the production constraint against local first, because that constraint has drifted before.
- Frequency normalisation:
'immediate' now reaches the digest path for the first time. The digest payload's frequency is the user's email_frequency preference, not the queue type — compile_digest_emails reads it at :569-571 and sends it at :667. processDigestMessage casts it as 'daily' | 'weekly' at apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:157, so an immediate value would render "immediate digest". Map immediate to daily before rendering.
- Header copy: a digest built only from content changes counts zero notifications. The count is derived and rendered in four places. Fix all four:
packages/email-templates/src/engine.ts:84, the HTML line at packages/email-templates/templates/digest.eta:7, the plaintext line at apps/hocuspocus.server/src/lib/email/templates.ts:120, and the subject line at apps/hocuspocus.server/src/lib/email/sender.ts:119. Each sums only channels[].notifications, so each would read "0 notifications".
Acceptance
Notes
The fan-out matrix itself (member, editor, mute, 24-hour dedup) is proven in the sibling database issue. This issue proves the privacy decisions layered on top of it.
Problem
A private document must reach its owner only. Supabase cannot decide that.
join_workspace(packages/supabase/scripts/10-functions.sql:833) adds any signed-in visitor toworkspace_membersand never looks atisPrivate,ownerIdordeletedAt. It cannot: those three live in Prisma, and Supabase has no handle on them. Soworkspace_membersis untrustworthy for privacy by construction.Only the hocuspocus worker holds both database handles. If the worker does not gate, a document that turns private still emails every person who ever opened it.
Part of #169.
What to do
Blocked by Part D.
git grep -n enrichDigestDocumentsreturns no match. Neither doescontent_change,p_only_useroremail_content_changes. Part D supplies thecontent_changesblock on a document entry. The send-time gate and the empty-digest skip both act on that block. Land Part D first, then this.Two gates, both in the worker.
Gate one, at fan-out. Before calling the RPC, read the live Prisma metadata for the document and decide the audience:
deletedAtset — nobody. Return before the RPC call.isPrivatewith noownerId— nobody.isPrivatewith anownerId— that owner only, passed asp_only_user.p_only_usernull.Put the call in the post-commit region of the save processor, after the
'Document stored successfully'log atapps/hocuspocus.server/src/lib/queue.ts:525. Copy the fire-and-forget shape of thesetImmediateblock at:530, but do not extend that block.:528gates it onisFirstCreation && createdSlug, and a content change fires on every save. Thedoc:<id>:savedpublish at:556is a second example of the same shape.The fan-out must never throw into the save job. A null Supabase client, a rejected RPC and a failed metadata read each log and continue.
Gate two, at send time.
processDigestMessage(apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:142) re-reads the metadata. A document can turn private between the carrier insert and the send. For a recipient who is not the owner, drop thecontent_changesblock. Drop the whole document entry when a carrier seeded it.Force-digest is a privacy invariant, not a rendering choice. In
queue_email_notification(packages/supabase/scripts/07-5-email-notifications-pgmq.sql:285), acontent_changemust always take the digest branch. The function writes its localqueue_typeinto theemail_typecolumn (column list at:381, value at:386). So the assignment to make isqueue_type := 'digest'. Onlyemail_type = 'digest'rows are picked up bycompile_digest_emails(:535, filter at:575).The immediate path,
processNotificationMessage(apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:210), has no re-check and no enrichment. An immediate-typed carrier would email a line about a document that is now private.Order matters. Put the force-digest assignment before the quiet-hours block. The frequency
caseis atpackages/supabase/scripts/07-5-email-notifications-pgmq.sql:327-351. The quiet-hours gate is at:353-376. Quiet hours only testsqueue_type = 'immediate'and then returns without inserting anemail_queuerow. If acontent_changereached it, one quiet-hours window would drop the email. The sibling database issue suppresses re-fan-out for 24 hours, so one evening edit would silence a whole day. Name the test for this so it reads as a privacy pin.Add the preference case in the same function (
:309-318):when 'content_change' then should_queue := coalesce((prefs->>'email_content_changes')::boolean, true);. Default true. That is safe because the masteremail_enableddefaults to false (:305).Consumer shape.
buildDigestDocuments(apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:78): acontent_changeraw row seeds the document entry, thencontinues before the channel grouping. Otherwise the row falls into the channel map at:105-122and renders as a chat line.content_changesblock. If no document remains, skip the email entirely and mark the queue ids'skipped'. Widen the localupdateEmailStatusunion (:61-65) — it accepts'sent' | 'failed'today.'skipped'. Theemail_queuestatus constraint already allows it (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:31), andupdate_email_status(:743) writesp_statusstraight through with no validation (:754-758). Still compare the production constraint against local first, because that constraint has drifted before.'immediate'now reaches the digest path for the first time. The digest payload'sfrequencyis the user'semail_frequencypreference, not the queue type —compile_digest_emailsreads it at:569-571and sends it at:667.processDigestMessagecasts it as'daily' | 'weekly'atapps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:157, so an immediate value would render "immediate digest". Mapimmediatetodailybefore rendering.packages/email-templates/src/engine.ts:84, the HTML line atpackages/email-templates/templates/digest.eta:7, the plaintext line atapps/hocuspocus.server/src/lib/email/templates.ts:120, and the subject line atapps/hocuspocus.server/src/lib/email/sender.ts:119. Each sums onlychannels[].notifications, so each would read "0 notifications".Acceptance
content_changerow.content_changefor an unchanged document gets no email. The queue ids are marked'skipped'.email_frequency = 'immediate'gets oneemail_queuerow withemail_type = 'digest'.content_changestill produces a digest row.content_changerow.content_changesrenders byte-identical to today.Notes
The fan-out matrix itself (member, editor, mute, 24-hour dedup) is proven in the sibling database issue. This issue proves the privacy decisions layered on top of it.