diff --git a/collaborative-research-editor/find-replace-safety-guard/README.md b/collaborative-research-editor/find-replace-safety-guard/README.md
new file mode 100644
index 00000000..f0301d6e
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/README.md
@@ -0,0 +1,21 @@
+# Collaborative Find/Replace Safety Guard
+
+Focused slice for SCIBASE issue #12: a real-time collaborative editor guard that previews global find/replace operations before they mutate shared manuscript state.
+
+## What it checks
+
+- Protected section locks and final-review freeze state.
+- Citation keys, cross-reference anchors, and LaTeX command names.
+- Notebook/code cells where semantic replacements need explicit review.
+- Inline comment quote anchors that would no longer resolve after replacement.
+- WYSIWYG/source-mode replacement scope so batch edits remain reviewable.
+
+The module is deterministic and dependency-free. It uses synthetic document packets only; it does not connect to live editors, private projects, external services, or credentials.
+
+## Validation
+
+```bash
+node collaborative-research-editor/find-replace-safety-guard/test.js
+node collaborative-research-editor/find-replace-safety-guard/demo.js
+node collaborative-research-editor/find-replace-safety-guard/make-demo-video.js
+```
diff --git a/collaborative-research-editor/find-replace-safety-guard/demo.js b/collaborative-research-editor/find-replace-safety-guard/demo.js
new file mode 100644
index 00000000..2fd621f2
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/demo.js
@@ -0,0 +1,15 @@
+const fs = require("fs");
+const path = require("path");
+const { replacementPreview, renderMarkdownReport, renderSvgSummary } = require("./index");
+const { riskyDocument, riskyOperation } = require("./sample-data");
+
+const outputDir = path.join(__dirname, "reports");
+fs.mkdirSync(outputDir, { recursive: true });
+
+const result = replacementPreview(riskyDocument, riskyOperation);
+fs.writeFileSync(path.join(outputDir, "find-replace-review.json"), `${JSON.stringify(result, null, 2)}\n`);
+fs.writeFileSync(path.join(outputDir, "find-replace-review.md"), renderMarkdownReport(result));
+fs.writeFileSync(path.join(outputDir, "find-replace-summary.svg"), renderSvgSummary(result));
+
+console.log(`decision=${result.decision} riskScore=${result.riskScore} findings=${result.findings.length}`);
+console.log(`reports=${outputDir}`);
diff --git a/collaborative-research-editor/find-replace-safety-guard/index.js b/collaborative-research-editor/find-replace-safety-guard/index.js
new file mode 100644
index 00000000..527afeeb
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/index.js
@@ -0,0 +1,248 @@
+const crypto = require("crypto");
+
+const SEVERITY_WEIGHT = {
+ blocker: 30,
+ high: 18,
+ medium: 9,
+ low: 4,
+};
+
+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").slice(0, 16);
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function buildMatcher(operation) {
+ const flags = operation.caseSensitive ? "g" : "gi";
+ const source = operation.wholeWord ? `\\b${escapeRegExp(operation.find)}\\b` : escapeRegExp(operation.find);
+ return new RegExp(source, flags);
+}
+
+function blockText(block) {
+ return [block.content, block.key, block.anchor, block.caption].filter(Boolean).join("\n");
+}
+
+function countMatches(text, matcher) {
+ const matches = String(text).match(matcher);
+ return matches ? matches.length : 0;
+}
+
+function classifyBlockRisk(block, section, operation, matcher) {
+ const findings = [];
+ const text = blockText(block);
+ const matches = countMatches(text, matcher);
+ if (!matches) {
+ return findings;
+ }
+
+ const identity = `${section.id}/${block.id}`;
+ if (section.locked || section.freeze === "final-review") {
+ findings.push({
+ severity: "blocker",
+ code: "LOCKED_SECTION_MATCH",
+ title: "Batch replacement touches a locked or final-review section",
+ evidence: `${identity} has ${matches} match(es) while section state is ${section.locked ? "locked" : section.freeze}.`,
+ action: "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions.",
+ });
+ }
+
+ if (block.type === "citation" || /@[A-Za-z0-9:_-]+/.test(text)) {
+ findings.push({
+ severity: "high",
+ code: "CITATION_KEY_MUTATION",
+ title: "Replacement would alter citation keys or reference anchors",
+ evidence: `${identity} contains citation/reference text matching "${operation.find}".`,
+ action: "Route citation-key changes through the reference manager merge workflow instead of direct replacement.",
+ });
+ }
+
+ if (block.type === "latex" && /\\[A-Za-z]+/.test(text)) {
+ findings.push({
+ severity: "high",
+ code: "LATEX_COMMAND_MUTATION",
+ title: "Replacement would alter LaTeX command or equation source",
+ evidence: `${identity} is a LaTeX block with ${matches} match(es).`,
+ action: "Preview equation output and require formula-owner approval before applying source-mode replacements.",
+ });
+ }
+
+ if (block.type === "code" || block.type === "notebook") {
+ findings.push({
+ severity: "high",
+ code: "CODE_CELL_MUTATION",
+ title: "Replacement would mutate notebook or code cell source",
+ evidence: `${identity} is ${block.type} source with ${matches} match(es).`,
+ action: "Create a code-review task and rerun the notebook before sharing updated outputs.",
+ });
+ }
+
+ if (block.type === "cross-reference" || /fig:|tbl:|eq:/i.test(text)) {
+ findings.push({
+ severity: "medium",
+ code: "CROSS_REFERENCE_TOUCH",
+ title: "Replacement touches cross-reference anchors",
+ evidence: `${identity} contains figure, table, or equation anchors.`,
+ action: "Regenerate cross-reference maps and hold export until anchors resolve.",
+ });
+ }
+
+ return findings;
+}
+
+function replacementPreview(document, operation) {
+ const matcher = buildMatcher(operation);
+ const scope = new Set(operation.scopeSections || []);
+ const changedBlocks = [];
+ const findings = [];
+
+ document.sections.forEach((section) => {
+ if (scope.size && !scope.has(section.id)) {
+ return;
+ }
+ section.blocks.forEach((block) => {
+ const text = blockText(block);
+ const matches = countMatches(text, matcher);
+ if (!matches) {
+ return;
+ }
+ const before = text;
+ const after = text.replace(matcher, operation.replace);
+ changedBlocks.push({
+ sectionId: section.id,
+ blockId: block.id,
+ type: block.type,
+ matches,
+ beforeDigest: digest(before),
+ afterDigest: digest(after),
+ });
+ findings.push(...classifyBlockRisk(block, section, operation, matcher));
+ });
+ });
+
+ (document.comments || []).forEach((comment) => {
+ const changed = changedBlocks.some((block) => block.blockId === comment.blockId);
+ if (changed && comment.quote && countMatches(comment.quote, matcher)) {
+ findings.push({
+ severity: "medium",
+ code: "COMMENT_ANCHOR_DRIFT",
+ title: "Replacement would invalidate an inline comment quote anchor",
+ evidence: `Comment ${comment.id} anchors to text that would be replaced in block ${comment.blockId}.`,
+ action: "Convert the edit to a suggestion and re-anchor or resolve the comment before applying.",
+ });
+ }
+ });
+
+ if (operation.mode === "wysiwyg" && findings.some((finding) => /LATEX|CODE|CITATION/.test(finding.code))) {
+ findings.push({
+ severity: "medium",
+ code: "WYSIWYG_SCOPE_MISMATCH",
+ title: "WYSIWYG replacement crosses source-only content",
+ evidence: "The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks.",
+ action: "Split the operation by block type so source-mode changes receive explicit review.",
+ });
+ }
+
+ const riskScore = Math.min(100, findings.reduce((sum, finding) => sum + SEVERITY_WEIGHT[finding.severity], 0));
+ const blockers = findings.filter((finding) => finding.severity === "blocker").length;
+ const high = findings.filter((finding) => finding.severity === "high").length;
+ const decision = blockers || high ? "hold-batch-edit" : findings.length ? "suggestion-preview-required" : "safe-to-apply";
+ const packet = {
+ documentId: document.id,
+ operation: {
+ find: operation.find,
+ replace: operation.replace,
+ mode: operation.mode,
+ wholeWord: Boolean(operation.wholeWord),
+ scopeSections: operation.scopeSections || [],
+ },
+ decision,
+ riskScore,
+ changedBlocks,
+ findings,
+ actions: findings.map((finding) => ({ code: finding.code, action: finding.action })),
+ generatedFrom: "synthetic-data-only",
+ };
+
+ return {
+ ...packet,
+ auditDigest: digest(packet),
+ };
+}
+
+function renderMarkdownReport(result) {
+ const lines = [
+ `# Collaborative Find/Replace Safety Review`,
+ "",
+ `Decision: ${result.decision}`,
+ `Risk score: ${result.riskScore}`,
+ `Changed blocks: ${result.changedBlocks.length}`,
+ `Audit digest: ${result.auditDigest}`,
+ "",
+ "## Findings",
+ ];
+ if (!result.findings.length) {
+ lines.push("- No blocking find/replace risks.");
+ } else {
+ result.findings.forEach((finding) => {
+ lines.push(`- [${finding.severity}] ${finding.title}: ${finding.evidence}`);
+ lines.push(` Action: ${finding.action}`);
+ });
+ }
+ lines.push("", "## Safety", "- Synthetic document packet only; no live editor or private manuscript data.");
+ return `${lines.join("\n")}\n`;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function renderSvgSummary(result) {
+ const color = result.decision === "hold-batch-edit" ? "#b42318" : result.decision === "safe-to-apply" ? "#067647" : "#b54708";
+ const rows = result.findings
+ .slice(0, 5)
+ .map((finding, index) => {
+ const y = 215 + index * 54;
+ return `${escapeXml(finding.severity.toUpperCase())}: ${escapeXml(finding.title).slice(0, 72)}
+${escapeXml(finding.code)}`;
+ })
+ .join("\n");
+ return ``;
+}
+
+module.exports = {
+ replacementPreview,
+ renderMarkdownReport,
+ renderSvgSummary,
+ stableStringify,
+ digest,
+};
diff --git a/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js b/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js
new file mode 100644
index 00000000..89244b77
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/make-demo-video.js
@@ -0,0 +1,157 @@
+const fs = require("fs");
+const path = require("path");
+const { execFileSync } = require("child_process");
+const { replacementPreview } = require("./index");
+const { riskyDocument, riskyOperation } = require("./sample-data");
+
+const WIDTH = 1280;
+const HEIGHT = 720;
+const outputDir = path.join(__dirname, "reports");
+const frameDir = path.join(outputDir, "ppm-frames");
+fs.mkdirSync(frameDir, { recursive: true });
+
+const GLYPHS = {
+ A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
+ B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
+ C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
+ D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
+ E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
+ F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
+ G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
+ H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
+ I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
+ J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
+ K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
+ L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
+ M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
+ N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
+ O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
+ P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
+ Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
+ R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
+ S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
+ T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
+ U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
+ V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
+ W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
+ X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
+ Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
+ Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
+ "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
+ "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
+ "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
+ "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
+ "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
+ "5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
+ "6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
+ "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
+ "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
+ "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
+ "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
+ "#": ["01010", "11111", "01010", "01010", "11111", "01010", "01010"],
+ " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
+};
+
+function rgb(hex) {
+ const clean = hex.replace("#", "");
+ return [Number.parseInt(clean.slice(0, 2), 16), Number.parseInt(clean.slice(2, 4), 16), Number.parseInt(clean.slice(4, 6), 16)];
+}
+
+function canvas(color) {
+ const data = Buffer.alloc(WIDTH * HEIGHT * 3);
+ const [r, g, b] = rgb(color);
+ for (let offset = 0; offset < data.length; offset += 3) {
+ data[offset] = r;
+ data[offset + 1] = g;
+ data[offset + 2] = b;
+ }
+ return data;
+}
+
+function rect(data, x, y, width, height, color) {
+ const [r, g, b] = rgb(color);
+ for (let row = Math.max(0, y); row < Math.min(HEIGHT, y + height); row += 1) {
+ for (let col = Math.max(0, x); col < Math.min(WIDTH, x + width); col += 1) {
+ const offset = (row * WIDTH + col) * 3;
+ data[offset] = r;
+ data[offset + 1] = g;
+ data[offset + 2] = b;
+ }
+ }
+}
+
+function text(data, value, x, y, scale, color) {
+ const [r, g, b] = rgb(color);
+ let cursor = x;
+ String(value).toUpperCase().split("").forEach((char) => {
+ const glyph = GLYPHS[char] || GLYPHS[" "];
+ glyph.forEach((line, gy) => {
+ line.split("").forEach((pixel, gx) => {
+ if (pixel !== "1") return;
+ for (let sy = 0; sy < scale; sy += 1) {
+ for (let sx = 0; sx < scale; sx += 1) {
+ const px = cursor + gx * scale + sx;
+ const py = y + gy * scale + sy;
+ if (px < 0 || px >= WIDTH || py < 0 || py >= HEIGHT) continue;
+ const offset = (py * WIDTH + px) * 3;
+ data[offset] = r;
+ data[offset + 1] = g;
+ data[offset + 2] = b;
+ }
+ }
+ });
+ });
+ cursor += 6 * scale;
+ });
+}
+
+function writePpm(filePath, data) {
+ fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`), data]));
+}
+
+function renderFrame(filePath, subtitle, lines, result) {
+ const data = canvas("#f7f8fa");
+ rect(data, 36, 36, 1208, 648, "#ffffff");
+ rect(data, 36, 36, 1208, 3, "#d0d5dd");
+ rect(data, 36, 681, 1208, 3, "#d0d5dd");
+ rect(data, 36, 36, 3, 648, "#d0d5dd");
+ rect(data, 1241, 36, 3, 648, "#d0d5dd");
+ text(data, "COLLAB FIND REPLACE SAFETY GUARD", 56, 74, 5, "#101828");
+ text(data, subtitle, 56, 126, 3, "#475467");
+ rect(data, 56, 166, 390, 54, "#b42318");
+ text(data, "HOLD BATCH EDIT", 76, 184, 4, "#ffffff");
+ text(data, `RISK SCORE ${result.riskScore}`, 476, 184, 4, "#101828");
+ lines.forEach((line, index) => text(data, line, 56, 260 + index * 62, 4, "#111827"));
+ text(data, "JSON MARKDOWN SVG MP4 REVIEWER ARTIFACTS", 56, 592, 3, "#344054");
+ text(data, `AUDIT DIGEST ${result.auditDigest.toUpperCase()}`, 56, 638, 3, "#667085");
+ writePpm(filePath, data);
+}
+
+const result = replacementPreview(riskyDocument, riskyOperation);
+const frames = [
+ ["SCIBASE ISSUE #12 BATCH EDIT PREVIEW", ["BLOCKER LOCKED SECTION MATCH", "HIGH CITATION KEY MUTATION", "HIGH LATEX COMMAND MUTATION"]],
+ ["SOURCE AND WYSIWYG SCOPE PROTECTION", ["HIGH CODE CELL MUTATION", "MEDIUM WYSIWYG SCOPE MISMATCH", "MEDIUM CROSS REFERENCE TOUCH"]],
+ ["COMMENT ANCHORS STAY REVIEWABLE", ["MEDIUM COMMENT ANCHOR DRIFT", "ACTION CONVERT TO SUGGESTION", "NO LIVE EDITOR OR PRIVATE DATA"]],
+ ["DETERMINISTIC REVIEW PACKET", ["SYNTHETIC DOCUMENT ONLY", "PREVIEW BEFORE MUTATION", "READY FOR MAINTAINER REVIEW"]],
+];
+
+frames.forEach(([subtitle, lines], index) => {
+ renderFrame(path.join(frameDir, `frame-${String(index + 1).padStart(3, "0")}.ppm`), subtitle, lines, result);
+});
+
+const outputPath = path.join(outputDir, "demo.mp4");
+execFileSync("ffmpeg", [
+ "-y",
+ "-framerate",
+ "1",
+ "-i",
+ path.join(frameDir, "frame-%03d.ppm"),
+ "-vf",
+ "fps=12,format=yuv420p",
+ "-movflags",
+ "+faststart",
+ outputPath,
+], { stdio: "inherit" });
+
+fs.rmSync(frameDir, { recursive: true, force: true });
+console.log(`demo video=${outputPath}`);
diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4 b/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4
new file mode 100644
index 00000000..dcd94d59
Binary files /dev/null and b/collaborative-research-editor/find-replace-safety-guard/reports/demo.mp4 differ
diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json
new file mode 100644
index 00000000..77d8051c
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.json
@@ -0,0 +1,141 @@
+{
+ "documentId": "collab-doc-12-find-replace",
+ "operation": {
+ "find": "alpha",
+ "replace": "omega",
+ "mode": "wysiwyg",
+ "wholeWord": false,
+ "scopeSections": [
+ "methods",
+ "references",
+ "final-figures"
+ ]
+ },
+ "decision": "hold-batch-edit",
+ "riskScore": 100,
+ "changedBlocks": [
+ {
+ "sectionId": "methods",
+ "blockId": "methods-p1",
+ "type": "paragraph",
+ "matches": 2,
+ "beforeDigest": "a7c02b6adacc531c",
+ "afterDigest": "b604f6421a609783"
+ },
+ {
+ "sectionId": "methods",
+ "blockId": "methods-eq1",
+ "type": "latex",
+ "matches": 2,
+ "beforeDigest": "f32017986cd1ba0f",
+ "afterDigest": "89c44468bc1034ed"
+ },
+ {
+ "sectionId": "methods",
+ "blockId": "methods-code1",
+ "type": "notebook",
+ "matches": 1,
+ "beforeDigest": "8391629e63709c74",
+ "afterDigest": "c8645a8d6004fe0e"
+ },
+ {
+ "sectionId": "references",
+ "blockId": "ref-key",
+ "type": "citation",
+ "matches": 2,
+ "beforeDigest": "473fe626bbfe0c64",
+ "afterDigest": "94126d50ed228c79"
+ },
+ {
+ "sectionId": "final-figures",
+ "blockId": "fig1-caption",
+ "type": "cross-reference",
+ "matches": 2,
+ "beforeDigest": "0853df185fc30260",
+ "afterDigest": "40b70db10eaf56eb"
+ }
+ ],
+ "findings": [
+ {
+ "severity": "high",
+ "code": "LATEX_COMMAND_MUTATION",
+ "title": "Replacement would alter LaTeX command or equation source",
+ "evidence": "methods/methods-eq1 is a LaTeX block with 2 match(es).",
+ "action": "Preview equation output and require formula-owner approval before applying source-mode replacements."
+ },
+ {
+ "severity": "high",
+ "code": "CODE_CELL_MUTATION",
+ "title": "Replacement would mutate notebook or code cell source",
+ "evidence": "methods/methods-code1 is notebook source with 1 match(es).",
+ "action": "Create a code-review task and rerun the notebook before sharing updated outputs."
+ },
+ {
+ "severity": "high",
+ "code": "CITATION_KEY_MUTATION",
+ "title": "Replacement would alter citation keys or reference anchors",
+ "evidence": "references/ref-key contains citation/reference text matching \"alpha\".",
+ "action": "Route citation-key changes through the reference manager merge workflow instead of direct replacement."
+ },
+ {
+ "severity": "blocker",
+ "code": "LOCKED_SECTION_MATCH",
+ "title": "Batch replacement touches a locked or final-review section",
+ "evidence": "final-figures/fig1-caption has 2 match(es) while section state is locked.",
+ "action": "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions."
+ },
+ {
+ "severity": "medium",
+ "code": "CROSS_REFERENCE_TOUCH",
+ "title": "Replacement touches cross-reference anchors",
+ "evidence": "final-figures/fig1-caption contains figure, table, or equation anchors.",
+ "action": "Regenerate cross-reference maps and hold export until anchors resolve."
+ },
+ {
+ "severity": "medium",
+ "code": "COMMENT_ANCHOR_DRIFT",
+ "title": "Replacement would invalidate an inline comment quote anchor",
+ "evidence": "Comment cmt-17 anchors to text that would be replaced in block methods-p1.",
+ "action": "Convert the edit to a suggestion and re-anchor or resolve the comment before applying."
+ },
+ {
+ "severity": "medium",
+ "code": "WYSIWYG_SCOPE_MISMATCH",
+ "title": "WYSIWYG replacement crosses source-only content",
+ "evidence": "The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks.",
+ "action": "Split the operation by block type so source-mode changes receive explicit review."
+ }
+ ],
+ "actions": [
+ {
+ "code": "LATEX_COMMAND_MUTATION",
+ "action": "Preview equation output and require formula-owner approval before applying source-mode replacements."
+ },
+ {
+ "code": "CODE_CELL_MUTATION",
+ "action": "Create a code-review task and rerun the notebook before sharing updated outputs."
+ },
+ {
+ "code": "CITATION_KEY_MUTATION",
+ "action": "Route citation-key changes through the reference manager merge workflow instead of direct replacement."
+ },
+ {
+ "code": "LOCKED_SECTION_MATCH",
+ "action": "Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions."
+ },
+ {
+ "code": "CROSS_REFERENCE_TOUCH",
+ "action": "Regenerate cross-reference maps and hold export until anchors resolve."
+ },
+ {
+ "code": "COMMENT_ANCHOR_DRIFT",
+ "action": "Convert the edit to a suggestion and re-anchor or resolve the comment before applying."
+ },
+ {
+ "code": "WYSIWYG_SCOPE_MISMATCH",
+ "action": "Split the operation by block type so source-mode changes receive explicit review."
+ }
+ ],
+ "generatedFrom": "synthetic-data-only",
+ "auditDigest": "855bf874ddecf9aa"
+}
diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md
new file mode 100644
index 00000000..044961f1
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-review.md
@@ -0,0 +1,25 @@
+# Collaborative Find/Replace Safety Review
+
+Decision: hold-batch-edit
+Risk score: 100
+Changed blocks: 5
+Audit digest: 855bf874ddecf9aa
+
+## Findings
+- [high] Replacement would alter LaTeX command or equation source: methods/methods-eq1 is a LaTeX block with 2 match(es).
+ Action: Preview equation output and require formula-owner approval before applying source-mode replacements.
+- [high] Replacement would mutate notebook or code cell source: methods/methods-code1 is notebook source with 1 match(es).
+ Action: Create a code-review task and rerun the notebook before sharing updated outputs.
+- [high] Replacement would alter citation keys or reference anchors: references/ref-key contains citation/reference text matching "alpha".
+ Action: Route citation-key changes through the reference manager merge workflow instead of direct replacement.
+- [blocker] Batch replacement touches a locked or final-review section: final-figures/fig1-caption has 2 match(es) while section state is locked.
+ Action: Exclude the section, unlock with an audit reason, or convert matches to explicit suggestions.
+- [medium] Replacement touches cross-reference anchors: final-figures/fig1-caption contains figure, table, or equation anchors.
+ Action: Regenerate cross-reference maps and hold export until anchors resolve.
+- [medium] Replacement would invalidate an inline comment quote anchor: Comment cmt-17 anchors to text that would be replaced in block methods-p1.
+ Action: Convert the edit to a suggestion and re-anchor or resolve the comment before applying.
+- [medium] WYSIWYG replacement crosses source-only content: The operation starts in WYSIWYG mode but reaches LaTeX, citation, or code source blocks.
+ Action: Split the operation by block type so source-mode changes receive explicit review.
+
+## Safety
+- Synthetic document packet only; no live editor or private manuscript data.
diff --git a/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg
new file mode 100644
index 00000000..8e6b6b4f
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/reports/find-replace-summary.svg
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/collaborative-research-editor/find-replace-safety-guard/sample-data.js b/collaborative-research-editor/find-replace-safety-guard/sample-data.js
new file mode 100644
index 00000000..48197fad
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/sample-data.js
@@ -0,0 +1,102 @@
+const riskyDocument = {
+ id: "collab-doc-12-find-replace",
+ sections: [
+ {
+ id: "methods",
+ locked: false,
+ freeze: "draft",
+ blocks: [
+ {
+ id: "methods-p1",
+ type: "paragraph",
+ content: "The alpha assay was selected for the pilot manuscript because alpha response is reproducible.",
+ },
+ {
+ id: "methods-eq1",
+ type: "latex",
+ content: "\\alpha = 0.05; \\frac{signal_{alpha}}{baseline}",
+ },
+ {
+ id: "methods-code1",
+ type: "notebook",
+ content: "model <- lm(response ~ alpha + baseline, data = notebook_frame)",
+ },
+ ],
+ },
+ {
+ id: "references",
+ locked: false,
+ freeze: "draft",
+ blocks: [
+ {
+ id: "ref-key",
+ type: "citation",
+ key: "@alpha2024",
+ content: "Alpha response benchmark, DOI 10.0000/example.",
+ },
+ ],
+ },
+ {
+ id: "final-figures",
+ locked: true,
+ freeze: "final-review",
+ blocks: [
+ {
+ id: "fig1-caption",
+ type: "cross-reference",
+ anchor: "fig:alpha-response",
+ caption: "Figure 1. Alpha response across cohorts.",
+ },
+ ],
+ },
+ ],
+ comments: [
+ {
+ id: "cmt-17",
+ blockId: "methods-p1",
+ quote: "alpha response is reproducible",
+ },
+ ],
+};
+
+const safeDocument = {
+ id: "collab-doc-12-safe-preview",
+ sections: [
+ {
+ id: "intro",
+ locked: false,
+ freeze: "draft",
+ blocks: [
+ {
+ id: "intro-p1",
+ type: "paragraph",
+ content: "This pilot manuscript uses an exploratory workflow for reader feedback.",
+ },
+ ],
+ },
+ ],
+ comments: [],
+};
+
+const riskyOperation = {
+ find: "alpha",
+ replace: "omega",
+ mode: "wysiwyg",
+ wholeWord: false,
+ scopeSections: ["methods", "references", "final-figures"],
+};
+
+const safeOperation = {
+ find: "pilot",
+ replace: "feasibility",
+ mode: "wysiwyg",
+ wholeWord: true,
+ scopeSections: ["intro"],
+};
+
+module.exports = {
+ riskyDocument,
+ safeDocument,
+ riskyOperation,
+ safeOperation,
+};
diff --git a/collaborative-research-editor/find-replace-safety-guard/test.js b/collaborative-research-editor/find-replace-safety-guard/test.js
new file mode 100644
index 00000000..e192270e
--- /dev/null
+++ b/collaborative-research-editor/find-replace-safety-guard/test.js
@@ -0,0 +1,33 @@
+const assert = require("assert");
+const { replacementPreview, renderMarkdownReport, renderSvgSummary, digest } = require("./index");
+const { riskyDocument, safeDocument, riskyOperation, safeOperation } = require("./sample-data");
+
+const risky = replacementPreview(riskyDocument, riskyOperation);
+assert.strictEqual(risky.decision, "hold-batch-edit");
+assert.ok(risky.riskScore >= 90, "risky batch edit should produce a strong hold score");
+assert.ok(risky.findings.some((finding) => finding.code === "LOCKED_SECTION_MATCH"));
+assert.ok(risky.findings.some((finding) => finding.code === "LATEX_COMMAND_MUTATION"));
+assert.ok(risky.findings.some((finding) => finding.code === "CODE_CELL_MUTATION"));
+assert.ok(risky.findings.some((finding) => finding.code === "CITATION_KEY_MUTATION"));
+assert.ok(risky.findings.some((finding) => finding.code === "COMMENT_ANCHOR_DRIFT"));
+assert.ok(risky.findings.some((finding) => finding.code === "WYSIWYG_SCOPE_MISMATCH"));
+
+const repeat = replacementPreview(riskyDocument, riskyOperation);
+assert.strictEqual(risky.auditDigest, repeat.auditDigest, "audit digest must be deterministic");
+
+const safe = replacementPreview(safeDocument, safeOperation);
+assert.strictEqual(safe.decision, "safe-to-apply");
+assert.strictEqual(safe.findings.length, 0);
+assert.strictEqual(safe.changedBlocks.length, 1);
+
+const markdown = renderMarkdownReport(risky);
+assert.ok(markdown.includes("Collaborative Find/Replace Safety Review"));
+assert.ok(markdown.includes("Synthetic document packet only"));
+
+const svg = renderSvgSummary(risky);
+assert.ok(svg.includes("