Problem
A person who opens a document never learns that it changed later. There is no follow state, no carrier row and no fan-out.
Nothing in the tree matches content_change, content_email_muted_at, notify_document_content_change, set_document_follow or get_document_follow_state. Confirm with git grep -n "content_change\|content_email_muted_at\|notify_document_content_change\|set_document_follow\|get_document_follow_state" — it returns nothing.
This issue is the database half. Part of #169.
Decided — 2026-09-01
Drop claim 2. Record the gap.
The fan-out RPC returns 0 when the document has no workspaces row, and it does not create one. A document created through the REST API, which no signed-in person has ever opened, notifies nobody, owner included.
The rejected alternative was to insert the row with on conflict (id) do nothing. It was rejected because it puts a write into a read path, and because it would leave workspaces.created_by null forever, against the documented meaning at packages/supabase/scripts/03-0-workspaces.sql:20-21.
This gap must be documented, not left to be discovered. apps/hocuspocus.server/API.md states it in the audience paragraph, in plain words: a document only reaches followers once a signed-in person has opened it at least once.
The ruling is recorded on #169.
The original question, kept for the record
What was decided
The design states two things that cannot both hold:
- The fan-out RPC returns 0 when no
workspaces row exists.
- A private document still reaches an owner who never opened it.
In the live path, only a signed-in browser open creates that row. It happens through join_workspace (packages/supabase/scripts/10-functions.sql:833). Two browser helpers can also insert a workspaces row, apps/webapp/src/api/workspaces/createWorkspace.ts and apps/webapp/src/api/workspaces/upsertWorkspace.ts, but neither has a caller today. A document created through the REST API and never opened has no row. So claim 2 fails exactly when claim 1 fires.
Pick one:
- Drop claim 2. Record the gap: a private document that nobody has ever opened while signed in sends nothing.
- Let the RPC create the row. Insert with
on conflict (id) do nothing, copying the expression at packages/supabase/scripts/10-functions.sql:858-859: the documentId for id and name, and lower(documentId) for slug. That expression already passes the check_slug_format constraint (packages/supabase/scripts/03-0-workspaces.sql:16) inside join_workspace today. Accept that created_by stays null. The column is nullable (packages/supabase/scripts/03-0-workspaces.sql:9). Its comment (:20-21) says it means "First signed-in visitor to open the document", not the owner. A service-role fan-out is not a visitor, so null is the honest value.
What to do
The design is settled. Do not re-derive it.
- The subscription is workspace membership. Every signed-in document open runs
join_workspace. So there is no new table.
- The only new state is one nullable column:
workspace_members.content_email_muted_at timestamptz. NULL means following.
- The carrier rides
public.notifications with a new notification_category value, content_change. The enum has eight values today (packages/supabase/scripts/01-enum.sql:71-80). The digest compiler already groups every pending digest row per user (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:532), so it needs no change.
Six script edits:
01-enum.sql — add 'content_change' after 'system_alert' (:79).
03-1-workspace_members.sql — add content_email_muted_at, with a comment saying it must never be trusted for privacy.
07-4-push-notifications-pgmq.sql — the push gate. See below.
07-5-email-notifications-pgmq.sql — the preference case and the force-digest line. The type case there ends in else should_queue := true; (:316-317), so a new type queues email by default. That work is specified in the sibling consumer issue.
10-func-notifications.sql — notify_document_content_change.
10-8-func-workspace_members.sql — set_document_follow and get_document_follow_state.
Leave 13-RLS.sql and 16-cron-jobs.sql alone. No new policy and no new scheduler.
Grant the two RPCs from the hardening array, not next to the functions. A grant written next to the new functions will not survive a reset.
packages/supabase/scripts/29-lint-hardening.sql §5 (:161-184) loops over every SECURITY DEFINER function in public that the migration role owns. For each one it runs REVOKE EXECUTE ON FUNCTION %I.%I(%s) FROM public, anon, authenticated (:178-181). §6 then re-grants EXECUTE to authenticated from one hardcoded array, user_facing_names (:202-231).
Neither set_document_follow nor get_document_follow_state is in that array. The file is loaded last (:4-5). packages/supabase/generate-seed.ts:76 sorts scripts by numeric prefix, so 29 runs after 10. Without adding both names to the array, both RPCs answer 42501 permission denied to the browser after every local db reset.
A migration cannot cover this locally. packages/supabase/config.toml:58 sets enabled = false under [db.migrations], and :61 sets schema_paths = ["./scripts/*.sql"]. A local reset applies the scripts and skips the migrations. So the array edit belongs in the script, and the migration repeats it for remote.
Keep notify_document_content_change out of the array on purpose. It is service_role-only, the same posture as enqueue_document_view described at 29-lint-hardening.sql:257-259.
Type trap. workspace_members.workspace_id is varchar(36) (packages/supabase/scripts/03-1-workspace_members.sql:6). member_id is uuid (:7). They differ. SQL that compares one to the other raises operator does not exist: character varying = uuid. Compare varchar to varchar and uuid to uuid.
Push gate. enqueue_push_notification (packages/supabase/scripts/07-4-push-notifications-pgmq.sql:186) gates only three types: mention, reply and reaction (:229-237). There is no else. Any other type falls through and pushes. So a content_change carrier would push with no gate at all. Add if v_notify_type = 'content_change' and not coalesce((v_prefs->>'push_content_changes')::boolean, false) then return new; end if;. This gate is load-bearing. No push UI toggle.
Sender guard. The carrier's sender_user_id must be NULL when the actor has no public.users row, and the insert must still run. The column already allows null (packages/supabase/scripts/07-0-notifications.sql:12). Do not copy the precedent at packages/supabase/scripts/10-func-notifications.sql:32-40. That is an early RETURN NEW which aborts the whole fan-out. Every REST write path has a null actor, so a literal copy would notify nobody. The reaction variant at :422-441 skips the row instead. Neither shape fits. Write case when exists(select 1 from public.users where id = p_actor_id) then p_actor_id end and pin it with a test.
set_document_follow is update-only. An upsert would fire notify_user_join_workspace (packages/supabase/scripts/10-8-func-workspace_members.sql:3), which posts a "joined" chat message. It must also leave updated_at untouched. join_workspace writes that column as the last-visit stamp (packages/supabase/scripts/10-functions.sql:867-869), and the roster renders it (packages/supabase/scripts/10-8-func-workspace_members.sql:238).
SQL order is required.
- Edit
packages/supabase/scripts/*.sql.
- Run
bun run --filter @docs.plus/supabase_back reset. It regenerates seed.sql. A fresh-volume seed must come up green.
- Author the paired migrations.
- Re-apply each migration to the seeded database to prove idempotence.
- Run
bun run --filter @docs.plus/supabase_back types and ship the regenerated apps/webapp/src/types/supabase.ts in the same change. Never hand-edit it.
Ship two migrations. The enum value gets its own migration that consumes nothing: alter type public.notification_category add value if not exists 'content_change';. Supabase wraps each migration in one transaction, and Postgres forbids using an enum value added in the same transaction. The second, later-timestamped migration holds the column, the functions and the grants.
Acceptance
Notes
The worker producer seam sits beside the existing fire-and-forget block in apps/hocuspocus.server/src/lib/queue.ts:527-547. That block sits just above the doc:<id>:saved publish at :556. The privacy rules that decide p_only_user are specified in the sibling consumer issue.
Problem
A person who opens a document never learns that it changed later. There is no follow state, no carrier row and no fan-out.
Nothing in the tree matches
content_change,content_email_muted_at,notify_document_content_change,set_document_followorget_document_follow_state. Confirm withgit grep -n "content_change\|content_email_muted_at\|notify_document_content_change\|set_document_follow\|get_document_follow_state"— it returns nothing.This issue is the database half. Part of #169.
Decided — 2026-09-01
Drop claim 2. Record the gap.
The fan-out RPC returns 0 when the document has no
workspacesrow, and it does not create one. A document created through the REST API, which no signed-in person has ever opened, notifies nobody, owner included.The rejected alternative was to insert the row with
on conflict (id) do nothing. It was rejected because it puts a write into a read path, and because it would leaveworkspaces.created_bynull forever, against the documented meaning atpackages/supabase/scripts/03-0-workspaces.sql:20-21.This gap must be documented, not left to be discovered.
apps/hocuspocus.server/API.mdstates it in the audience paragraph, in plain words: a document only reaches followers once a signed-in person has opened it at least once.The ruling is recorded on #169.
The original question, kept for the record
What was decided
The design states two things that cannot both hold:
workspacesrow exists.In the live path, only a signed-in browser open creates that row. It happens through
join_workspace(packages/supabase/scripts/10-functions.sql:833). Two browser helpers can also insert aworkspacesrow,apps/webapp/src/api/workspaces/createWorkspace.tsandapps/webapp/src/api/workspaces/upsertWorkspace.ts, but neither has a caller today. A document created through the REST API and never opened has no row. So claim 2 fails exactly when claim 1 fires.Pick one:
on conflict (id) do nothing, copying the expression atpackages/supabase/scripts/10-functions.sql:858-859: the documentId foridandname, andlower(documentId)forslug. That expression already passes thecheck_slug_formatconstraint (packages/supabase/scripts/03-0-workspaces.sql:16) insidejoin_workspacetoday. Accept thatcreated_bystays null. The column is nullable (packages/supabase/scripts/03-0-workspaces.sql:9). Its comment (:20-21) says it means "First signed-in visitor to open the document", not the owner. A service-role fan-out is not a visitor, so null is the honest value.What to do
The design is settled. Do not re-derive it.
join_workspace. So there is no new table.workspace_members.content_email_muted_at timestamptz.NULLmeans following.public.notificationswith a newnotification_categoryvalue,content_change. The enum has eight values today (packages/supabase/scripts/01-enum.sql:71-80). The digest compiler already groups every pending digest row per user (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:532), so it needs no change.Six script edits:
01-enum.sql— add'content_change'after'system_alert'(:79).03-1-workspace_members.sql— addcontent_email_muted_at, with a comment saying it must never be trusted for privacy.07-4-push-notifications-pgmq.sql— the push gate. See below.07-5-email-notifications-pgmq.sql— the preference case and the force-digest line. The typecasethere ends inelse should_queue := true;(:316-317), so a new type queues email by default. That work is specified in the sibling consumer issue.10-func-notifications.sql—notify_document_content_change.10-8-func-workspace_members.sql—set_document_followandget_document_follow_state.Leave
13-RLS.sqland16-cron-jobs.sqlalone. No new policy and no new scheduler.Grant the two RPCs from the hardening array, not next to the functions. A grant written next to the new functions will not survive a reset.
packages/supabase/scripts/29-lint-hardening.sql§5 (:161-184) loops over every SECURITY DEFINER function inpublicthat the migration role owns. For each one it runsREVOKE EXECUTE ON FUNCTION %I.%I(%s) FROM public, anon, authenticated(:178-181). §6 then re-grants EXECUTE toauthenticatedfrom one hardcoded array,user_facing_names(:202-231).Neither
set_document_follownorget_document_follow_stateis in that array. The file is loaded last (:4-5).packages/supabase/generate-seed.ts:76sorts scripts by numeric prefix, so 29 runs after 10. Without adding both names to the array, both RPCs answer42501 permission deniedto the browser after every localdb reset.A migration cannot cover this locally.
packages/supabase/config.toml:58setsenabled = falseunder[db.migrations], and:61setsschema_paths = ["./scripts/*.sql"]. A local reset applies the scripts and skips the migrations. So the array edit belongs in the script, and the migration repeats it for remote.Keep
notify_document_content_changeout of the array on purpose. It is service_role-only, the same posture asenqueue_document_viewdescribed at29-lint-hardening.sql:257-259.Type trap.
workspace_members.workspace_idisvarchar(36)(packages/supabase/scripts/03-1-workspace_members.sql:6).member_idisuuid(:7). They differ. SQL that compares one to the other raisesoperator does not exist: character varying = uuid. Compare varchar to varchar and uuid to uuid.Push gate.
enqueue_push_notification(packages/supabase/scripts/07-4-push-notifications-pgmq.sql:186) gates only three types:mention,replyandreaction(:229-237). There is noelse. Any other type falls through and pushes. So acontent_changecarrier would push with no gate at all. Addif v_notify_type = 'content_change' and not coalesce((v_prefs->>'push_content_changes')::boolean, false) then return new; end if;. This gate is load-bearing. No push UI toggle.Sender guard. The carrier's
sender_user_idmust be NULL when the actor has nopublic.usersrow, and the insert must still run. The column already allows null (packages/supabase/scripts/07-0-notifications.sql:12). Do not copy the precedent atpackages/supabase/scripts/10-func-notifications.sql:32-40. That is an earlyRETURN NEWwhich aborts the whole fan-out. Every REST write path has a null actor, so a literal copy would notify nobody. The reaction variant at:422-441skips the row instead. Neither shape fits. Writecase when exists(select 1 from public.users where id = p_actor_id) then p_actor_id endand pin it with a test.set_document_followis update-only. An upsert would firenotify_user_join_workspace(packages/supabase/scripts/10-8-func-workspace_members.sql:3), which posts a "joined" chat message. It must also leaveupdated_atuntouched.join_workspacewrites that column as the last-visit stamp (packages/supabase/scripts/10-functions.sql:867-869), and the roster renders it (packages/supabase/scripts/10-8-func-workspace_members.sql:238).SQL order is required.
packages/supabase/scripts/*.sql.bun run --filter @docs.plus/supabase_back reset. It regeneratesseed.sql. A fresh-volume seed must come up green.bun run --filter @docs.plus/supabase_back typesand ship the regeneratedapps/webapp/src/types/supabase.tsin the same change. Never hand-edit it.Ship two migrations. The enum value gets its own migration that consumes nothing:
alter type public.notification_category add value if not exists 'content_change';. Supabase wraps each migration in one transaction, and Postgres forbids using an enum value added in the same transaction. The second, later-timestamped migration holds the column, the functions and the grants.Acceptance
apps/hocuspocus.server/API.mdstates that a document with no signed-in open notifies nobody, owner included.select unnest(enum_range(null::public.notification_category));listscontent_change.\d public.workspace_membersshowscontent_email_muted_atas a nullabletimestamp with time zone.After
bun run --filter @docs.plus/supabase_back reset, anauthenticatedcaller runsset_document_followandget_document_follow_statewithout a42501.An
anoncaller and anauthenticatedcaller both get42501fromnotify_document_content_change.Fan-out matrix, proven in psql. An active member gets one row.
A member listed in
p_editor_idsgets none.A member with
content_email_muted_atset gets none.The
p_only_userbranch produces exactly one row, for that user.Two calls inside 24 hours produce one row in total.
The fan-out inserts a row with
sender_user_idNULL when the actor has nopublic.usersrow.A
content_changenotification withpush_content_changesunset produces no message on thepush_notificationsqueue.set_document_followleavesworkspace_members.updated_atunchanged and posts no chat message.Both migrations apply twice to the seeded database with no error.
apps/webapp/src/types/supabase.tsis regenerated in the same change.The decision above is recorded here before the RPC body is written.
Notes
The worker producer seam sits beside the existing fire-and-forget block in
apps/hocuspocus.server/src/lib/queue.ts:527-547. That block sits just above thedoc:<id>:savedpublish at:556. The privacy rules that decidep_only_userare specified in the sibling consumer issue.