diff --git a/collab-presence-heartbeat-liveness-guard/README.md b/collab-presence-heartbeat-liveness-guard/README.md new file mode 100644 index 00000000..cb222a26 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/README.md @@ -0,0 +1,35 @@ +# Collaborative Presence Heartbeat Liveness Guard + +This module adds a focused Real-Time Collaborative Editor slice for issue #12: heartbeat-based cleanup before live cursor and user-presence fanout. + +It evaluates synthetic collaboration rooms for: + +- stale heartbeats +- ghost sessions that still show cursors or editing state +- reconnect-grace sessions that should keep restore state but not broadcast cursors +- duplicate collaborator tabs +- orphaned cursors without a session record +- expired typing indicators +- expired presence fanout leases +- future heartbeat clock skew + +The output is a sanitized presence snapshot plus cleanup, recovery, and fanout queues. The module is dependency-free and uses synthetic data only. It does not call live editor transports, identity services, notebook kernels, storage providers, notification systems, or external APIs. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +`npm run demo` writes reviewer artifacts under `reports/`: + +- `presence-liveness-packet.json` +- `presence-liveness-report.md` +- `summary.svg` +- `demo.mp4` + +## Scope + +This is intentionally separate from presence privacy, notification visibility, notebook kernel leases, lock/checkpoint recovery, autosave recovery, broad editor foundations, and release/export guards. It handles the liveness decision immediately before a presence room publishes cursor and activity snapshots. diff --git a/collab-presence-heartbeat-liveness-guard/acceptance-notes.md b/collab-presence-heartbeat-liveness-guard/acceptance-notes.md new file mode 100644 index 00000000..344ff068 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/acceptance-notes.md @@ -0,0 +1,37 @@ +# Acceptance Notes + +The reviewer can verify this slice without accounts, services, or private data. + +## Expected Synthetic Result + +The demo fixture produces: + +- 2 rooms reviewed +- 8 sessions reviewed +- 3 sessions published +- 5 sessions suppressed +- 7 cleanup actions +- 1 reconnect recovery action +- 3 presence fanout events +- 1 room held for broadcast cleanup +- 1 room sanitized and ready + +The critical case is `sess-bio-ghost`: its heartbeat is stale while an editing cursor and typing state are still visible. The guard suppresses it and emits cleanup actions before the room fanout can show a ghost collaborator. + +## Validation + +```bash +npm run check +npm test +npm run demo +``` + +Optional video metadata check: + +```bash +ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,duration -show_entries format=size,duration -of default=noprint_wrappers=1 reports/demo.mp4 +``` + +## Boundaries + +This module does not implement the editor UI, network transport, CRDT/OT operations, section locking, notebook execution, privacy redaction, notification fanout, or release export. It is a deterministic pre-broadcast liveness guard for cursor and presence snapshots. diff --git a/collab-presence-heartbeat-liveness-guard/demo.js b/collab-presence-heartbeat-liveness-guard/demo.js new file mode 100644 index 00000000..8671ba02 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/demo.js @@ -0,0 +1,91 @@ +const fs = require("node:fs") +const path = require("node:path") +const { spawnSync } = require("node:child_process") +const { evaluatePresenceLiveness } = require("./index") +const { rooms, policy } = require("./sample-data") + +const reportsDir = path.join(__dirname, "reports") +fs.mkdirSync(reportsDir, { recursive: true }) + +const packet = evaluatePresenceLiveness({ + asOf: "2026-05-23T01:20:00.000Z", + rooms, + policy, +}) + +fs.writeFileSync( + path.join(reportsDir, "presence-liveness-packet.json"), + `${JSON.stringify(packet, null, 2)}\n`, +) + +const markdown = [ + "# Collaborative Presence Heartbeat Liveness Report", + "", + `Rooms reviewed: ${packet.summary.totalRooms}`, + `Sessions reviewed: ${packet.summary.totalSessions}`, + `Published sessions: ${packet.summary.publishedSessions}`, + `Suppressed sessions: ${packet.summary.suppressedSessions}`, + `Cleanup actions: ${packet.summary.cleanupActions}`, + `Recovery actions: ${packet.summary.recoveryActions}`, + `Fanout events: ${packet.summary.fanoutEvents}`, + `Audit digest: \`${packet.audit.digest}\``, + "", + "## Room Decisions", + ...packet.rooms.flatMap((room) => [ + "", + `### ${room.title}`, + `- Status: ${room.status}`, + `- Reviewed sessions: ${room.reviewedSessions}`, + `- Published sessions: ${room.publishedSessions}`, + `- Suppressed sessions: ${room.suppressedSessions}`, + `- Cleanup codes: ${room.cleanupQueue.map((item) => item.code).join(", ") || "none"}`, + `- Recovery codes: ${room.recoveryQueue.map((item) => item.code).join(", ") || "none"}`, + ]), + "", + "## Cleanup Queue", + ...packet.cleanupQueue.map((item) => ( + `- ${item.roomId}/${item.sessionId}: ${item.code} (${item.severity})` + )), + "", +] + +fs.writeFileSync(path.join(reportsDir, "presence-liveness-report.md"), markdown.join("\n")) + +const svg = ` + + Collaborative Presence Heartbeat Liveness Guard + Ghost session cleanup before live cursor and presence fanout + + ${packet.summary.publishedSessions} + published + + ${packet.summary.recoveryActions} + recoverable + + ${packet.summary.suppressedSessions} + suppressed + Controls: stale heartbeat, ghost session, duplicate tab, orphan cursor, expired typing, expired fanout lease. + Digest ${packet.audit.digest.slice(0, 28)}... + +` + +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg) + +const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x101827:s=960x540:d=5:r=15", + "-vf", + "drawbox=x=48:y=170:w=250:h=150:color=0x047857@1:t=fill,drawbox=x=355:y=170:w=250:h=150:color=0xb45309@1:t=fill,drawbox=x=662:y=170:w=250:h=150:color=0xbe123c@1:t=fill,drawbox=x=48:y=368:w=864:h=18:color=0x22d3ee@1:t=fill", + "-pix_fmt", + "yuv420p", + path.join(reportsDir, "demo.mp4"), +], { stdio: "ignore" }) + +if (ffmpeg.status !== 0) { + console.warn("ffmpeg video generation failed; JSON, Markdown, and SVG reports were still generated.") +} + +console.log(`Wrote collaborative presence liveness artifacts to ${reportsDir}`) diff --git a/collab-presence-heartbeat-liveness-guard/index.js b/collab-presence-heartbeat-liveness-guard/index.js new file mode 100644 index 00000000..cbb9bb59 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/index.js @@ -0,0 +1,343 @@ +const crypto = require("node:crypto") + +const DEFAULT_POLICY = Object.freeze({ + heartbeatStaleSeconds: 45, + ghostSessionSeconds: 120, + reconnectGraceSeconds: 90, + typingIndicatorSeconds: 20, + maxFutureClockSkewSeconds: 10, + duplicateTabWindowSeconds: 15, +}) + +function evaluatePresenceLiveness(input) { + const policy = { ...DEFAULT_POLICY, ...(input.policy || {}) } + const asOf = input.asOf || new Date().toISOString() + const now = Date.parse(asOf) + const rooms = Array.isArray(input.rooms) ? input.rooms : [] + const roomDecisions = rooms.map((room) => evaluateRoom(room, now, policy)) + const cleanupQueue = roomDecisions.flatMap((room) => room.cleanupQueue) + const recoveryQueue = roomDecisions.flatMap((room) => room.recoveryQueue) + const fanoutQueue = roomDecisions.flatMap((room) => room.fanoutQueue) + const summary = summarize(roomDecisions, cleanupQueue, recoveryQueue, fanoutQueue) + const auditPayload = { + asOf, + policy, + summary, + rooms: roomDecisions.map((room) => ({ + roomId: room.roomId, + status: room.status, + publishedSessions: room.sanitizedPresence.sessions.map((session) => session.sessionId), + cleanupCodes: room.cleanupQueue.map((item) => item.code), + recoveryCodes: room.recoveryQueue.map((item) => item.code), + })), + } + + return { + asOf, + summary, + rooms: roomDecisions, + cleanupQueue, + recoveryQueue, + fanoutQueue, + audit: { + algorithm: "sha256", + digest: digest(auditPayload), + policyVersion: policy.version || "presence-heartbeat-liveness-v1", + }, + } +} + +function evaluateRoom(room, now, policy) { + const sessions = Array.isArray(room.sessions) ? room.sessions : [] + const cursors = new Map((room.cursors || []).map((cursor) => [cursor.sessionId, cursor])) + const leases = new Map((room.presenceLeases || []).map((lease) => [lease.sessionId, lease])) + const duplicateWinners = selectDuplicateWinners(sessions, now) + const sessionDecisions = sessions.map((session) => { + return evaluateSession({ + room, + session, + cursor: cursors.get(session.sessionId), + lease: leases.get(session.sessionId), + isDuplicateLoser: duplicateWinners.get(session.collaboratorId) !== session.sessionId, + now, + policy, + }) + }) + + const knownSessionIds = new Set(sessions.map((session) => session.sessionId)) + const orphanCursorActions = (room.cursors || []) + .filter((cursor) => !knownSessionIds.has(cursor.sessionId)) + .map((cursor) => cleanupAction({ + roomId: room.roomId, + sessionId: cursor.sessionId, + collaboratorId: "unknown", + code: "orphan_cursor", + severity: "warning", + message: "Cursor has no matching live session record.", + remediation: "Drop cursor from the next presence snapshot.", + })) + + const cleanupQueue = [ + ...sessionDecisions.flatMap((decision) => decision.cleanupActions), + ...orphanCursorActions, + ] + const recoveryQueue = sessionDecisions.flatMap((decision) => decision.recoveryActions) + const fanoutQueue = sessionDecisions + .filter((decision) => decision.broadcast) + .map((decision) => ({ + roomId: room.roomId, + sessionId: decision.sessionId, + collaboratorId: decision.collaboratorId, + channel: "presence_snapshot", + cursor: decision.publicCursor, + typing: decision.publicTyping, + })) + + const sanitizedPresence = { + roomId: room.roomId, + documentId: room.documentId, + sessions: sessionDecisions + .filter((decision) => decision.broadcast) + .map((decision) => ({ + sessionId: decision.sessionId, + collaboratorId: decision.collaboratorId, + displayName: decision.displayName, + status: decision.publicStatus, + cursor: decision.publicCursor, + typing: decision.publicTyping, + })), + } + + const critical = cleanupQueue.some((item) => item.severity === "critical") + const warning = cleanupQueue.length > 0 || recoveryQueue.length > 0 + const status = critical ? "hold_broadcast_cleanup" : warning ? "sanitized_ready" : "ready" + + return { + roomId: room.roomId, + documentId: room.documentId, + title: room.title, + status, + reviewedSessions: sessions.length, + publishedSessions: sanitizedPresence.sessions.length, + suppressedSessions: sessionDecisions.filter((decision) => !decision.broadcast).length, + sanitizedPresence, + sessionDecisions, + cleanupQueue, + recoveryQueue, + fanoutQueue, + } +} + +function evaluateSession({ room, session, cursor, lease, isDuplicateLoser, now, policy }) { + const heartbeatAge = secondsBetween(now, session.lastHeartbeatAt) + const disconnectedAge = session.disconnectedAt ? secondsBetween(now, session.disconnectedAt) : null + const typingAge = session.typingSince ? secondsBetween(now, session.typingSince) : null + const leaseAge = lease ? secondsBetween(now, lease.expiresAt) : null + const cleanupActions = [] + const recoveryActions = [] + const futureHeartbeat = heartbeatAge < -policy.maxFutureClockSkewSeconds + const staleHeartbeat = heartbeatAge > policy.heartbeatStaleSeconds + const ghostSession = heartbeatAge > policy.ghostSessionSeconds && Boolean(cursor || session.status === "editing") + const reconnecting = session.status === "disconnected" && disconnectedAge !== null && disconnectedAge <= policy.reconnectGraceSeconds + const expiredTyping = typingAge !== null && typingAge > policy.typingIndicatorSeconds + const expiredLease = lease && leaseAge > 0 + + let broadcast = true + let publicStatus = session.status || "active" + let publicCursor = cursor ? sanitizeCursor(cursor) : null + let publicTyping = Boolean(session.typingSince) + + if (futureHeartbeat) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "future_heartbeat_clock_skew", + severity: "warning", + message: "Heartbeat timestamp is ahead of the room clock.", + remediation: "Hold the timestamp for clock-skew review before fanout.", + })) + publicStatus = "clock_skew_review" + publicCursor = null + publicTyping = false + broadcast = false + } + + if (isDuplicateLoser) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "duplicate_tab_shadowed", + severity: "warning", + message: "A newer tab for the same collaborator is active.", + remediation: "Keep only the newest tab in the next presence fanout.", + })) + broadcast = false + } + + if (ghostSession) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "ghost_session", + severity: "critical", + message: `Heartbeat is stale by ${heartbeatAge}s while cursor or editing state is still visible.`, + remediation: "Suppress cursor and remove the session from live fanout until a fresh heartbeat arrives.", + })) + broadcast = false + } else if (staleHeartbeat && !reconnecting) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "stale_heartbeat", + severity: "warning", + message: `Heartbeat age ${heartbeatAge}s exceeds the ${policy.heartbeatStaleSeconds}s policy.`, + remediation: "Hide cursor and ask the client to refresh presence.", + })) + broadcast = false + } + + if (reconnecting) { + recoveryActions.push({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "reconnect_grace", + action: "hold_cursor_restore_marker", + expiresInSeconds: Math.max(0, policy.reconnectGraceSeconds - disconnectedAge), + }) + publicStatus = "reconnecting" + publicCursor = null + publicTyping = false + broadcast = false + } + + if (expiredTyping) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "expired_typing_indicator", + severity: "warning", + message: `Typing indicator age ${typingAge}s exceeds the ${policy.typingIndicatorSeconds}s policy.`, + remediation: "Drop typing state while preserving the session if heartbeat is fresh.", + })) + publicTyping = false + } + + if (expiredLease) { + cleanupActions.push(cleanupAction({ + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + code: "expired_presence_fanout_lease", + severity: "warning", + message: "Presence fanout lease expired before the next broadcast window.", + remediation: "Renew lease before including this session in a fanout batch.", + })) + broadcast = false + } + + return { + roomId: room.roomId, + sessionId: session.sessionId, + collaboratorId: session.collaboratorId, + displayName: session.displayName, + heartbeatAge, + broadcast, + publicStatus, + publicCursor, + publicTyping, + cleanupActions, + recoveryActions, + } +} + +function selectDuplicateWinners(sessions, now) { + const byCollaborator = new Map() + for (const session of sessions) { + const previous = byCollaborator.get(session.collaboratorId) + if (!previous || Date.parse(session.lastHeartbeatAt) > Date.parse(previous.lastHeartbeatAt)) { + byCollaborator.set(session.collaboratorId, session) + } + } + + const winners = new Map() + for (const [collaboratorId, session] of byCollaborator.entries()) { + const sameCollaborator = sessions.filter((candidate) => candidate.collaboratorId === collaboratorId) + if (sameCollaborator.length === 1) { + winners.set(collaboratorId, session.sessionId) + continue + } + + const winnerAge = secondsBetween(now, session.lastHeartbeatAt) + if (winnerAge <= DEFAULT_POLICY.duplicateTabWindowSeconds || session.status === "active") { + winners.set(collaboratorId, session.sessionId) + } else { + winners.set(collaboratorId, session.sessionId) + } + } + + return winners +} + +function sanitizeCursor(cursor) { + return { + sectionId: cursor.sectionId, + blockId: cursor.blockId, + offset: Number.isFinite(Number(cursor.offset)) ? Number(cursor.offset) : 0, + } +} + +function cleanupAction({ roomId, sessionId, collaboratorId, code, severity, message, remediation }) { + return { roomId, sessionId, collaboratorId, code, severity, message, remediation } +} + +function summarize(rooms, cleanupQueue, recoveryQueue, fanoutQueue) { + return { + totalRooms: rooms.length, + totalSessions: rooms.reduce((sum, room) => sum + room.reviewedSessions, 0), + publishedSessions: rooms.reduce((sum, room) => sum + room.publishedSessions, 0), + suppressedSessions: rooms.reduce((sum, room) => sum + room.suppressedSessions, 0), + cleanupActions: cleanupQueue.length, + recoveryActions: recoveryQueue.length, + fanoutEvents: fanoutQueue.length, + holdRooms: rooms.filter((room) => room.status === "hold_broadcast_cleanup").length, + sanitizedRooms: rooms.filter((room) => room.status === "sanitized_ready").length, + readyRooms: rooms.filter((room) => room.status === "ready").length, + } +} + +function secondsBetween(nowMillis, timestamp) { + const parsed = Date.parse(timestamp) + if (!Number.isFinite(parsed)) { + return Number.POSITIVE_INFINITY + } + + return Math.round((nowMillis - parsed) / 1000) +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]` + } + + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}` + } + + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex") +} + +module.exports = { + DEFAULT_POLICY, + evaluatePresenceLiveness, + stableStringify, +} diff --git a/collab-presence-heartbeat-liveness-guard/package.json b/collab-presence-heartbeat-liveness-guard/package.json new file mode 100644 index 00000000..cc761d3c --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "collab-presence-heartbeat-liveness-guard", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + } +} diff --git a/collab-presence-heartbeat-liveness-guard/reports/demo.mp4 b/collab-presence-heartbeat-liveness-guard/reports/demo.mp4 new file mode 100644 index 00000000..a7c27f31 Binary files /dev/null and b/collab-presence-heartbeat-liveness-guard/reports/demo.mp4 differ diff --git a/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-packet.json b/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-packet.json new file mode 100644 index 00000000..2dd7961d --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-packet.json @@ -0,0 +1,518 @@ +{ + "asOf": "2026-05-23T01:20:00.000Z", + "summary": { + "totalRooms": 2, + "totalSessions": 8, + "publishedSessions": 3, + "suppressedSessions": 5, + "cleanupActions": 7, + "recoveryActions": 1, + "fanoutEvents": 3, + "holdRooms": 1, + "sanitizedRooms": 1, + "readyRooms": 0 + }, + "rooms": [ + { + "roomId": "manuscript-alpha", + "documentId": "doc-alpha", + "title": "Collaborative manuscript draft", + "status": "hold_broadcast_cleanup", + "reviewedSessions": 5, + "publishedSessions": 2, + "suppressedSessions": 3, + "sanitizedPresence": { + "roomId": "manuscript-alpha", + "documentId": "doc-alpha", + "sessions": [ + { + "sessionId": "sess-ada-active", + "collaboratorId": "ada", + "displayName": "Ada", + "status": "active", + "cursor": { + "sectionId": "intro", + "blockId": "intro-2", + "offset": 81 + }, + "typing": true + }, + { + "sessionId": "sess-chen-new-tab", + "collaboratorId": "chen", + "displayName": "Chen", + "status": "active", + "cursor": { + "sectionId": "results", + "blockId": "result-2", + "offset": 33 + }, + "typing": false + } + ] + }, + "sessionDecisions": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-ada-active", + "collaboratorId": "ada", + "displayName": "Ada", + "heartbeatAge": 15, + "broadcast": true, + "publicStatus": "active", + "publicCursor": { + "sectionId": "intro", + "blockId": "intro-2", + "offset": 81 + }, + "publicTyping": true, + "cleanupActions": [], + "recoveryActions": [] + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "displayName": "Bianca", + "heartbeatAge": 165, + "broadcast": false, + "publicStatus": "editing", + "publicCursor": { + "sectionId": "methods", + "blockId": "methods-4", + "offset": 12 + }, + "publicTyping": false, + "cleanupActions": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "ghost_session", + "severity": "critical", + "message": "Heartbeat is stale by 165s while cursor or editing state is still visible.", + "remediation": "Suppress cursor and remove the session from live fanout until a fresh heartbeat arrives." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 150s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + } + ], + "recoveryActions": [] + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-old-tab", + "collaboratorId": "chen", + "displayName": "Chen", + "heartbeatAge": 35, + "broadcast": false, + "publicStatus": "active", + "publicCursor": { + "sectionId": "results", + "blockId": "result-1", + "offset": 3 + }, + "publicTyping": false, + "cleanupActions": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-old-tab", + "collaboratorId": "chen", + "code": "duplicate_tab_shadowed", + "severity": "warning", + "message": "A newer tab for the same collaborator is active.", + "remediation": "Keep only the newest tab in the next presence fanout." + } + ], + "recoveryActions": [] + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-new-tab", + "collaboratorId": "chen", + "displayName": "Chen", + "heartbeatAge": 5, + "broadcast": true, + "publicStatus": "active", + "publicCursor": { + "sectionId": "results", + "blockId": "result-2", + "offset": 33 + }, + "publicTyping": false, + "cleanupActions": [], + "recoveryActions": [] + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-dina-reconnect", + "collaboratorId": "dina", + "displayName": "Dina", + "heartbeatAge": 58, + "broadcast": false, + "publicStatus": "reconnecting", + "publicCursor": null, + "publicTyping": false, + "cleanupActions": [], + "recoveryActions": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-dina-reconnect", + "collaboratorId": "dina", + "code": "reconnect_grace", + "action": "hold_cursor_restore_marker", + "expiresInSeconds": 50 + } + ] + } + ], + "cleanupQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "ghost_session", + "severity": "critical", + "message": "Heartbeat is stale by 165s while cursor or editing state is still visible.", + "remediation": "Suppress cursor and remove the session from live fanout until a fresh heartbeat arrives." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 150s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-old-tab", + "collaboratorId": "chen", + "code": "duplicate_tab_shadowed", + "severity": "warning", + "message": "A newer tab for the same collaborator is active.", + "remediation": "Keep only the newest tab in the next presence fanout." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "missing-session", + "collaboratorId": "unknown", + "code": "orphan_cursor", + "severity": "warning", + "message": "Cursor has no matching live session record.", + "remediation": "Drop cursor from the next presence snapshot." + } + ], + "recoveryQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-dina-reconnect", + "collaboratorId": "dina", + "code": "reconnect_grace", + "action": "hold_cursor_restore_marker", + "expiresInSeconds": 50 + } + ], + "fanoutQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-ada-active", + "collaboratorId": "ada", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "intro", + "blockId": "intro-2", + "offset": 81 + }, + "typing": true + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-new-tab", + "collaboratorId": "chen", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "results", + "blockId": "result-2", + "offset": 33 + }, + "typing": false + } + ] + }, + { + "roomId": "notebook-review", + "documentId": "doc-notebook", + "title": "Notebook review room", + "status": "sanitized_ready", + "reviewedSessions": 3, + "publishedSessions": 1, + "suppressedSessions": 2, + "sanitizedPresence": { + "roomId": "notebook-review", + "documentId": "doc-notebook", + "sessions": [ + { + "sessionId": "sess-eli-clean", + "collaboratorId": "eli", + "displayName": "Eli", + "status": "active", + "cursor": { + "sectionId": "analysis", + "blockId": "cell-2", + "offset": 18 + }, + "typing": false + } + ] + }, + "sessionDecisions": [ + { + "roomId": "notebook-review", + "sessionId": "sess-eli-clean", + "collaboratorId": "eli", + "displayName": "Eli", + "heartbeatAge": 12, + "broadcast": true, + "publicStatus": "active", + "publicCursor": { + "sectionId": "analysis", + "blockId": "cell-2", + "offset": 18 + }, + "publicTyping": false, + "cleanupActions": [], + "recoveryActions": [] + }, + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "displayName": "Farid", + "heartbeatAge": 10, + "broadcast": false, + "publicStatus": "active", + "publicCursor": { + "sectionId": "analysis", + "blockId": "cell-5", + "offset": 44 + }, + "publicTyping": false, + "cleanupActions": [ + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 65s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_presence_fanout_lease", + "severity": "warning", + "message": "Presence fanout lease expired before the next broadcast window.", + "remediation": "Renew lease before including this session in a fanout batch." + } + ], + "recoveryActions": [] + }, + { + "roomId": "notebook-review", + "sessionId": "sess-gita-skew", + "collaboratorId": "gita", + "displayName": "Gita", + "heartbeatAge": -22, + "broadcast": false, + "publicStatus": "clock_skew_review", + "publicCursor": null, + "publicTyping": false, + "cleanupActions": [ + { + "roomId": "notebook-review", + "sessionId": "sess-gita-skew", + "collaboratorId": "gita", + "code": "future_heartbeat_clock_skew", + "severity": "warning", + "message": "Heartbeat timestamp is ahead of the room clock.", + "remediation": "Hold the timestamp for clock-skew review before fanout." + } + ], + "recoveryActions": [] + } + ], + "cleanupQueue": [ + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 65s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_presence_fanout_lease", + "severity": "warning", + "message": "Presence fanout lease expired before the next broadcast window.", + "remediation": "Renew lease before including this session in a fanout batch." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-gita-skew", + "collaboratorId": "gita", + "code": "future_heartbeat_clock_skew", + "severity": "warning", + "message": "Heartbeat timestamp is ahead of the room clock.", + "remediation": "Hold the timestamp for clock-skew review before fanout." + } + ], + "recoveryQueue": [], + "fanoutQueue": [ + { + "roomId": "notebook-review", + "sessionId": "sess-eli-clean", + "collaboratorId": "eli", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "analysis", + "blockId": "cell-2", + "offset": 18 + }, + "typing": false + } + ] + } + ], + "cleanupQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "ghost_session", + "severity": "critical", + "message": "Heartbeat is stale by 165s while cursor or editing state is still visible.", + "remediation": "Suppress cursor and remove the session from live fanout until a fresh heartbeat arrives." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-bio-ghost", + "collaboratorId": "bianca", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 150s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-old-tab", + "collaboratorId": "chen", + "code": "duplicate_tab_shadowed", + "severity": "warning", + "message": "A newer tab for the same collaborator is active.", + "remediation": "Keep only the newest tab in the next presence fanout." + }, + { + "roomId": "manuscript-alpha", + "sessionId": "missing-session", + "collaboratorId": "unknown", + "code": "orphan_cursor", + "severity": "warning", + "message": "Cursor has no matching live session record.", + "remediation": "Drop cursor from the next presence snapshot." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_typing_indicator", + "severity": "warning", + "message": "Typing indicator age 65s exceeds the 20s policy.", + "remediation": "Drop typing state while preserving the session if heartbeat is fresh." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-farid-expired-lease", + "collaboratorId": "farid", + "code": "expired_presence_fanout_lease", + "severity": "warning", + "message": "Presence fanout lease expired before the next broadcast window.", + "remediation": "Renew lease before including this session in a fanout batch." + }, + { + "roomId": "notebook-review", + "sessionId": "sess-gita-skew", + "collaboratorId": "gita", + "code": "future_heartbeat_clock_skew", + "severity": "warning", + "message": "Heartbeat timestamp is ahead of the room clock.", + "remediation": "Hold the timestamp for clock-skew review before fanout." + } + ], + "recoveryQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-dina-reconnect", + "collaboratorId": "dina", + "code": "reconnect_grace", + "action": "hold_cursor_restore_marker", + "expiresInSeconds": 50 + } + ], + "fanoutQueue": [ + { + "roomId": "manuscript-alpha", + "sessionId": "sess-ada-active", + "collaboratorId": "ada", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "intro", + "blockId": "intro-2", + "offset": 81 + }, + "typing": true + }, + { + "roomId": "manuscript-alpha", + "sessionId": "sess-chen-new-tab", + "collaboratorId": "chen", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "results", + "blockId": "result-2", + "offset": 33 + }, + "typing": false + }, + { + "roomId": "notebook-review", + "sessionId": "sess-eli-clean", + "collaboratorId": "eli", + "channel": "presence_snapshot", + "cursor": { + "sectionId": "analysis", + "blockId": "cell-2", + "offset": 18 + }, + "typing": false + } + ], + "audit": { + "algorithm": "sha256", + "digest": "4a2254121a88799219fbeb58149dc0cc31087ce21619de6817fa5963bf7b55ba", + "policyVersion": "presence-heartbeat-liveness-v1" + } +} diff --git a/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-report.md b/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-report.md new file mode 100644 index 00000000..0bd6143c --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/reports/presence-liveness-report.md @@ -0,0 +1,37 @@ +# Collaborative Presence Heartbeat Liveness Report + +Rooms reviewed: 2 +Sessions reviewed: 8 +Published sessions: 3 +Suppressed sessions: 5 +Cleanup actions: 7 +Recovery actions: 1 +Fanout events: 3 +Audit digest: `4a2254121a88799219fbeb58149dc0cc31087ce21619de6817fa5963bf7b55ba` + +## Room Decisions + +### Collaborative manuscript draft +- Status: hold_broadcast_cleanup +- Reviewed sessions: 5 +- Published sessions: 2 +- Suppressed sessions: 3 +- Cleanup codes: ghost_session, expired_typing_indicator, duplicate_tab_shadowed, orphan_cursor +- Recovery codes: reconnect_grace + +### Notebook review room +- Status: sanitized_ready +- Reviewed sessions: 3 +- Published sessions: 1 +- Suppressed sessions: 2 +- Cleanup codes: expired_typing_indicator, expired_presence_fanout_lease, future_heartbeat_clock_skew +- Recovery codes: none + +## Cleanup Queue +- manuscript-alpha/sess-bio-ghost: ghost_session (critical) +- manuscript-alpha/sess-bio-ghost: expired_typing_indicator (warning) +- manuscript-alpha/sess-chen-old-tab: duplicate_tab_shadowed (warning) +- manuscript-alpha/missing-session: orphan_cursor (warning) +- notebook-review/sess-farid-expired-lease: expired_typing_indicator (warning) +- notebook-review/sess-farid-expired-lease: expired_presence_fanout_lease (warning) +- notebook-review/sess-gita-skew: future_heartbeat_clock_skew (warning) diff --git a/collab-presence-heartbeat-liveness-guard/reports/summary.svg b/collab-presence-heartbeat-liveness-guard/reports/summary.svg new file mode 100644 index 00000000..0c0ce961 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/reports/summary.svg @@ -0,0 +1,16 @@ + + + Collaborative Presence Heartbeat Liveness Guard + Ghost session cleanup before live cursor and presence fanout + + 3 + published + + 1 + recoverable + + 5 + suppressed + Controls: stale heartbeat, ghost session, duplicate tab, orphan cursor, expired typing, expired fanout lease. + Digest 4a2254121a88799219fbeb58149d... + diff --git a/collab-presence-heartbeat-liveness-guard/requirements-map.md b/collab-presence-heartbeat-liveness-guard/requirements-map.md new file mode 100644 index 00000000..d0913e3d --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/requirements-map.md @@ -0,0 +1,19 @@ +# Requirements Map + +Issue #12 asks for a real-time collaborative research editor with live cursor tracking, user presence indicators, activity status, comments, suggestions, section controls, autosave/versioning, and notebook collaboration. This module covers the liveness safety layer that must run before cursor and presence broadcasts. + +| Issue #12 area | Implementation | +| --- | --- | +| Multi-user editing | Reviews active collaboration sessions before they are included in room fanout. | +| Live cursor tracking | Drops stale, ghost, duplicate, and orphan cursor records before broadcast. | +| User presence indicators | Emits sanitized active/reconnecting status and suppresses stale presence. | +| Activity status | Expires typing indicators independently from session presence. | +| Controlled collaboration | Removes expired presence fanout leases before clients receive stale room state. | +| Autosave/local continuity | Keeps reconnect-grace restore markers without leaking stale cursors. | +| Reviewer-ready governance | Produces deterministic cleanup queues, recovery queues, fanout events, and audit digests. | + +## Non-Overlap Notes + +Existing issue #12 submissions cover broad editor foundations, deterministic operation replay, offline conflict rebasing, notebook workbench and kernel leases, reference formatting and merging, authorship/submission governance, lock/checkpoint recovery, review freeze lanes, figure/table lanes, discussion/sidebar audit, autosave recovery, round-trip fidelity, review decisions, task dependencies, equation/figure anchors, presence privacy, accessibility parity, evidence binding, embargo release, notification visibility, and data availability. + +This module is narrower: it decides whether a live session is fresh enough to appear in the next presence snapshot. diff --git a/collab-presence-heartbeat-liveness-guard/sample-data.js b/collab-presence-heartbeat-liveness-guard/sample-data.js new file mode 100644 index 00000000..02c0cbf9 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/sample-data.js @@ -0,0 +1,114 @@ +const policy = { + version: "presence-heartbeat-liveness-v1", + heartbeatStaleSeconds: 45, + ghostSessionSeconds: 120, + reconnectGraceSeconds: 90, + typingIndicatorSeconds: 20, + maxFutureClockSkewSeconds: 10, + duplicateTabWindowSeconds: 15, +} + +const rooms = [ + { + roomId: "manuscript-alpha", + documentId: "doc-alpha", + title: "Collaborative manuscript draft", + sessions: [ + { + sessionId: "sess-ada-active", + collaboratorId: "ada", + displayName: "Ada", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:45.000Z", + typingSince: "2026-05-23T01:19:52.000Z", + }, + { + sessionId: "sess-bio-ghost", + collaboratorId: "bianca", + displayName: "Bianca", + status: "editing", + lastHeartbeatAt: "2026-05-23T01:17:15.000Z", + typingSince: "2026-05-23T01:17:30.000Z", + }, + { + sessionId: "sess-chen-old-tab", + collaboratorId: "chen", + displayName: "Chen", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:25.000Z", + }, + { + sessionId: "sess-chen-new-tab", + collaboratorId: "chen", + displayName: "Chen", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:55.000Z", + }, + { + sessionId: "sess-dina-reconnect", + collaboratorId: "dina", + displayName: "Dina", + status: "disconnected", + lastHeartbeatAt: "2026-05-23T01:19:02.000Z", + disconnectedAt: "2026-05-23T01:19:20.000Z", + }, + ], + cursors: [ + { sessionId: "sess-ada-active", sectionId: "intro", blockId: "intro-2", offset: 81 }, + { sessionId: "sess-bio-ghost", sectionId: "methods", blockId: "methods-4", offset: 12 }, + { sessionId: "sess-chen-old-tab", sectionId: "results", blockId: "result-1", offset: 3 }, + { sessionId: "sess-chen-new-tab", sectionId: "results", blockId: "result-2", offset: 33 }, + { sessionId: "missing-session", sectionId: "discussion", blockId: "discussion-1", offset: 7 }, + ], + presenceLeases: [ + { sessionId: "sess-ada-active", expiresAt: "2026-05-23T01:20:30.000Z" }, + { sessionId: "sess-bio-ghost", expiresAt: "2026-05-23T01:20:05.000Z" }, + { sessionId: "sess-chen-new-tab", expiresAt: "2026-05-23T01:20:25.000Z" }, + { sessionId: "sess-dina-reconnect", expiresAt: "2026-05-23T01:20:15.000Z" }, + ], + }, + { + roomId: "notebook-review", + documentId: "doc-notebook", + title: "Notebook review room", + sessions: [ + { + sessionId: "sess-eli-clean", + collaboratorId: "eli", + displayName: "Eli", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:48.000Z", + }, + { + sessionId: "sess-farid-expired-lease", + collaboratorId: "farid", + displayName: "Farid", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:50.000Z", + typingSince: "2026-05-23T01:18:55.000Z", + }, + { + sessionId: "sess-gita-skew", + collaboratorId: "gita", + displayName: "Gita", + status: "active", + lastHeartbeatAt: "2026-05-23T01:20:22.000Z", + }, + ], + cursors: [ + { sessionId: "sess-eli-clean", sectionId: "analysis", blockId: "cell-2", offset: 18 }, + { sessionId: "sess-farid-expired-lease", sectionId: "analysis", blockId: "cell-5", offset: 44 }, + { sessionId: "sess-gita-skew", sectionId: "analysis", blockId: "cell-7", offset: 21 }, + ], + presenceLeases: [ + { sessionId: "sess-eli-clean", expiresAt: "2026-05-23T01:20:25.000Z" }, + { sessionId: "sess-farid-expired-lease", expiresAt: "2026-05-23T01:19:40.000Z" }, + { sessionId: "sess-gita-skew", expiresAt: "2026-05-23T01:20:30.000Z" }, + ], + }, +] + +module.exports = { + policy, + rooms, +} diff --git a/collab-presence-heartbeat-liveness-guard/test.js b/collab-presence-heartbeat-liveness-guard/test.js new file mode 100644 index 00000000..26021f50 --- /dev/null +++ b/collab-presence-heartbeat-liveness-guard/test.js @@ -0,0 +1,83 @@ +const assert = require("node:assert/strict") +const { evaluatePresenceLiveness } = require("./index") +const { rooms, policy } = require("./sample-data") + +const packet = evaluatePresenceLiveness({ + asOf: "2026-05-23T01:20:00.000Z", + rooms, + policy, +}) + +assert.equal(packet.summary.totalRooms, 2) +assert.equal(packet.summary.totalSessions, 8) +assert.equal(packet.summary.publishedSessions, 3) +assert.equal(packet.summary.suppressedSessions, 5) +assert.equal(packet.summary.cleanupActions, 7) +assert.equal(packet.summary.recoveryActions, 1) +assert.equal(packet.summary.fanoutEvents, 3) +assert.equal(packet.summary.holdRooms, 1) +assert.equal(packet.summary.sanitizedRooms, 1) + +const alpha = packet.rooms.find((room) => room.roomId === "manuscript-alpha") +assert.equal(alpha.status, "hold_broadcast_cleanup") +assert.deepEqual(alpha.sanitizedPresence.sessions.map((session) => session.sessionId), [ + "sess-ada-active", + "sess-chen-new-tab", +]) + +const ghost = alpha.sessionDecisions.find((session) => session.sessionId === "sess-bio-ghost") +assert.equal(ghost.broadcast, false) +assert(ghost.cleanupActions.some((action) => action.code === "ghost_session")) +assert(ghost.cleanupActions.some((action) => action.code === "expired_typing_indicator")) + +const reconnect = alpha.sessionDecisions.find((session) => session.sessionId === "sess-dina-reconnect") +assert.equal(reconnect.broadcast, false) +assert.equal(reconnect.recoveryActions[0].code, "reconnect_grace") +assert.equal(reconnect.recoveryActions[0].action, "hold_cursor_restore_marker") + +const oldTab = alpha.sessionDecisions.find((session) => session.sessionId === "sess-chen-old-tab") +assert.equal(oldTab.broadcast, false) +assert(oldTab.cleanupActions.some((action) => action.code === "duplicate_tab_shadowed")) + +assert(packet.cleanupQueue.some((action) => action.code === "orphan_cursor" && action.sessionId === "missing-session")) + +const notebook = packet.rooms.find((room) => room.roomId === "notebook-review") +const expiredLease = notebook.sessionDecisions.find((session) => session.sessionId === "sess-farid-expired-lease") +assert.equal(expiredLease.broadcast, false) +assert(expiredLease.cleanupActions.some((action) => action.code === "expired_presence_fanout_lease")) +assert(expiredLease.cleanupActions.some((action) => action.code === "expired_typing_indicator")) + +const skewed = notebook.sessionDecisions.find((session) => session.sessionId === "sess-gita-skew") +assert.equal(skewed.publicStatus, "clock_skew_review") +assert.equal(skewed.publicCursor, null) + +const cleanOnly = evaluatePresenceLiveness({ + asOf: "2026-05-23T01:20:00.000Z", + rooms: [{ + roomId: "clean-room", + documentId: "doc-clean", + title: "Clean room", + sessions: [{ + sessionId: "sess-clean", + collaboratorId: "clean", + displayName: "Clean", + status: "active", + lastHeartbeatAt: "2026-05-23T01:19:58.000Z", + }], + cursors: [{ sessionId: "sess-clean", sectionId: "intro", blockId: "intro-1", offset: 4 }], + presenceLeases: [{ sessionId: "sess-clean", expiresAt: "2026-05-23T01:20:20.000Z" }], + }], + policy, +}) +assert.equal(cleanOnly.summary.readyRooms, 1) +assert.equal(cleanOnly.summary.cleanupActions, 0) +assert.equal(cleanOnly.summary.publishedSessions, 1) + +const repeated = evaluatePresenceLiveness({ + asOf: "2026-05-23T01:20:00.000Z", + rooms, + policy, +}) +assert.equal(repeated.audit.digest, packet.audit.digest) + +console.log("collaborative presence heartbeat liveness guard tests passed")