diff --git a/collab-notification-visibility-guard/README.md b/collab-notification-visibility-guard/README.md
new file mode 100644
index 00000000..57536064
--- /dev/null
+++ b/collab-notification-visibility-guard/README.md
@@ -0,0 +1,39 @@
+# Collaborative Notification Visibility Guard
+
+This module is a focused slice for issue #12, "Real-time collaborative research editor & interface." It audits the notification fanout layer for a collaborative research editor so sensitive collaboration context is not leaked through in-app alerts, email digests, push messages, or webhooks.
+
+The guard checks whether each recipient has the required role, scope, channel allowance, digest receipt, and privacy permission before a notification is delivered. It can deliver, sanitize, hold for review, or drop each route.
+
+## Covered Risks
+
+- Blinded reviewer identities leaking into author notifications.
+- Private section titles appearing in email digests before recipients have section access.
+- Embargoed anchors leaving the editor through external channels.
+- Restricted notebook paths appearing in push or email alerts.
+- Private collaborator notes appearing in publication export notifications.
+- External notification channels sending non-sanitized payloads without an explicit receipt.
+
+## Files
+
+- `index.js`: deterministic notification visibility evaluator and artifact builders.
+- `sample-data.js`: synthetic collaborative editor notification batch.
+- `test.js`: dependency-free assertions for routing and redaction behavior.
+- `demo.js`: writes JSON, Markdown, SVG, and PPM demo artifacts into `reports/`.
+- `reports/`: generated reviewer packet and demo artifacts.
+
+## Run
+
+```bash
+npm run check
+npm test
+npm run demo
+```
+
+The demo produces:
+
+- `reports/notification-visibility-packet.json`
+- `reports/notification-visibility-report.md`
+- `reports/summary.svg`
+- `reports/demo.ppm`
+
+The submitted PR also includes a short H.264 MP4 demo generated from the PPM frame.
diff --git a/collab-notification-visibility-guard/demo.js b/collab-notification-visibility-guard/demo.js
new file mode 100644
index 00000000..901014ca
--- /dev/null
+++ b/collab-notification-visibility-guard/demo.js
@@ -0,0 +1,40 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const {
+ buildDemoFrame,
+ buildReviewReport,
+ buildSummarySvg,
+ evaluateNotificationBatch
+} = require("./index");
+const { sampleBatch } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const result = evaluateNotificationBatch(sampleBatch);
+
+fs.writeFileSync(
+ path.join(reportsDir, "notification-visibility-packet.json"),
+ `${JSON.stringify(result, null, 2)}\n`
+);
+fs.writeFileSync(
+ path.join(reportsDir, "notification-visibility-report.md"),
+ buildReviewReport(result)
+);
+fs.writeFileSync(
+ path.join(reportsDir, "summary.svg"),
+ buildSummarySvg(result)
+);
+fs.writeFileSync(
+ path.join(reportsDir, "demo.ppm"),
+ buildDemoFrame(result)
+);
+
+console.log(`routes=${result.summary.totalRoutes}`);
+console.log(`deliver=${result.summary.deliver}`);
+console.log(`sanitize=${result.summary.sanitize}`);
+console.log(`hold=${result.summary.hold}`);
+console.log(`drop=${result.summary.drop}`);
+console.log(`auditDigest=${result.auditDigest}`);
diff --git a/collab-notification-visibility-guard/index.js b/collab-notification-visibility-guard/index.js
new file mode 100644
index 00000000..8feb6d46
--- /dev/null
+++ b/collab-notification-visibility-guard/index.js
@@ -0,0 +1,459 @@
+"use strict";
+
+const crypto = require("crypto");
+
+const EXTERNAL_CHANNELS = new Set(["email", "push", "webhook", "slack_digest"]);
+const ACTION_RANK = {
+ deliver: 0,
+ sanitize: 1,
+ hold: 2,
+ drop: 3
+};
+
+function stableStringify(value) {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value);
+ }
+
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(",")}]`;
+ }
+
+ const keys = Object.keys(value).sort();
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
+}
+
+function digest(value) {
+ return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
+}
+
+function asArray(value) {
+ if (value === undefined || value === null) {
+ return [];
+ }
+
+ return Array.isArray(value) ? value : [value];
+}
+
+function unique(values) {
+ return Array.from(new Set(values.filter(Boolean)));
+}
+
+function recipientHasScope(recipient, scope) {
+ const scopes = new Set(asArray(recipient.scopes));
+ return scopes.has("*") || scopes.has(scope);
+}
+
+function recipientHasPermission(recipient, permission) {
+ return new Set(asArray(recipient.permissions)).has(permission);
+}
+
+function routeIsExternal(route) {
+ return EXTERNAL_CHANNELS.has(route.channel);
+}
+
+function includesRole(roles, role) {
+ return roles.includes("*") || roles.includes(role);
+}
+
+function redactMessage(text, redactions) {
+ let output = String(text || "");
+
+ for (const redaction of redactions) {
+ if (!redaction.value) {
+ continue;
+ }
+
+ output = output.split(redaction.value).join(redaction.replacement);
+ }
+
+ return output;
+}
+
+function makeSafeSubject(event, action) {
+ if (action === "deliver") {
+ return event.subject;
+ }
+
+ if (event.type === "reviewer_note") {
+ return "Restricted reviewer note update";
+ }
+
+ if (event.type === "notebook_output") {
+ return "Restricted notebook output update";
+ }
+
+ if (event.type === "section_lock") {
+ return "Restricted section lock update";
+ }
+
+ if (event.type === "export_digest") {
+ return "Publication export digest requires review";
+ }
+
+ return "Collaborative editor update requires review";
+}
+
+function buildPayload(event, action, redactions, reasons) {
+ const rawPayload = {
+ subject: event.subject,
+ message: event.message,
+ sectionTitle: event.sectionTitle,
+ anchor: event.anchor,
+ notebookPath: event.notebookPath,
+ reviewerIdentity: event.reviewerIdentity,
+ threadTitle: event.threadTitle,
+ eventType: event.type,
+ eventId: event.id
+ };
+
+ if (action === "deliver") {
+ return {
+ ...rawPayload,
+ visibility: "full"
+ };
+ }
+
+ const safePayload = {
+ subject: makeSafeSubject(event, action),
+ message: redactMessage(event.message, redactions),
+ eventType: event.type,
+ eventId: event.id,
+ visibility: action,
+ redactedFields: unique(redactions.map((redaction) => redaction.field)),
+ reviewReasons: reasons
+ };
+
+ if (event.anchor && !redactions.some((redaction) => redaction.field === "anchor")) {
+ safePayload.anchor = event.anchor;
+ }
+
+ return safePayload;
+}
+
+function routeRecipientIds(event, recipients) {
+ if (event.fanout === "all") {
+ return recipients.map((recipient) => recipient.id);
+ }
+
+ return asArray(event.recipientIds);
+}
+
+function evaluateRoute(event, recipient, channel) {
+ const reasons = [];
+ const redactions = [];
+ const requiredScopes = asArray(event.requiredScopes);
+ const allowedRoles = asArray(event.allowedRoles.length ? event.allowedRoles : ["*"]);
+ const allowedChannels = asArray(event.allowedChannels.length ? event.allowedChannels : ["in_app"]);
+ const route = {
+ eventId: event.id,
+ recipientId: recipient.id,
+ recipientRole: recipient.role,
+ channel
+ };
+
+ if (!includesRole(allowedRoles, recipient.role)) {
+ reasons.push("recipient-role-not-allowed");
+ }
+
+ for (const scope of requiredScopes) {
+ if (!recipientHasScope(recipient, scope)) {
+ reasons.push(`missing-scope:${scope}`);
+ }
+ }
+
+ if (!allowedChannels.includes(channel)) {
+ reasons.push(`channel-not-allowed:${channel}`);
+ }
+
+ if (event.privateThread && !asArray(event.threadParticipants).includes(recipient.id) && !recipientHasPermission(recipient, "viewPrivateThreads")) {
+ reasons.push("not-private-thread-participant");
+ }
+
+ if (event.requiresReceipt && routeIsExternal(route) && !recipient.digestReceiptId) {
+ reasons.push("missing-external-digest-receipt");
+ }
+
+ if (event.blindedReviewerIdentity && !recipientHasPermission(recipient, "viewReviewerIdentity")) {
+ reasons.push("blinded-reviewer-identity-hidden");
+ redactions.push({
+ field: "reviewerIdentity",
+ value: event.reviewerIdentity,
+ replacement: "[redacted reviewer identity]"
+ });
+ }
+
+ if (event.privateSectionTitle && !recipientHasScope(recipient, `section:${event.sectionId}`)) {
+ reasons.push(`private-section-title-hidden:${event.sectionId}`);
+ redactions.push({
+ field: "sectionTitle",
+ value: event.sectionTitle,
+ replacement: "[restricted section]"
+ });
+ }
+
+ if (event.embargoedAnchor && !recipientHasPermission(recipient, "viewEmbargoedAnchors") && !recipientHasScope(recipient, `embargo:${event.sectionId}`)) {
+ reasons.push(`embargoed-anchor-hidden:${event.sectionId}`);
+ redactions.push({
+ field: "anchor",
+ value: event.anchor,
+ replacement: "[restricted anchor]"
+ });
+ }
+
+ if (event.restrictedNotebookPath && !recipientHasPermission(recipient, "viewRestrictedNotebookPaths") && !recipientHasScope(recipient, `notebook:${event.notebookId}`)) {
+ reasons.push(`restricted-notebook-path-hidden:${event.notebookId}`);
+ redactions.push({
+ field: "notebookPath",
+ value: event.notebookPath,
+ replacement: "[restricted notebook path]"
+ });
+ }
+
+ if (event.privateCollaboratorNote && !recipientHasPermission(recipient, "viewPrivateThreads")) {
+ reasons.push("private-collaborator-note-hidden");
+ redactions.push({
+ field: "threadTitle",
+ value: event.threadTitle,
+ replacement: "[private collaborator note]"
+ });
+ }
+
+ if (routeIsExternal(route) && event.externalDigestSafe !== true) {
+ reasons.push("external-channel-requires-sanitized-digest");
+ }
+
+ let action = "deliver";
+
+ if (reasons.some((reason) => reason.startsWith("channel-not-allowed") || reason === "recipient-role-not-allowed")) {
+ action = "drop";
+ } else if (reasons.some((reason) => reason.startsWith("missing-scope") || reason === "not-private-thread-participant" || reason === "missing-external-digest-receipt")) {
+ action = "hold";
+ } else if (redactions.length > 0 || reasons.includes("external-channel-requires-sanitized-digest")) {
+ action = "sanitize";
+ }
+
+ return {
+ ...route,
+ action,
+ severity: ACTION_RANK[action],
+ reasons: unique(reasons),
+ payload: buildPayload(event, action, redactions, unique(reasons)),
+ routeDigest: digest({
+ eventId: event.id,
+ recipientId: recipient.id,
+ channel,
+ action,
+ reasons: unique(reasons)
+ })
+ };
+}
+
+function evaluateNotificationBatch(batch) {
+ const recipients = asArray(batch.recipients);
+ const recipientMap = new Map(recipients.map((recipient) => [recipient.id, recipient]));
+ const routes = [];
+
+ for (const event of asArray(batch.events)) {
+ for (const recipientId of routeRecipientIds(event, recipients)) {
+ const recipient = recipientMap.get(recipientId);
+ if (!recipient) {
+ routes.push({
+ eventId: event.id,
+ recipientId,
+ recipientRole: "unknown",
+ channel: "unknown",
+ action: "drop",
+ severity: ACTION_RANK.drop,
+ reasons: ["recipient-not-found"],
+ payload: {
+ eventId: event.id,
+ eventType: event.type,
+ visibility: "drop"
+ },
+ routeDigest: digest({ eventId: event.id, recipientId, action: "drop" })
+ });
+ continue;
+ }
+
+ const channel = event.routeChannels && event.routeChannels[recipientId] ? event.routeChannels[recipientId] : recipient.defaultChannel;
+ routes.push(evaluateRoute(event, recipient, channel));
+ }
+ }
+
+ const counts = {
+ deliver: 0,
+ sanitize: 0,
+ hold: 0,
+ drop: 0
+ };
+
+ for (const route of routes) {
+ counts[route.action] += 1;
+ }
+
+ const leakageRisks = routes
+ .filter((route) => route.action !== "deliver")
+ .map((route) => ({
+ eventId: route.eventId,
+ recipientId: route.recipientId,
+ action: route.action,
+ reasons: route.reasons
+ }));
+
+ const result = {
+ projectId: batch.projectId,
+ evaluatedAt: batch.evaluatedAt,
+ summary: {
+ totalRoutes: routes.length,
+ ...counts,
+ maxSeverity: routes.reduce((max, route) => Math.max(max, route.severity), 0),
+ leakageRiskCount: leakageRisks.length
+ },
+ routes,
+ leakageRisks
+ };
+
+ return {
+ ...result,
+ auditDigest: digest(result)
+ };
+}
+
+function buildReviewReport(result) {
+ const lines = [
+ "# Collaborative Notification Visibility Guard Report",
+ "",
+ `Project: ${result.projectId}`,
+ `Audit digest: ${result.auditDigest}`,
+ "",
+ "## Summary",
+ "",
+ `- Total routes evaluated: ${result.summary.totalRoutes}`,
+ `- Delivered without change: ${result.summary.deliver}`,
+ `- Sanitized before delivery: ${result.summary.sanitize}`,
+ `- Held for review: ${result.summary.hold}`,
+ `- Dropped: ${result.summary.drop}`,
+ `- Leakage risks found: ${result.summary.leakageRiskCount}`,
+ "",
+ "## Route Decisions",
+ ""
+ ];
+
+ for (const route of result.routes) {
+ lines.push(`- ${route.eventId} -> ${route.recipientId} via ${route.channel}: ${route.action}`);
+ if (route.reasons.length) {
+ lines.push(` - Reasons: ${route.reasons.join(", ")}`);
+ }
+ }
+
+ lines.push("");
+ lines.push("## Reviewer Notes");
+ lines.push("");
+ lines.push("This guard focuses on notification fanout, not the editor canvas itself. It verifies that in-app alerts, email digests, push alerts, and webhook notifications do not disclose restricted collaborative context to recipients who lack the role, scope, channel, or receipt required to see it.");
+ lines.push("");
+
+ return lines.join("\n");
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function buildSummarySvg(result) {
+ const width = 1280;
+ const height = 720;
+ const bars = [
+ ["deliver", "#1f7a4d"],
+ ["sanitize", "#1f5f99"],
+ ["hold", "#b36b00"],
+ ["drop", "#9e2b25"]
+ ];
+ const max = Math.max(1, ...bars.map(([key]) => result.summary[key]));
+ const barSvg = bars.map(([key, color], index) => {
+ const value = result.summary[key];
+ const barWidth = Math.round((value / max) * 720);
+ const y = 230 + index * 86;
+ return [
+ `${escapeXml(key)}`,
+ ``,
+ ``,
+ `${value}`
+ ].join("\n");
+ }).join("\n");
+
+ return [
+ ``
+ ].join("\n");
+}
+
+function fillRect(pixels, width, x0, y0, rectWidth, rectHeight, rgb) {
+ for (let y = y0; y < y0 + rectHeight; y += 1) {
+ for (let x = x0; x < x0 + rectWidth; x += 1) {
+ const index = (y * width + x) * 3;
+ pixels[index] = rgb[0];
+ pixels[index + 1] = rgb[1];
+ pixels[index + 2] = rgb[2];
+ }
+ }
+}
+
+function buildDemoFrame(result, width = 1280, height = 720) {
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ const pixels = Buffer.alloc(width * height * 3);
+
+ for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ const index = (y * width + x) * 3;
+ pixels[index] = 246;
+ pixels[index + 1] = 248;
+ pixels[index + 2] = 250;
+ }
+ }
+
+ fillRect(pixels, width, 64, 58, 1152, 604, [255, 255, 255]);
+ fillRect(pixels, width, 64, 58, 1152, 12, [51, 65, 85]);
+ fillRect(pixels, width, 110, 130, 620, 20, [31, 41, 55]);
+ fillRect(pixels, width, 110, 170, 760, 10, [100, 116, 139]);
+
+ const colors = {
+ deliver: [31, 122, 77],
+ sanitize: [31, 95, 153],
+ hold: [179, 107, 0],
+ drop: [158, 43, 37]
+ };
+ const keys = Object.keys(colors);
+ const max = Math.max(1, ...keys.map((key) => result.summary[key]));
+
+ keys.forEach((key, index) => {
+ const y = 250 + index * 90;
+ const barWidth = Math.max(18, Math.round((result.summary[key] / max) * 740));
+ fillRect(pixels, width, 290, y, 760, 44, [226, 232, 240]);
+ fillRect(pixels, width, 290, y, barWidth, 44, colors[key]);
+ fillRect(pixels, width, 110, y + 8, 130, 18, [31, 41, 55]);
+ });
+
+ fillRect(pixels, width, 110, 620, 500, 12, [71, 85, 105]);
+
+ return Buffer.concat([header, pixels]);
+}
+
+module.exports = {
+ evaluateNotificationBatch,
+ buildReviewReport,
+ buildSummarySvg,
+ buildDemoFrame,
+ stableStringify,
+ digest
+};
diff --git a/collab-notification-visibility-guard/package.json b/collab-notification-visibility-guard/package.json
new file mode 100644
index 00000000..d3d3204b
--- /dev/null
+++ b/collab-notification-visibility-guard/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "collab-notification-visibility-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Dependency-free guard for notification visibility in a real-time collaborative research editor.",
+ "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-notification-visibility-guard/reports/demo.mp4 b/collab-notification-visibility-guard/reports/demo.mp4
new file mode 100644
index 00000000..3372fe48
Binary files /dev/null and b/collab-notification-visibility-guard/reports/demo.mp4 differ
diff --git a/collab-notification-visibility-guard/reports/notification-visibility-packet.json b/collab-notification-visibility-guard/reports/notification-visibility-packet.json
new file mode 100644
index 00000000..fb7ffc41
--- /dev/null
+++ b/collab-notification-visibility-guard/reports/notification-visibility-packet.json
@@ -0,0 +1,387 @@
+{
+ "projectId": "sci-editor-proj-12-demo",
+ "evaluatedAt": "2026-05-22T14:00:00Z",
+ "summary": {
+ "totalRoutes": 11,
+ "deliver": 3,
+ "sanitize": 5,
+ "hold": 3,
+ "drop": 0,
+ "maxSeverity": 2,
+ "leakageRiskCount": 8
+ },
+ "routes": [
+ {
+ "eventId": "evt-reviewer-note",
+ "recipientId": "ed-1",
+ "recipientRole": "editor",
+ "channel": "in_app",
+ "action": "deliver",
+ "severity": 0,
+ "reasons": [],
+ "payload": {
+ "subject": "Reviewer Dr. Lane flagged Methods - oncology cohort",
+ "message": "Reviewer Dr. Lane says Figure 2 references /restricted/nb-qc.ipynb and should not be visible in the author email digest yet.",
+ "sectionTitle": "Methods - oncology cohort",
+ "notebookPath": "/restricted/nb-qc.ipynb",
+ "reviewerIdentity": "Dr. Lane",
+ "threadTitle": "Private R2 reviewer note",
+ "eventType": "reviewer_note",
+ "eventId": "evt-reviewer-note",
+ "visibility": "full"
+ },
+ "routeDigest": "0614f949973cd62446c3f5a92e0cb175802001db40cddd90ce62b963e742fab7"
+ },
+ {
+ "eventId": "evt-reviewer-note",
+ "recipientId": "rev-anon-2",
+ "recipientRole": "reviewer",
+ "channel": "in_app",
+ "action": "sanitize",
+ "severity": 1,
+ "reasons": [
+ "blinded-reviewer-identity-hidden",
+ "restricted-notebook-path-hidden:nb-qc"
+ ],
+ "payload": {
+ "subject": "Restricted reviewer note update",
+ "message": "Reviewer [redacted reviewer identity] says Figure 2 references [restricted notebook path] and should not be visible in the author email digest yet.",
+ "eventType": "reviewer_note",
+ "eventId": "evt-reviewer-note",
+ "visibility": "sanitize",
+ "redactedFields": [
+ "reviewerIdentity",
+ "notebookPath"
+ ],
+ "reviewReasons": [
+ "blinded-reviewer-identity-hidden",
+ "restricted-notebook-path-hidden:nb-qc"
+ ]
+ },
+ "routeDigest": "dd70ce34da950d1a90ea92ac50b50cbf3a4c4d71fcec6314a29fc2456757ae0d"
+ },
+ {
+ "eventId": "evt-reviewer-note",
+ "recipientId": "author-1",
+ "recipientRole": "author",
+ "channel": "email",
+ "action": "hold",
+ "severity": 2,
+ "reasons": [
+ "missing-scope:review:R2",
+ "not-private-thread-participant",
+ "blinded-reviewer-identity-hidden",
+ "private-section-title-hidden:methods",
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Restricted reviewer note update",
+ "message": "Reviewer [redacted reviewer identity] says Figure 2 references [restricted notebook path] and should not be visible in the author email digest yet.",
+ "eventType": "reviewer_note",
+ "eventId": "evt-reviewer-note",
+ "visibility": "hold",
+ "redactedFields": [
+ "reviewerIdentity",
+ "sectionTitle",
+ "notebookPath"
+ ],
+ "reviewReasons": [
+ "missing-scope:review:R2",
+ "not-private-thread-participant",
+ "blinded-reviewer-identity-hidden",
+ "private-section-title-hidden:methods",
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "fee8647af7dc1ee21199883ff4ea1d202d6d338cce61f9bb06b2320a9e8e686e"
+ },
+ {
+ "eventId": "evt-methods-lock",
+ "recipientId": "ed-1",
+ "recipientRole": "editor",
+ "channel": "in_app",
+ "action": "deliver",
+ "severity": 0,
+ "reasons": [],
+ "payload": {
+ "subject": "Methods - oncology cohort locked for review",
+ "message": "The Methods - oncology cohort section is locked at anchor sec-methods-r2 until the embargo review clears.",
+ "sectionTitle": "Methods - oncology cohort",
+ "anchor": "sec-methods-r2",
+ "eventType": "section_lock",
+ "eventId": "evt-methods-lock",
+ "visibility": "full"
+ },
+ "routeDigest": "8a4d753af2c9269d510efa9ef91abd029b5603f9e6f5bc3cbb1f70ec38a7ce61"
+ },
+ {
+ "eventId": "evt-methods-lock",
+ "recipientId": "author-1",
+ "recipientRole": "author",
+ "channel": "email",
+ "action": "sanitize",
+ "severity": 1,
+ "reasons": [
+ "private-section-title-hidden:methods",
+ "embargoed-anchor-hidden:methods",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Restricted section lock update",
+ "message": "The [restricted section] section is locked at anchor [restricted anchor] until the embargo review clears.",
+ "eventType": "section_lock",
+ "eventId": "evt-methods-lock",
+ "visibility": "sanitize",
+ "redactedFields": [
+ "sectionTitle",
+ "anchor"
+ ],
+ "reviewReasons": [
+ "private-section-title-hidden:methods",
+ "embargoed-anchor-hidden:methods",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "68d76233567aea0db69ff7e67aefa1659def34fd1b07b47cb5f18020c95c69c3"
+ },
+ {
+ "eventId": "evt-notebook-output",
+ "recipientId": "ed-1",
+ "recipientRole": "editor",
+ "channel": "in_app",
+ "action": "deliver",
+ "severity": 0,
+ "reasons": [],
+ "payload": {
+ "subject": "QC notebook output changed",
+ "message": "Output for /restricted/nb-qc.ipynb changed after collaborator review. Verify cell qc-17 before export.",
+ "notebookPath": "/restricted/nb-qc.ipynb",
+ "eventType": "notebook_output",
+ "eventId": "evt-notebook-output",
+ "visibility": "full"
+ },
+ "routeDigest": "b1ea637335b1eb2457dcec0258d60f3c91bcd5a595a0865a8964ca8e2a62cea8"
+ },
+ {
+ "eventId": "evt-notebook-output",
+ "recipientId": "analyst-1",
+ "recipientRole": "collaborator",
+ "channel": "push",
+ "action": "sanitize",
+ "severity": 1,
+ "reasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Restricted notebook output update",
+ "message": "Output for [restricted notebook path] changed after collaborator review. Verify cell qc-17 before export.",
+ "eventType": "notebook_output",
+ "eventId": "evt-notebook-output",
+ "visibility": "sanitize",
+ "redactedFields": [
+ "notebookPath"
+ ],
+ "reviewReasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "12f365a120704c1132f9275829bca705f827edb19effe83433271a6c4120e23f"
+ },
+ {
+ "eventId": "evt-notebook-output",
+ "recipientId": "author-1",
+ "recipientRole": "author",
+ "channel": "email",
+ "action": "sanitize",
+ "severity": 1,
+ "reasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Restricted notebook output update",
+ "message": "Output for [restricted notebook path] changed after collaborator review. Verify cell qc-17 before export.",
+ "eventType": "notebook_output",
+ "eventId": "evt-notebook-output",
+ "visibility": "sanitize",
+ "redactedFields": [
+ "notebookPath"
+ ],
+ "reviewReasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "b5f69dcee15b47bde546815640c922ca7a21e23565656021b59c4c7c02fd3fa5"
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "pm-1",
+ "recipientRole": "editor",
+ "channel": "email",
+ "action": "sanitize",
+ "severity": 1,
+ "reasons": [
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Publication export digest requires review",
+ "message": "Export packet includes private collaborator note Final R2 reply before journal upload.",
+ "eventType": "export_digest",
+ "eventId": "evt-export-digest",
+ "visibility": "sanitize",
+ "redactedFields": [],
+ "reviewReasons": [
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "5e53cd393603f361428b24f147456e888461f1aa437eb253c74f607fa1a0c8b2"
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "author-1",
+ "recipientRole": "author",
+ "channel": "email",
+ "action": "hold",
+ "severity": 2,
+ "reasons": [
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Publication export digest requires review",
+ "message": "Export packet includes private collaborator note [private collaborator note] before journal upload.",
+ "eventType": "export_digest",
+ "eventId": "evt-export-digest",
+ "visibility": "hold",
+ "redactedFields": [
+ "threadTitle"
+ ],
+ "reviewReasons": [
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "44d241de5f48d1e20ceb400761b760bfabd8b55b83c62b841613a6c43deb72fa"
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "sponsor-1",
+ "recipientRole": "sponsor",
+ "channel": "webhook",
+ "action": "hold",
+ "severity": 2,
+ "reasons": [
+ "missing-scope:manuscript:core",
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ],
+ "payload": {
+ "subject": "Publication export digest requires review",
+ "message": "Export packet includes private collaborator note [private collaborator note] before journal upload.",
+ "eventType": "export_digest",
+ "eventId": "evt-export-digest",
+ "visibility": "hold",
+ "redactedFields": [
+ "threadTitle"
+ ],
+ "reviewReasons": [
+ "missing-scope:manuscript:core",
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ "routeDigest": "95f44e4217f00cc76b2ca451e7b27248ae725880dad6fe3275b5f208e864d7fd"
+ }
+ ],
+ "leakageRisks": [
+ {
+ "eventId": "evt-reviewer-note",
+ "recipientId": "rev-anon-2",
+ "action": "sanitize",
+ "reasons": [
+ "blinded-reviewer-identity-hidden",
+ "restricted-notebook-path-hidden:nb-qc"
+ ]
+ },
+ {
+ "eventId": "evt-reviewer-note",
+ "recipientId": "author-1",
+ "action": "hold",
+ "reasons": [
+ "missing-scope:review:R2",
+ "not-private-thread-participant",
+ "blinded-reviewer-identity-hidden",
+ "private-section-title-hidden:methods",
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-methods-lock",
+ "recipientId": "author-1",
+ "action": "sanitize",
+ "reasons": [
+ "private-section-title-hidden:methods",
+ "embargoed-anchor-hidden:methods",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-notebook-output",
+ "recipientId": "analyst-1",
+ "action": "sanitize",
+ "reasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-notebook-output",
+ "recipientId": "author-1",
+ "action": "sanitize",
+ "reasons": [
+ "restricted-notebook-path-hidden:nb-qc",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "pm-1",
+ "action": "sanitize",
+ "reasons": [
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "author-1",
+ "action": "hold",
+ "reasons": [
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ]
+ },
+ {
+ "eventId": "evt-export-digest",
+ "recipientId": "sponsor-1",
+ "action": "hold",
+ "reasons": [
+ "missing-scope:manuscript:core",
+ "missing-external-digest-receipt",
+ "private-collaborator-note-hidden",
+ "external-channel-requires-sanitized-digest"
+ ]
+ }
+ ],
+ "auditDigest": "02f8ceca9fe8760b5554587bfb21d4efeca366f41656e83f6083c53329a3ba80"
+}
diff --git a/collab-notification-visibility-guard/reports/notification-visibility-report.md b/collab-notification-visibility-guard/reports/notification-visibility-report.md
new file mode 100644
index 00000000..d97a250e
--- /dev/null
+++ b/collab-notification-visibility-guard/reports/notification-visibility-report.md
@@ -0,0 +1,39 @@
+# Collaborative Notification Visibility Guard Report
+
+Project: sci-editor-proj-12-demo
+Audit digest: 02f8ceca9fe8760b5554587bfb21d4efeca366f41656e83f6083c53329a3ba80
+
+## Summary
+
+- Total routes evaluated: 11
+- Delivered without change: 3
+- Sanitized before delivery: 5
+- Held for review: 3
+- Dropped: 0
+- Leakage risks found: 8
+
+## Route Decisions
+
+- evt-reviewer-note -> ed-1 via in_app: deliver
+- evt-reviewer-note -> rev-anon-2 via in_app: sanitize
+ - Reasons: blinded-reviewer-identity-hidden, restricted-notebook-path-hidden:nb-qc
+- evt-reviewer-note -> author-1 via email: hold
+ - Reasons: missing-scope:review:R2, not-private-thread-participant, blinded-reviewer-identity-hidden, private-section-title-hidden:methods, restricted-notebook-path-hidden:nb-qc, external-channel-requires-sanitized-digest
+- evt-methods-lock -> ed-1 via in_app: deliver
+- evt-methods-lock -> author-1 via email: sanitize
+ - Reasons: private-section-title-hidden:methods, embargoed-anchor-hidden:methods, external-channel-requires-sanitized-digest
+- evt-notebook-output -> ed-1 via in_app: deliver
+- evt-notebook-output -> analyst-1 via push: sanitize
+ - Reasons: restricted-notebook-path-hidden:nb-qc, external-channel-requires-sanitized-digest
+- evt-notebook-output -> author-1 via email: sanitize
+ - Reasons: restricted-notebook-path-hidden:nb-qc, external-channel-requires-sanitized-digest
+- evt-export-digest -> pm-1 via email: sanitize
+ - Reasons: external-channel-requires-sanitized-digest
+- evt-export-digest -> author-1 via email: hold
+ - Reasons: missing-external-digest-receipt, private-collaborator-note-hidden, external-channel-requires-sanitized-digest
+- evt-export-digest -> sponsor-1 via webhook: hold
+ - Reasons: missing-scope:manuscript:core, missing-external-digest-receipt, private-collaborator-note-hidden, external-channel-requires-sanitized-digest
+
+## Reviewer Notes
+
+This guard focuses on notification fanout, not the editor canvas itself. It verifies that in-app alerts, email digests, push alerts, and webhook notifications do not disclose restricted collaborative context to recipients who lack the role, scope, channel, or receipt required to see it.
diff --git a/collab-notification-visibility-guard/reports/summary.svg b/collab-notification-visibility-guard/reports/summary.svg
new file mode 100644
index 00000000..56f275ec
--- /dev/null
+++ b/collab-notification-visibility-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
\ No newline at end of file
diff --git a/collab-notification-visibility-guard/requirements-map.md b/collab-notification-visibility-guard/requirements-map.md
new file mode 100644
index 00000000..250f5402
--- /dev/null
+++ b/collab-notification-visibility-guard/requirements-map.md
@@ -0,0 +1,37 @@
+# Requirements Map
+
+Issue #12 asks for a real-time collaborative research editor and interface. This contribution covers a narrow interface safety slice: notification visibility and digest fanout for collaborative editing events.
+
+## Real-Time Collaboration
+
+- Inline comments, suggestions, and review notes can generate notifications.
+- The guard evaluates each notification route against recipient role, scope, and channel.
+- Private reviewer threads are held when a recipient is not a participant or lacks private-thread permissions.
+
+## User Presence And Activity Status
+
+- This module does not duplicate presence tracking. It complements presence work by checking what leaves the editor through notification channels after collaboration events occur.
+
+## Locking And Controlled Sections
+
+- Section lock notifications are checked for private section titles and embargoed anchors.
+- Recipients without section or embargo access receive held or sanitized payloads.
+
+## Jupyter Notebook Integration
+
+- Notebook output notifications are checked for restricted notebook paths.
+- Collaborators without the matching notebook scope receive sanitized payloads before any push or email fanout.
+
+## Integrated Task And Review Workflow
+
+- Export digest notifications that include private collaborator notes require a safe role, channel, and receipt.
+- The output packet gives reviewers a deterministic route-by-route audit trail.
+
+## Version History And Autosave Adjacent Safety
+
+- The guard emits a deterministic `auditDigest` for the evaluated notification batch.
+- Reviewer artifacts can be attached to a release, autosave checkpoint, or publication export review.
+
+## Non-Overlap Statement
+
+This is not another broad editor foundation, offline conflict resolver, notebook workbench, reference formatter, authorship governance module, freeze/recovery lane, discussion sidebar audit, autosave recovery module, round-trip fidelity checker, review decision ledger, task dependency guard, equation/figure anchor guard, notebook kernel lease guard, presence privacy guard, accessibility parity guard, manuscript evidence-binding guard, or embargo release guard. It focuses specifically on notification and digest payload visibility after collaborative editor events.
diff --git a/collab-notification-visibility-guard/sample-data.js b/collab-notification-visibility-guard/sample-data.js
new file mode 100644
index 00000000..02518f8a
--- /dev/null
+++ b/collab-notification-visibility-guard/sample-data.js
@@ -0,0 +1,128 @@
+"use strict";
+
+const sampleBatch = {
+ projectId: "sci-editor-proj-12-demo",
+ evaluatedAt: "2026-05-22T14:00:00Z",
+ recipients: [
+ {
+ id: "ed-1",
+ role: "editor",
+ defaultChannel: "in_app",
+ scopes: ["manuscript:core", "review:R2", "section:methods", "notebook:nb-qc", "embargo:methods"],
+ permissions: ["viewReviewerIdentity", "viewRestrictedNotebookPaths", "viewEmbargoedAnchors", "viewPrivateThreads"],
+ digestReceiptId: "receipt-ed-1"
+ },
+ {
+ id: "pm-1",
+ role: "editor",
+ defaultChannel: "email",
+ scopes: ["manuscript:core", "section:methods", "embargo:methods"],
+ permissions: ["viewEmbargoedAnchors", "viewPrivateThreads"],
+ digestReceiptId: "receipt-pm-1"
+ },
+ {
+ id: "rev-anon-2",
+ role: "reviewer",
+ defaultChannel: "in_app",
+ scopes: ["manuscript:core", "review:R2", "section:methods"],
+ permissions: [],
+ digestReceiptId: null
+ },
+ {
+ id: "author-1",
+ role: "author",
+ defaultChannel: "email",
+ scopes: ["manuscript:core", "section:introduction"],
+ permissions: [],
+ digestReceiptId: null
+ },
+ {
+ id: "analyst-1",
+ role: "collaborator",
+ defaultChannel: "push",
+ scopes: ["manuscript:core", "notebook:nb-public"],
+ permissions: [],
+ digestReceiptId: "receipt-analyst-1"
+ },
+ {
+ id: "sponsor-1",
+ role: "sponsor",
+ defaultChannel: "webhook",
+ scopes: ["project:public"],
+ permissions: [],
+ digestReceiptId: null
+ }
+ ],
+ events: [
+ {
+ id: "evt-reviewer-note",
+ type: "reviewer_note",
+ subject: "Reviewer Dr. Lane flagged Methods - oncology cohort",
+ message: "Reviewer Dr. Lane says Figure 2 references /restricted/nb-qc.ipynb and should not be visible in the author email digest yet.",
+ allowedRoles: ["editor", "reviewer", "author"],
+ allowedChannels: ["in_app", "email"],
+ recipientIds: ["ed-1", "rev-anon-2", "author-1"],
+ requiredScopes: ["review:R2"],
+ privateThread: true,
+ threadParticipants: ["ed-1", "rev-anon-2"],
+ threadTitle: "Private R2 reviewer note",
+ blindedReviewerIdentity: true,
+ reviewerIdentity: "Dr. Lane",
+ privateSectionTitle: true,
+ sectionId: "methods",
+ sectionTitle: "Methods - oncology cohort",
+ restrictedNotebookPath: true,
+ notebookId: "nb-qc",
+ notebookPath: "/restricted/nb-qc.ipynb",
+ externalDigestSafe: false
+ },
+ {
+ id: "evt-methods-lock",
+ type: "section_lock",
+ subject: "Methods - oncology cohort locked for review",
+ message: "The Methods - oncology cohort section is locked at anchor sec-methods-r2 until the embargo review clears.",
+ allowedRoles: ["editor", "author"],
+ allowedChannels: ["in_app", "email"],
+ recipientIds: ["ed-1", "author-1"],
+ requiredScopes: ["manuscript:core"],
+ privateSectionTitle: true,
+ sectionId: "methods",
+ sectionTitle: "Methods - oncology cohort",
+ embargoedAnchor: true,
+ anchor: "sec-methods-r2",
+ externalDigestSafe: false
+ },
+ {
+ id: "evt-notebook-output",
+ type: "notebook_output",
+ subject: "QC notebook output changed",
+ message: "Output for /restricted/nb-qc.ipynb changed after collaborator review. Verify cell qc-17 before export.",
+ allowedRoles: ["editor", "author", "collaborator"],
+ allowedChannels: ["in_app", "email", "push"],
+ recipientIds: ["ed-1", "analyst-1", "author-1"],
+ requiredScopes: ["manuscript:core"],
+ restrictedNotebookPath: true,
+ notebookId: "nb-qc",
+ notebookPath: "/restricted/nb-qc.ipynb",
+ externalDigestSafe: false
+ },
+ {
+ id: "evt-export-digest",
+ type: "export_digest",
+ subject: "Publication export ready",
+ message: "Export packet includes private collaborator note Final R2 reply before journal upload.",
+ allowedRoles: ["editor", "author", "sponsor"],
+ allowedChannels: ["email", "webhook", "in_app"],
+ recipientIds: ["pm-1", "author-1", "sponsor-1"],
+ requiredScopes: ["manuscript:core"],
+ privateCollaboratorNote: true,
+ threadTitle: "Final R2 reply",
+ requiresReceipt: true,
+ externalDigestSafe: false
+ }
+ ]
+};
+
+module.exports = {
+ sampleBatch
+};
diff --git a/collab-notification-visibility-guard/test.js b/collab-notification-visibility-guard/test.js
new file mode 100644
index 00000000..7edc7250
--- /dev/null
+++ b/collab-notification-visibility-guard/test.js
@@ -0,0 +1,70 @@
+"use strict";
+
+const assert = require("assert");
+const { evaluateNotificationBatch, stableStringify } = require("./index");
+const { sampleBatch } = require("./sample-data");
+
+function findRoute(result, eventId, recipientId) {
+ const route = result.routes.find((candidate) => candidate.eventId === eventId && candidate.recipientId === recipientId);
+ assert(route, `Missing route ${eventId} -> ${recipientId}`);
+ return route;
+}
+
+function assertNoSensitiveLeak(route) {
+ const serialized = stableStringify(route.payload);
+ assert(!serialized.includes("Dr. Lane"), "blinded reviewer identity leaked");
+ assert(!serialized.includes("/restricted/nb-qc.ipynb"), "restricted notebook path leaked");
+ assert(!serialized.includes("Methods - oncology cohort"), "private section title leaked");
+ assert(!serialized.includes("Final R2 reply"), "private collaborator note leaked");
+}
+
+const result = evaluateNotificationBatch(sampleBatch);
+const repeated = evaluateNotificationBatch(sampleBatch);
+
+assert.strictEqual(result.summary.totalRoutes, 11);
+assert.strictEqual(result.summary.deliver, 3);
+assert.strictEqual(result.summary.sanitize, 5);
+assert.strictEqual(result.summary.hold, 3);
+assert.strictEqual(result.summary.drop, 0);
+assert.strictEqual(result.auditDigest, repeated.auditDigest, "audit digest should be deterministic");
+
+const editorReviewerNote = findRoute(result, "evt-reviewer-note", "ed-1");
+assert.strictEqual(editorReviewerNote.action, "deliver");
+
+const reviewerSelfNote = findRoute(result, "evt-reviewer-note", "rev-anon-2");
+assert.strictEqual(reviewerSelfNote.action, "sanitize");
+assert(reviewerSelfNote.reasons.includes("blinded-reviewer-identity-hidden"));
+assertNoSensitiveLeak(reviewerSelfNote);
+
+const authorReviewerNote = findRoute(result, "evt-reviewer-note", "author-1");
+assert.strictEqual(authorReviewerNote.action, "hold");
+assert(authorReviewerNote.reasons.includes("missing-scope:review:R2"));
+assert(authorReviewerNote.reasons.includes("not-private-thread-participant"));
+assertNoSensitiveLeak(authorReviewerNote);
+
+const authorMethodsLock = findRoute(result, "evt-methods-lock", "author-1");
+assert.strictEqual(authorMethodsLock.action, "sanitize");
+assert(authorMethodsLock.reasons.includes("external-channel-requires-sanitized-digest"));
+assert(authorMethodsLock.reasons.includes("private-section-title-hidden:methods"));
+assertNoSensitiveLeak(authorMethodsLock);
+
+const analystNotebook = findRoute(result, "evt-notebook-output", "analyst-1");
+assert.strictEqual(analystNotebook.action, "sanitize");
+assert(analystNotebook.reasons.includes("restricted-notebook-path-hidden:nb-qc"));
+assertNoSensitiveLeak(analystNotebook);
+
+const authorNotebook = findRoute(result, "evt-notebook-output", "author-1");
+assert.strictEqual(authorNotebook.action, "sanitize");
+assert(authorNotebook.reasons.includes("restricted-notebook-path-hidden:nb-qc"));
+assertNoSensitiveLeak(authorNotebook);
+
+const projectManagerExport = findRoute(result, "evt-export-digest", "pm-1");
+assert.strictEqual(projectManagerExport.action, "sanitize");
+assert(projectManagerExport.reasons.includes("external-channel-requires-sanitized-digest"));
+
+const authorExport = findRoute(result, "evt-export-digest", "author-1");
+assert.strictEqual(authorExport.action, "hold");
+assert(authorExport.reasons.includes("missing-external-digest-receipt"));
+assertNoSensitiveLeak(authorExport);
+
+console.log("collab notification visibility guard tests passed");