Notification System

How Xudanu delivers real-time events to clients: detector subscriptions, content watch, and CRDT collaborative relay.

1. Overview: Three Notification Channels 2. Detector Events: Status, Revision, Fill 3. The ChannelDetector Bridge 4. Content Watch: Cross-Document Transclusion Alerts 5. The Fingerprint Reverse Index 6. Per-Session Notification Drain 7. Subscription Lifecycle and Cleanup 8. CRDT Collaborative Relay 9. Wire Protocol: Event Frames 10. Enhancements and Fixes

1. Overview: Three Notification Channels

Xudanu implements three distinct notification mechanisms, each designed for a different use case. They share a common WebSocket transport but differ in delivery model, scope, and complexity.

Three Notification Channels Client (WebSocket) JSON or Binary codec DETECTOR EVENTS Status (grab/release) Revision (edit saved) Fill (region populated) Push via mpsc Per-work scope CONTENT WATCH ContentTranscluders ContentWorks Fingerprint-based matching Drain on request Cross-work scope CRDT RELAY Real-time co-editing Cursor / awareness state Yjs CRDT engine Pull via diff Same-doc scope SERVER DetectorList · RecorderSystem · CrdtManager · BackfollowEngine Detector = fire & forget via channel Content Watch = fingerprint index + drain per session CRDT Relay = poll-based client calls CrdtSyncDiff D1 — Three Notification Channels
ChannelDeliveryScopeTrigger
Detector EventsPush (mpsc channel)Per-work or per-editionMutation (grab, revise, fill)
Content WatchDrain on requestCross-work content matchFingerprint lookup on revise
CRDT RelayClient polls via CrdtSyncDiffSame-document subscribersAny subscriber edits

2. Detector Events: Status, Revision, Fill

Detector events are the original notification mechanism, modelled after the Udanax-Gold observer pattern. A client subscribes to a specific work or edition and receives push events whenever that target changes.

Udanax-Gold origin: The detector/observer pattern (Status, Revision, Fill) comes directly from the Gold codebase's Detector class hierarchy. Xudanu reimplements it in Rust with async channels replacing C++ virtual callbacks.

Event Types

Detector TypeEventsEvent CodeWhen Fired
Status WorkGrabbed, WorkReleased 0x01, 0x02 work_grab(), work_release(), disconnect()
Revision WorkRevised 0x03 revise_work() after edition update
Fill RangeFilled, ElementFilled 0x04, 0x05 Region or element populated

Subscription Flow

Detector Subscription and Event Delivery Client WebSocket WS Handler per-connection Server DetectorList Other Client triggers event 1. Subscribe 2. add_detector 3. work_grab() or work_revise() 4. status_detectors.fire(&Event) or revision_detectors.fire() 5. ChannelDetector.on_event() mpsc::UnboundedSender::send(EventMessage) 6. Writer Task (tokio::spawn) recv() from channel → encode_event() → ws.send() 7. Event delivered D2 — Detector Event Delivery: synchronous fire → async mpsc channel → writer task → WebSocket

The key architectural insight: the server core is synchronous (Mutex-protected). The ChannelDetector bridges sync to async by pushing into an mpsc channel. A dedicated writer task on the async side drains the channel and writes to the WebSocket.

Code: Subscribing to Status Changes

JavaScript: Subscribe and handle events
// Subscribe to work status changes (grab/release)
ws.send(JSON.stringify({
  v: 2, type: 'subscribe', id: nextId(),
  payload: { detector_type: 'status', target_id: workId }
}));
// Response contains subscription_id

// Handle incoming events
ws.onmessage = function(msg) {
  var data = JSON.parse(msg.data);
  if (data.type === 'event') {
    var event = data.event;
    if (event.type === 'work_grabbed') {
      console.log('Work grabbed by session', event.payload.session_id);
    } else if (event.type === 'work_released') {
      console.log('Work released');
    }
  }
};

// Unsubscribe when done
ws.send(JSON.stringify({ v: 2, type: 'unsubscribe', id: subId }));

3. The ChannelDetector Bridge

The ChannelDetector is the sync-to-async bridge that makes detector events work. It implements the Detector trait but instead of processing the event itself, it wraps it in an EventMessage and sends it through a Tokio unbounded channel.

ChannelDetector: Sync → Async Bridge SYNC SIDE (Mutex-protected) work_grab() / work_release() / revise_work() DetectorList::fire(&Event) ChannelDetector::on_event() ASYNC SIDE (Tokio runtime) mpsc::UnboundedSender::send() mpsc channel Writer Task: recv() ws.send(Message::Binary/Text) D3 — ChannelDetector bridges the Mutex-protected server core to the async WebSocket writer
Source: ChannelDetector is in src/server/transport/channel.rs. The Detector trait and DetectorList are in src/server/detector.rs. The writer task is spawned in src/server/transport/handler.rs:347.

4. Content Watch: Cross-Document Transclusion Alerts

Content Watch is the newest notification channel. When a user clicks Watch on a document, the server monitors for any other documents that share content with it — both past matches that already exist and future matches that appear as documents are edited.

Content Watch: Subscription and Triggering PHASE 1: SUBSCRIBE 1. Extract content elements 2. Create Fossil (RecorderQuery) 3. Plant in fossil_by_fingerprint 4. Search for past matches → immediate ContentMatch events sent to subscriber PHASE 2: FUTURE MATCH (any client edits) 5. Client B: revise_work() 6. trigger_planted_recorders(work_id) 7. check_recorders_by_content(fps) 8. Find matching fossils 9. record_result() + dedup 10. ContentNotification queued PHASE 3: DELIVERY 11. Client A sends any Request 12. drain_content_notifications_for(A's fossils) 13. ContentMatch event pushed to Client A via WebSocket D4 — Content Watch: subscribe → fingerprint index → trigger on edit → drain per session

Why Content Watch Exists

Traditional hypertext systems require users to actively search for shared content. Content Watch inverts this: the server proactively notifies you when your content appears elsewhere. This is the Xanadu vision of automatic attribution and connection between documents.

Code: Setting Up Content Watch

JavaScript: Watch button handler
var watchSubId = null;
var watchResults = [];

function startWatch(workId) {
  var id = nextId();
  ws.send(JSON.stringify({
    v: 2, type: 'subscribe', id: id,
    payload: {
      detector_type: 'content_transcluders',
      target_id: workId
    }
  }));
}

// Handle content_match events
if (msg.event && msg.event.type === 'content_match') {
  var p = msg.event.payload;
  watchResults.push({
    edition_be_id: p.edition_be_id,
    is_direct: p.is_direct
  });
  toast('Transclusion found in edition ' + p.edition_be_id.toString(16));
}

function stopWatch() {
  if (watchSubId) {
    ws.send(JSON.stringify({
      v: 2, type: 'unsubscribe', id: watchSubId
    }));
    watchSubId = null;
  }
}

5. The Fingerprint Reverse Index

The key data structure enabling Content Watch is fossil_by_fingerprint, a reverse index in the BackfollowEngine that maps content fingerprints to the fossil IDs watching for them.

fossil_by_fingerprint: O(1) Content Lookup Watched Document "hello" fp=0x3a2f "world" fp=0x7b1c "xanadu" fp=0xe5d8 fossil_by_fingerprint HashMap<[u8;32], HashSet<RecorderId>> BLAKE3 Fingerprint Watching Fossils 0x3a2f... {fossil_1, fossil_3} 0x7b1c... {fossil_1} 0xe5d8... {fossil_2, fossil_3} Trigger: any revise_work() check_recorders_by_content([0x3a2f, 0xab12]) → found fossil_1 via 0x3a2f O(1) per fingerprint D5 — Fingerprint Reverse Index: content fingerprint → set of watching fossil IDs

When a recorder is planted during subscription, each content element's BLAKE3 fingerprint is mapped to the fossil ID. When any edition is revised, the new fingerprints are looked up in this index to find affected fossils in O(1) per element — no tree walk needed.

6. Per-Session Notification Drain

A critical design decision: content notifications are queued globally on the server, but drained per-session. Each client only receives notifications for its own fossils.

Per-Session Drain: Each Client Gets Only Its Own Notifications Global Notification Queue for A for B for A for B for A for C Client A sends a Request drain_content_notifications_for(A's fossils) for A for A for A A receives 3 notifications. Others remain in queue. Client B sends a Request drain_content_notifications_for(B's fossils) for B for B B receives 2 notifications. Remaining in queue: for C — waiting for Client C's next request Before fix: drain_all() → A steals B's notifications Global std::mem::take After fix: drain_for(fossils) → partition Each client only consumes its own D6 — Per-Session Drain: notifications are partitioned, not globally consumed
Enhancement: The original drain_content_notifications() used std::mem::take to drain ALL notifications. In a multi-client scenario, the first client to send a request would consume everyone's notifications. The fix partitions the queue by fossil ownership.

7. Subscription Lifecycle and Cleanup

The WS handler maintains three maps to track subscriptions across all notification types. Proper cleanup on both explicit unsubscribe and WebSocket disconnect is essential to avoid resource leaks.

Handler State: Three Subscription Maps subscriptions HashMap<u16, (DetType, BeId)> Status / Revision / Fill detector subscriptions Keyed by subscription_id content_subscriptions HashMap<u16, RecorderId> ContentTranscluders ContentWorks sub_id → fossil_id fossil_to_sub HashMap<RecorderId, u16> Reverse lookup for notification delivery: fossil_id → sub_id Unsubscribe(sub_id) — Enhanced Cleanup 1. subscriptions.remove(sub_id) → remove_detector() 2. content_subscriptions.remove(sub_id) → fossil_to_sub.remove(fossil_id) 3. recorder_extinguish(fossil_id) → remove_planted_recorder() → clean fingerprint index WebSocket Disconnect — Complete Teardown Drain subscriptions → remove all detectors Drain content_subscriptions → extinguish all fossils disconnect(session_id) → release grabbed works Security audit log entry D7 — Subscription Maps and Cleanup: both explicit unsubscribe and disconnect are handled
Enhancement: The original Unsubscribe handler only cleaned up subscriptions. Content subscriptions in content_subscriptions and fossil_to_sub were never cleaned up on explicit unsubscribe. Fossils remained planted, consuming CPU on every revise_work(). The fix adds complete cleanup for both unsubscribe and disconnect.

8. CRDT Collaborative Relay

The CRDT system uses a fundamentally different notification model: relay. When one client edits a shared document, the server applies the change and returns a list of other subscribers who need to be notified. The receiving client then polls for the update.

How It Differs from Detectors

Detector Events

Channel: mpsc event channel (push)

Encoding: EventPayload enum (typed)

Scope: Any subscriber to a work

Delivery: Async writer task pushes immediately

CRDT Relay

Channel: Returned in response (pull)

Encoding: Raw Yjs update bytes

Scope: Same-document subscribers only

Delivery: Client calls CrdtSyncDiff to fetch

Relay Flow

CRDT Collaborative Relay Alice (Editor) CrdtSyncUpdate CrdtManager Yrs Doc + subscribers Bob (Editor) CrdtSyncDiff (poll) 1 2. Apply Yjs update to Doc relay_to = [Bob] 3. Response: relay_count: 1 Bob's state_vector is now stale Server has pending update for Bob 5. CrdtSyncDiff 6. Compute diff & return 7. update_bytes D8 — CRDT Relay: push to server, pull by other subscribers

CRDT Awareness

Beyond document content, the CRDT system also relays awareness state: cursor positions, text selections, and typing indicators. Each subscriber's awareness is stored in the WorkDoc and relayed to all other subscribers.

JavaScript: Collaborative editing session
// Open CRDT sync
ws.send(JSON.stringify({
  v:2, type:'request', id:nextId(), op:'crdt_sync_open',
  payload: { work_id: workId }
}));
// Response: { state_vector: [...], current_text: "hello world" }

// Send edits
ws.send(JSON.stringify({
  v:2, type:'request', id:nextId(), op:'crdt_sync_update',
  payload: { work_id: workId, update: Array.from(updateBytes) }
}));
// Response: { relay_count: 2 }

// Poll for changes (every 50-100ms during active editing)
ws.send(JSON.stringify({
  v:2, type:'request', id:nextId(), op:'crdt_sync_diff',
  payload: { work_id: workId, state_vector: Array.from(mySV) }
}));
// Response: { update: [...] }

// Send awareness (cursor)
ws.send(JSON.stringify({
  v:2, type:'request', id:nextId(), op:'crdt_awareness_update',
  payload: {
    work_id: workId,
    state: { session_id: sid, user_name: "Alice",
             cursor: { index: 5 }, selection: null, is_typing: true }
  }
}));

9. Wire Protocol: Event Frames

All notification types share a common wire format. Events are delivered as WebSocket messages with the Event message type (0x04).

Binary Frame Layout

Binary Event Frame 0x02 version 0x04 Event type sub_id 2 bytes BE postcard-encoded EventPayload variable length Event Codes 0x01WorkGrabbed 0x02WorkReleased 0x03WorkRevised 0x04RangeFilled 0x05ElementFilled 0x06Done 0x07ContentMatch (new)

JSON Format

JSON event frame (content_match)
{
  "v": 2,
  "type": "event",
  "id": 42,
  "event": {
    "type": "content_match",
    "payload": {
      "fossil_id": 7,
      "edition_be_id": "0000000000abc123",
      "is_direct": true
    }
  }
}

Detector Type Codes

DetectorTypeSubscribe CodeJSON String
Status0x01"status"
Revision0x02"revision"
Fill0x03"fill"
ContentTranscluders0x04"content_transcluders"
ContentWorks0x05"content_works"

10. Enhancements and Fixes

The notification system was significantly improved during development. Here is a summary of all enhancements over the original design:

1. Per-Session Notification Drain

Before (Bug)

drain_content_notifications() used std::mem::take to drain ALL notifications from the global queue.

Client A sends request → gets ALL notifications (including B's and C's). Clients B and C get nothing.

After (Fixed)

drain_content_notifications_for(fossil_set) partitions the queue. Matching notifications returned; non-matching left for their owners.

Each client only consumes its own notifications.

2. Complete Unsubscribe Cleanup

Before (Bug)

Unsubscribe only removed from subscriptions map. Content subscriptions in content_subscriptions and fossil_to_sub were never cleaned up.

Fossils stayed planted, consuming CPU on every revise_work().

After (Fixed)

Unsubscribe now checks both maps. Calls recorder_extinguish() to kill the fossil and remove_planted_recorder() to clean the fingerprint index.

Resources fully released on explicit unsubscribe.

3. Fingerprint-Based Triggering

Before (Slow)

Used canopy walking to find affected recorders — traversing the edition hierarchy for every edit.

After (Fast)

check_recorders_by_content() uses the fossil_by_fingerprint reverse index for O(1) lookup per content element.

4. Content-Aware Past Match Search

Before (Incomplete)

Past matches only searched by edition ID reference (RangeElement::edition(eid)), missing transcluders that share content but not the same edition reference.

After (Complete)

process_agenda_with_engine() iterates over each watched_content element independently, finding all transcluders based on shared content.

Known Limitations

Content notifications are piggyback-only. Notifications are only drained when a client sends a regular Request. An idle client that never sends requests will not receive notifications. This is tracked in TODO.md.
CRDT updates are pull-based. There is no server-push for CRDT changes. Clients must poll via CrdtSyncDiff (typically every 50-100ms during active editing).
No notification persistence. If a client disconnects, pending content notifications are lost. There is no replay mechanism on reconnect.

Key Source Files

FilePurpose
src/server/detector.rsEvent enum, Detector trait, DetectorList
src/server/transport/channel.rsChannelDetector (sync → async bridge)
src/server/transport/handler.rsWS handler: subscribe/unsubscribe/drain
src/server/transport/protocol.rsDetectorType, EventPayload, EventCode
src/server/server.rsServer: fire events, trigger recorders, drain notifications
src/edition/recorder.rsRecorderSystem, Fossil, RecorderQuery
src/edition/backfollow.rsBackfollowEngine, fingerprint index, plant/check recorders
src/server/crdt_manager.rsCRDT: relay, awareness, materialization