Skip to content

[Feature]: document change digest and follow notifications #169

Description

@HMarzban

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.

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 carry triggeredBy and contributors, 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

  1. HMarzban commented on Sep 1, 2026

    @HMarzban
    CollaboratorAuthor

    Ruling — 2026-09-01

    The user story is accepted. All three open questions are answered, so this epic leaves the held set and #200 to #205 may 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 workspaces row, 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_by null 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. #203 carries 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 notifications insert. #205 carries this.

    What is still true

    #204 needs both #200 and #203. #203 is pure SQL and can run beside #200. #201 needs only #200's compute core, because the email worker imports it directly and makes no HTTP call.

  2. HMarzban commented on Sep 1, 2026

    @HMarzban
    CollaboratorAuthor

    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 Documents rows, 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

    1. 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 modified and removed, 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.
    2. changed is 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."
    3. A NULL inside p_editor_ids empties 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-2

    Each is filed as a comment with its fix and an acceptance checkbox on the issue that must apply it.

  3. HMarzban commented on Sep 5, 2026

    @HMarzban
    CollaboratorAuthor

    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. resolveContentChangeAudience is unit-tested in apps/hocuspocus.server/tests/unit/contentChangeDigest.test.ts. It answers { kind: 'owner', onlyUser: <owner> } for a private document, none for a private document with no owner, and none for 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.ts drops the content-change block when outcome.result.changed is false, which is the summary's own answer rather than a byte compare. A window that only spans the editor's first-open toc-id stamping pass therefore sends no block. pgmqConsumer.ts marks 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions