From daa12d7bf4316498afdcc6dfffefa0d236407662 Mon Sep 17 00:00:00 2001 From: Ethan Miller Date: Fri, 22 May 2026 03:43:35 -0400 Subject: [PATCH] Add collaborative notification visibility guard --- .../README.md | 39 ++ collab-notification-visibility-guard/demo.js | 40 ++ collab-notification-visibility-guard/index.js | 459 ++++++++++++++++++ .../package.json | 12 + .../reports/demo.mp4 | Bin 0 -> 10808 bytes .../notification-visibility-packet.json | 387 +++++++++++++++ .../reports/notification-visibility-report.md | 39 ++ .../reports/summary.svg | 23 + .../requirements-map.md | 37 ++ .../sample-data.js | 128 +++++ collab-notification-visibility-guard/test.js | 70 +++ 11 files changed, 1234 insertions(+) create mode 100644 collab-notification-visibility-guard/README.md create mode 100644 collab-notification-visibility-guard/demo.js create mode 100644 collab-notification-visibility-guard/index.js create mode 100644 collab-notification-visibility-guard/package.json create mode 100644 collab-notification-visibility-guard/reports/demo.mp4 create mode 100644 collab-notification-visibility-guard/reports/notification-visibility-packet.json create mode 100644 collab-notification-visibility-guard/reports/notification-visibility-report.md create mode 100644 collab-notification-visibility-guard/reports/summary.svg create mode 100644 collab-notification-visibility-guard/requirements-map.md create mode 100644 collab-notification-visibility-guard/sample-data.js create mode 100644 collab-notification-visibility-guard/test.js 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 [ + ``, + ``, + ``, + `Notification Visibility Guard`, + `Real-time collaborative research editor notification fanout audit`, + barSvg, + `Digest: ${escapeXml(result.auditDigest.slice(0, 24))}`, + `` + ].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 0000000000000000000000000000000000000000..3372fe481bac530233414ea8eab896df8e9df73d GIT binary patch literal 10808 zcmeHN3p7;Q8{b1jB%&m`9XFN2m>G{yQ&dW&)a~{lHIA9XOy+rJJR^#vZV%;hOETPE zq|)Q|^sl5_L=SFB^m6r7DthX{{P&)r+!nIdf8EyluVt@u&i?k^-}mj`{`T45-uvtW z0Kfud;bNXtBmh7Spcvi^JcLabi0$bB0IdL$ARrr6z`l;|*kmoPL7>@Rs4aOFc1V(psz(_j=gMsQXf!(;294nWGr2OE*u~a1G&GbN!s8GkJ}#t+$Us|E3zaJq@F9;# zEaQoUQWuPk`{QgT9V3YVrX9v1{P`kw5R>jgbD?3l5a)+W2_`Mf&V?36r`uryg2^Sq zFln$qq()(4X*d*xPZr5x(y25k1Rt1y7e;VcNFyCeut;1ONHFP+7@JFq1UL(d(lHrH z@cBF`q@2Q>IBXdt*b)Jg1|i^_2$7IrGU(QHIu?LSWh}8Yh$luIDg_cTOB4_wC1gw+ z1}5W@Py#AqV0@7%2N%=fB1k;*9!-S-=5;jkO%McKrP)3k^95TUte||8D zhqKrsff$#;G#lDTM&djnQ~?=D9PtE@xPXvCYx%Rp;gIHWn6R^0I0qM_X85!GdAJl+ z!XpV~!$JvOAXnxO%S2*A$O;sRVW}!p3`K*8aHyNfaGj z4Z$RFWK)uma-lfMW_@lhDks@YHW`2k{Gnk{vtWYBu%ps232F?JMsajgqUfM@x-u~;2Qxu5$eJv@C88K31R@yJVPYJfzgWQdpV6pm0Pu|8G9zl(??J% z?++6?vp(s4Sey@l`sYh6qiVIePZUEmy(Md*EAWxNsON4U0r>2tc^h(S8%`%bJ>c|O zTNqJs=9Sl{-H~TtHR?d;vaidM7&qzUg5`#L7bpI9<$jsEwf+sSF%gG^S$8fV7K2MS z#(50wIXUp2cK*~NgV}NYZ=-DI=%T#)QY#JsJ<4XdQclLw|K1( zk*2#zwvFDylV>&>uG`iq#Mc0z!G7$K^J`GVO@}{oO$=SK`R2t3tSfFEdp7bwjE=@A zt5LeOr}(<*2O=IEzxj@lrnV_Di39re`D>)Y(tEbJ&%lJ3jqaz()HOZ|%k3e-Ce{7k zm5+5@TXlba@=EtR!q}o!63MAody7AEef;OYT(m|DI)SC{4=>p;e(MNw9Cz~qEXuZD z|LXZ=6|YQ%Y8TU;E3TWDHW#rSZBGQ=s~Qx0^#}kyv))ilST#!{kF39a^y-dvoS)7v z-W&2300YGWL&~jcisW=P#mTH>^h{5(Ol`T}miXm`<;=s><`fI_gvG_r-2c$a%s6{> z#Ef&U)2GIOWz&50)C}~GHyNjwZ|oa%eO__W75`zIQ@~I&!*D|#`$s&{y7xnG&n-Q> zA?d~oBd*ZA$bEY}qe^sc?Uqd%|Iu1}`Pi7Kdo#!V(DPNKfw1oSAJ?Aq;(ejjbq{UZ zGh|TdvRC^XbMuG1J~}#n>h_D*ep_U{Yv6;6E9vsb)+wU~UcEmtBywp65IMUwG*Ke+ z8~a@zInB&Oa-rxFb$QKby5p zJ9c7$_JcJ6fHE=1F&OBF#AE=M>#MVt-bv}jH7$-7MxH-=SCk|AeR@siNBN~x(-L_^ zX#DN;yT3=+F*9{y)CT$0_r4XI4A^DAzO`C%Cn&3bFw<9b;EnR-w(Mg%`})55nkN2I zHcNP8mwke!olJhnX-b8$lilXLRbz)!Dkm|S#@lkd?#x(QD81Q?in1 zEn?vIz;4=rB@U7DlydS%JWvo8ACV+?xvnF{kImElhp2sD4Gpm;$XpbIaZriT#ST7fO$;l#Gu_d%M?MQ#@e~E4zR5v_G#} z#w~yD&^OBIU?R=h(mS~CvSY#L*Q+m^Y0~FJRtVAba($wJwh4fc!A9{9ny$Qg^lb%;i@(}fLgEL8Irzlo= z^hpi(Gw31uXCEz8yFT)IUS3*Sm8W8% zRWK)ihwrQ-305Oc=%XrVlk0z3S6uQVbHj(Gn?FUVO_+7%)1SrUkxZFJLcs6^<)bf+0@r6a8~tl(5wEa@8O}un;vy> zA2QDc1)f*HMnjn-stxJb<%rcO-2lRaB zd@WgYr?BwRdY$koYo00Y<|i_ulrvD@=}CiX`yJesSW#+;WLqz_AOHZ(E!BcQ;dFC$ ziZY*OJSam)Zug5HN>McC{$6tHE&own^F_W=73k>+Wz5~u$A_&@d{*=|VJ1OLlyqD+ z{`A*nt(Aje)hZ_T;?9`_5al+VLR48%ZrcTjTkJc9*p3zDKXnbFq*I70E6Sx^ff&{) z#P+P1@(qY$Wgp59cM7q^ibp$zs2VO?{?jSM_N-X-4T!1{Eq~uB#1<=l>SIrjP3a#8it;A+}@1^zq+-_^R*df&#BRT+bk!ZqF5P~y zb^3)f+AuWiscu`k{bC!Vd|u|X8pxa?@Aiuw80goE{N~0$-o0M@ zJOQ?|(vf$s7rzXyVX9Sk_j*VTk4Az>&;U% zJGpiQPwl1>oxC^v=wQ{c`)ipW#dh(sY!Ctnc4h39K+gZg6tjKzQg(Dfz! zBLTC&O1pmHY>!d{K)W|QUq)ZbePuz)I-!gD99p!&dcO`|)zeE{#44@a5;QZNx=vD&!Lq@@m%!Z zg9k3;@Cn4DMS}<50gx~X5h#_mg>Au6m6>ti={gdI0hQ|plYC4?_pF!7{Ndpn*e{hy zTRQ~tWT0O?V2naroyrUi)NBt(q8kNDKTpW-$e_~cR66`mg3t3u|755NmCpeR`1$}x z$Tfk&0fQBP1Cu})@2Pa1si8WF(T6XgI%tSB@uL7u?$diEKjt?zHX>moc=X?5)RFL5 zTp0#PWlDpi1j3hyep7+L2(|-t1PUl0-aQi7UwGHI1vkq=$E>KilO0-5%7h`tyHH+qBUK=R&p!rcpre8x7ugFh_>N*jqp-V@Lw?whIop pNFi~t7_A9D2Wy^cRb!DY6Cs6W@Rv`d1Sth@<_~}~za5(R{|k*C>eK)L literal 0 HcmV?d00001 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 @@ + + + +Notification Visibility Guard +Real-time collaborative research editor notification fanout audit +deliver + + +3 +sanitize + + +5 +hold + + +3 +drop + + +0 +Digest: 02f8ceca9fe8760b5554587b + \ 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");