Repository navigation
[Feature]: document change digest and follow notifications #169
Description
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. ownerId sits on the DocumentMetadata model that the collaboration worker reads. But the audience list is built on the Supabase side, from the workspace row. That row is written only inside join_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:
- Accept it, and write it down. A document that no signed-in person has opened notifies nobody.
- Let the fan-out create the missing row. That puts a write into a read path. It also creates rows for documents no person ever opened.
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.
- Add the document change digest compute service and its REST route #200 — Compute what changed, per document and per heading
- Add document content-change blocks to the digest email #201 — Render the change digest email
- Add the document changes end-to-end script, its CI step and the docs #202 — End-to-end verification, the CI step and the docs
- Add the content_change carrier, the follow column and the fan-out RPC #203 — Follow state, the carrier and the fan-out RPC
- Decide content-change privacy in the worker and skip empty digests #204 — Worker privacy decision and the digest consumer
- Add the Follow toggle and the document-change notification to the app #205 — Webapp follow control and the notification row
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
- A maintainer has accepted the user story in this issue.
Ruled 2026-09-01. - The audience question is answered here, with one option chosen.
Ruled: accept the gap and document it. - The follow toggle wording is approved here.
Ruled: "Notify me when this document changes." - All six work items listed under Scope are done.
- A private document with two past visitors sends to the owner only.
- No follower receives a digest that lists zero sections.
Notes
What already exists, and what this work reuses:
- The content API is
apps/hocuspocus.server/src/modules/document-content. - The version store is
apps/hocuspocus.server/src/modules/document-versions. Version rows carrytriggeredByandcontributors, so attribution already exists. - The reader-facing history view is
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_category enum, plus one new column on an existing table.
Build warning for whoever takes the fan-out: workspaces.slug and document_views.document_slug hold lower(documentId), not the human slug. Every lookup against those columns must pass lower(documentId). See the first bullet under §Supabase in packages/supabase/CLAUDE.md.
Activity
HMarzban commentedon Sep 1, 2026
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.HMarzban commentedon Sep 1, 2026
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.The three that matter most
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."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
ALG-1,D3-1,ALG-3,API-1,D5-1D5-4,D3-3,P5,EMAIL-1DB-1,EMAIL-2Each is filed as a comment with its fix and an acceptance checkbox on the issue that must apply it.
HMarzban commentedon Sep 5, 2026
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.
docs(agents): file inbox facts and record the Private chat fix