Repository navigation
[Feature]: document change digest and follow notifications #169
Description
Activity
Ruling — 2026-09-01
The user story is accepted. All three open questions are answered, so this epic leaves the held set and
#200to#205may start.1. The user story — accepted as written
Opening a document while signed in makes you a follower. The window starts at your last visit, falling back to the last day or week, and never reaching past the version retention window. The digest serves both a document summary and a heading tree; the email uses the heading tree. A private document reaches its owner and nobody else.
2. The audience question — accept the gap and document it
Chosen: accept it. The fan-out returns zero recipients when the document has no
workspacesrow, and that row is written only when a signed-in browser opens the document.So a document created only through the API notifies nobody, owner included, until a signed-in person opens it once.
The fan-out will not create the row. Writing into a read path was the alternative, and it would leave
workspaces.created_bynull forever, against its documented meaning of "the first signed-in visitor".This must be stated plainly in
API.md, not left for someone to discover.#203carries the documentation task.3. The follow toggle wording — approved
The control is labelled Follow, and its description reads "Notify me when this document changes."
Not "Email me". Turning follow off removes the in-app notification too, because the mute is applied in the fan-out recipient query, upstream of the
notificationsinsert.#205carries this.What is still true
#204needs both#200and#203.#203is pure SQL and can run beside#200.#201needs only#200's compute core, because the email worker imports it directly and makes no HTTP call.Architecture review — verdict, 2026-09-01
Two independent teams, ten specialists, adversarial verification on every ranked finding. 42 agents, zero errors. Full report:
REPORT-partDE-architecture-review.md.Part D: BUILD-WITH-CHANGES. Part E: BUILD-WITH-CHANGES. Proceed.
Block-level compare is the right substrate. Part E's privacy model — worker-side adjudication from live Prisma metadata, plus the send-time seal re-check — was attacked by ten specialists and nothing broke. Every surviving defect is local. None needs a table, a column, a metric, or a new subsystem.
Verification killed most of it
30 ranked findings went to adversarial verification. 19 died. 11 survived. Two lenses returned REDESIGN and both collapsed, because every finding underneath them was refuted. The refuted list is in the report so nobody re-raises it.
What measurement settled
The algorithms department wrote a working implementation of the four pure functions and decoded all 92 stored
Documentsrows, with zero decode failures.- Real corpus: median 5 sections, maximum 89. The design's reachable limit is 5,000 — 56 times larger than anything that exists.
- Whole pipeline on the largest real document: 1.8 ms.
- "There is no performance problem here, and I will not pretend there is one."
The three that matter most
- The positional title rule is wrong (Add the document change digest compute service and its REST route #200). §D-3 pairs the title positionally, overruling toc-id. Delete a title and one heading reports as both
modifiedandremoved, while the heading that actually went is never named. Wrong at three sections, not at scale. Proven by running it, and proven fixed by disabling the rule. changedis byte-derived and never recomputed (Add the document change digest compute service and its REST route #200). On the common legacy path the email body says nothing changed. That fails an acceptance line on this issue: "No follower receives a digest that lists zero sections."- A
NULLinsidep_editor_idsempties the entire recipient set, silently (Add the content_change carrier, the follow column and the fan-out RPC #203). Part E would notify nobody.
Where each finding lives
Issue Findings #200 ALG-1,D3-1,ALG-3,API-1,D5-1#201 D5-4,D3-3,P5,EMAIL-1#203 DB-1,EMAIL-2Each is filed as a comment with its fix and an acceptance checkbox on the issue that must apply it.
All six work items are merged and closed: #200, #201, #202, #203, #204, #205.
Evidence behind the last two acceptance boxes, stated at the level it actually holds.
A private document reaches the owner only.
resolveContentChangeAudienceis unit-tested inapps/hocuspocus.server/tests/unit/contentChangeDigest.test.ts. It answers{ kind: 'owner', onlyUser: <owner> }for a private document,nonefor a private document with no owner, andnonefor a trashed document even when public. The worker re-reads privacy before it sends, and defers the message rather than acking when that read fails.This proves the decision function and the consumer gate. It is not an end-to-end run with two real past visitors against the fan-out RPC, so the box is ticked on unit and integration evidence, not on a live delivery test.
No follower receives a digest listing zero sections.
digestContentChanges.tsdrops the content-change block whenoutcome.result.changedis false, which is the summary's own answer rather than a byte compare. A window that only spans the editor's first-opentoc-idstamping pass therefore sends no block.pgmqConsumer.tsmarks a genuinely empty digest and acks it, so pgmq cannot redeliver the same empty digest forever.Follow-up work has its own issue: #238 covers the three deferred refactors.
- added a commit that references this issue
on Oct 9, 2026
Problem
A person who works on a document cannot learn what changed while they were away. There is no digest and no follow state. The only record of change today is the version history, and a reader must open it and compare by hand.
What to decide
A maintainer must accept the user story below. Nothing starts until that ruling lands.
The user story
Who follows a document. Open a document while signed in, and you follow it. There is no separate subscribe button. You can unfollow a single document from that document's settings. You can also turn the whole category off in your notification settings, which stops every document at once.
What a digest covers. The window starts at your last visit to that document. When there is no visit on record, it falls back to the last day or the last week, matching the frequency you chose. The window never reaches further back than the version history the document still keeps.
Per heading or per document. Both. The API can return a one-line document summary, or a heading tree. The email uses the heading tree. It lists at most eight sections, then one "and N more" line.
A private document. A private document reaches its owner and nobody else. The collaboration worker decides this from live document metadata, not from the Supabase membership table. The check runs again at send time, in case the document turned private after the change.
The open audience question
The owner is always known.
ownerIdsits on theDocumentMetadatamodel that the collaboration worker reads. But the audience list is built on the Supabase side, from the workspace row. That row is written only insidejoin_workspace, which a signed-in browser calls when it opens the document (packages/supabase/scripts/10-functions.sql:858-859). So a document created only through the API has no row. The fan-out then returns zero recipients, owner included. Two options:Pick one. Both are defensible. The build cannot start without the answer.
The follow toggle wording
Turning follow off removes the in-app notification too, not only the email. A label that mentions email alone would be wrong. A maintainer must approve the final wording here.
Scope
Six child issues carry the work. They are filed and ready, but none should start before the ruling above.
Order. #200 first. Then #201 and #202 in either order — #201 needs only the compute core, because the email worker imports it directly and makes no HTTP call. #203 can run beside all of them, because its work is SQL. #204 needs both #200 and #203. #205 needs #203 for its two RPCs.
Three of these carry a blocking correction that must be applied before code is written. #200 holds two, #201 holds one, #203 holds one, and #205 holds one. Each is written out in the child issue itself.
Acceptance
Ruled 2026-09-01.Ruled: accept the gap and document it.Ruled: "Notify me when this document changes."Notes
What already exists, and what this work reuses:
apps/hocuspocus.server/src/modules/document-content.apps/hocuspocus.server/src/modules/document-versions. Version rows carrytriggeredByandcontributors, so attribution already exists.apps/webapp/src/components/pages/history/.Planned scope, which the maintainer rules on with the story: no new dependency, no new table, and no new scheduled job. The only planned schema change is one new value on the
notification_categoryenum, plus one new column on an existing table.Build warning for whoever takes the fan-out:
workspaces.sluganddocument_views.document_slugholdlower(documentId), not the human slug. Every lookup against those columns must passlower(documentId). See the first bullet under §Supabase inpackages/supabase/CLAUDE.md.