diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/README.md b/real-time-collaborative-editor/comment-anchor-drift-gate/README.md
new file mode 100644
index 00000000..72f99c65
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/README.md
@@ -0,0 +1,37 @@
+# Comment Anchor Drift Gate
+
+This slice supports issue #12, Real-time collaborative research editor &
+interface. It reviews inline comments and annotations after document edits so
+collaborative reviewers do not leave comments attached to the wrong sentence,
+cell output, figure, table, or protocol step.
+
+The module uses synthetic edit packets only. It does not connect to a live
+editor, read private documents, or store collaborator identities.
+
+## Checks
+
+- Anchors whose quoted text no longer exists are blocked unless a new exact
+ anchor is supplied.
+- Anchors that moved outside the expected section are blocked.
+- Anchors attached to stale notebook output are blocked when execution evidence
+ changed.
+- Large anchor movement requires reviewer confirmation.
+- Clean anchors produce deterministic reviewer receipts.
+
+## Local Verification
+
+```bash
+node real-time-collaborative-editor/comment-anchor-drift-gate/test.js
+node real-time-collaborative-editor/comment-anchor-drift-gate/demo.js
+git diff --check
+```
+
+The demo writes deterministic artifacts under
+`real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/`.
+
+## Files
+
+- `gate.js` - anchor drift checks and reviewer receipt generation.
+- `data/anchor-packets.json` - synthetic collaborative edit packets.
+- `test.js` - Node built-in tests.
+- `demo.js` - deterministic JSON, Markdown, and SVG artifact generation.
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg
new file mode 100644
index 00000000..39958762
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-flow.svg
@@ -0,0 +1,17 @@
+
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json
new file mode 100644
index 00000000..48091461
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.json
@@ -0,0 +1,108 @@
+{
+ "summary": {
+ "total": 5,
+ "ready": 2,
+ "blocked": 3,
+ "findings": 4
+ },
+ "reports": [
+ {
+ "documentId": "doc-clean-methods",
+ "revisionId": "rev-001",
+ "decision": "ready",
+ "findings": [],
+ "receipt": {
+ "commentsReviewed": 1,
+ "blockedFindings": 0,
+ "heldFindings": 0,
+ "nextActions": []
+ }
+ },
+ {
+ "documentId": "doc-missing-anchor",
+ "revisionId": "rev-002",
+ "decision": "block",
+ "findings": [
+ {
+ "code": "missing_anchor_quote",
+ "severity": "block",
+ "target": "comment-2",
+ "message": "comment-2 quoted text no longer exists and no replacement anchor was provided.",
+ "action": "Re-anchor the comment to exact current text or mark it resolved."
+ }
+ ],
+ "receipt": {
+ "commentsReviewed": 1,
+ "blockedFindings": 1,
+ "heldFindings": 0,
+ "nextActions": [
+ "Re-anchor the comment to exact current text or mark it resolved."
+ ]
+ }
+ },
+ {
+ "documentId": "doc-section-drift",
+ "revisionId": "rev-003",
+ "decision": "block",
+ "findings": [
+ {
+ "code": "section_drift",
+ "severity": "block",
+ "target": "comment-3",
+ "message": "comment-3 moved from Limitations to Discussion.",
+ "action": "Confirm the comment still applies or re-anchor it in the expected section."
+ },
+ {
+ "code": "large_anchor_movement",
+ "severity": "hold",
+ "target": "comment-3",
+ "message": "comment-3 moved 220 characters, above the trusted movement threshold.",
+ "action": "Ask the reviewer to confirm or manually re-anchor the comment."
+ }
+ ],
+ "receipt": {
+ "commentsReviewed": 1,
+ "blockedFindings": 1,
+ "heldFindings": 1,
+ "nextActions": [
+ "Confirm the comment still applies or re-anchor it in the expected section.",
+ "Ask the reviewer to confirm or manually re-anchor the comment."
+ ]
+ }
+ },
+ {
+ "documentId": "doc-notebook-output",
+ "revisionId": "rev-004",
+ "decision": "block",
+ "findings": [
+ {
+ "code": "stale_notebook_output_anchor",
+ "severity": "block",
+ "target": "comment-4",
+ "message": "comment-4 targets notebook output whose execution record changed.",
+ "action": "Re-run output review and confirm the comment still applies to the new output."
+ }
+ ],
+ "receipt": {
+ "commentsReviewed": 1,
+ "blockedFindings": 1,
+ "heldFindings": 0,
+ "nextActions": [
+ "Re-run output review and confirm the comment still applies to the new output."
+ ]
+ }
+ },
+ {
+ "documentId": "doc-manual-confirmation",
+ "revisionId": "rev-005",
+ "decision": "ready",
+ "findings": [],
+ "receipt": {
+ "commentsReviewed": 1,
+ "blockedFindings": 0,
+ "heldFindings": 0,
+ "nextActions": []
+ }
+ }
+ ]
+}
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md
new file mode 100644
index 00000000..ddc93293
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/artifacts/comment-anchor-drift-report.md
@@ -0,0 +1,45 @@
+# Comment Anchor Drift Gate
+
+## Summary
+
+- Anchor packets reviewed: 5
+- Ready: 2
+- Blocked: 3
+- Findings: 4
+
+## Reports
+
+### doc-clean-methods
+
+- Revision: rev-001
+- Decision: ready
+- Comments reviewed: 1
+
+### doc-missing-anchor
+
+- Revision: rev-002
+- Decision: block
+- Comments reviewed: 1
+ - block: missing_anchor_quote - comment-2 quoted text no longer exists and no replacement anchor was provided.
+
+### doc-section-drift
+
+- Revision: rev-003
+- Decision: block
+- Comments reviewed: 1
+ - block: section_drift - comment-3 moved from Limitations to Discussion.
+ - hold: large_anchor_movement - comment-3 moved 220 characters, above the trusted movement threshold.
+
+### doc-notebook-output
+
+- Revision: rev-004
+- Decision: block
+- Comments reviewed: 1
+ - block: stale_notebook_output_anchor - comment-4 targets notebook output whose execution record changed.
+
+### doc-manual-confirmation
+
+- Revision: rev-005
+- Decision: ready
+- Comments reviewed: 1
+
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json b/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json
new file mode 100644
index 00000000..a2745087
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/data/anchor-packets.json
@@ -0,0 +1,103 @@
+[
+ {
+ "documentId": "doc-clean-methods",
+ "revisionId": "rev-001",
+ "policy": { "maxTrustedOffsetDelta": 120 },
+ "sections": [
+ { "id": "methods", "label": "Methods", "text": "Cells were seeded at 20,000 cells per well before treatment." }
+ ],
+ "comments": [
+ {
+ "id": "comment-1",
+ "anchorType": "text",
+ "expectedSectionId": "methods",
+ "currentSectionId": "methods",
+ "quotedText": "20,000 cells per well",
+ "offsetDelta": 24,
+ "executionRecordChanged": false,
+ "reviewerConfirmed": false
+ }
+ ]
+ },
+ {
+ "documentId": "doc-missing-anchor",
+ "revisionId": "rev-002",
+ "policy": { "maxTrustedOffsetDelta": 120 },
+ "sections": [
+ { "id": "results", "label": "Results", "text": "The revised figure reports normalized response values." }
+ ],
+ "comments": [
+ {
+ "id": "comment-2",
+ "anchorType": "text",
+ "expectedSectionId": "results",
+ "currentSectionId": "results",
+ "quotedText": "raw fluorescence increased threefold",
+ "offsetDelta": 18,
+ "executionRecordChanged": false,
+ "reviewerConfirmed": false
+ }
+ ]
+ },
+ {
+ "documentId": "doc-section-drift",
+ "revisionId": "rev-003",
+ "policy": { "maxTrustedOffsetDelta": 120 },
+ "sections": [
+ { "id": "discussion", "label": "Discussion", "text": "The limitation now appears in discussion text." },
+ { "id": "limitations", "label": "Limitations", "text": "Sample size limitations remain important." }
+ ],
+ "comments": [
+ {
+ "id": "comment-3",
+ "anchorType": "text",
+ "expectedSectionId": "limitations",
+ "currentSectionId": "discussion",
+ "quotedText": "limitation",
+ "offsetDelta": 220,
+ "executionRecordChanged": false,
+ "reviewerConfirmed": false
+ }
+ ]
+ },
+ {
+ "documentId": "doc-notebook-output",
+ "revisionId": "rev-004",
+ "policy": { "maxTrustedOffsetDelta": 120 },
+ "sections": [
+ { "id": "notebook-cell-7", "label": "Notebook cell 7", "text": "Mean response table output: 1.8, 2.1, 2.4." }
+ ],
+ "comments": [
+ {
+ "id": "comment-4",
+ "anchorType": "notebook-output",
+ "expectedSectionId": "notebook-cell-7",
+ "currentSectionId": "notebook-cell-7",
+ "quotedText": "Mean response table output",
+ "offsetDelta": 42,
+ "executionRecordChanged": true,
+ "reviewerConfirmed": false
+ }
+ ]
+ },
+ {
+ "documentId": "doc-manual-confirmation",
+ "revisionId": "rev-005",
+ "policy": { "maxTrustedOffsetDelta": 120 },
+ "sections": [
+ { "id": "protocol", "label": "Protocol", "text": "The revised protocol includes the centrifuge speed and duration." }
+ ],
+ "comments": [
+ {
+ "id": "comment-5",
+ "anchorType": "text",
+ "expectedSectionId": "protocol",
+ "currentSectionId": "protocol",
+ "quotedText": "centrifuge speed and duration",
+ "offsetDelta": 360,
+ "executionRecordChanged": false,
+ "reviewerConfirmed": true
+ }
+ ]
+ }
+]
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js b/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js
new file mode 100644
index 00000000..8dbd4c99
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/demo.js
@@ -0,0 +1,69 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const packets = require("./data/anchor-packets.json");
+const { reviewAnchorPackets, summarizeReports } = require("./gate");
+
+const artifactDir = path.join(__dirname, "artifacts");
+const reports = reviewAnchorPackets(packets);
+const summary = summarizeReports(reports);
+
+fs.mkdirSync(artifactDir, { recursive: true });
+fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-report.json"), `${JSON.stringify({ summary, reports }, null, 2)}\n`);
+fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-report.md"), renderMarkdown(summary, reports));
+fs.writeFileSync(path.join(artifactDir, "comment-anchor-drift-flow.svg"), renderSvg(summary));
+
+console.log(`reviewed ${summary.total} anchor packets`);
+console.log(`ready=${summary.ready} blocked=${summary.blocked} findings=${summary.findings}`);
+
+function renderMarkdown(reportSummary, reportRows) {
+ const lines = [
+ "# Comment Anchor Drift Gate",
+ "",
+ "## Summary",
+ "",
+ `- Anchor packets reviewed: ${reportSummary.total}`,
+ `- Ready: ${reportSummary.ready}`,
+ `- Blocked: ${reportSummary.blocked}`,
+ `- Findings: ${reportSummary.findings}`,
+ "",
+ "## Reports",
+ "",
+ ];
+
+ for (const report of reportRows) {
+ lines.push(`### ${report.documentId}`);
+ lines.push("");
+ lines.push(`- Revision: ${report.revisionId}`);
+ lines.push(`- Decision: ${report.decision}`);
+ lines.push(`- Comments reviewed: ${report.receipt.commentsReviewed}`);
+ for (const finding of report.findings) {
+ lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`);
+ }
+ lines.push("");
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvg(reportSummary) {
+ return `
+`;
+}
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js b/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js
new file mode 100644
index 00000000..33853bb2
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/gate.js
@@ -0,0 +1,130 @@
+"use strict";
+
+function reviewAnchorPackets(packets) {
+ return packets.map(reviewAnchorPacket);
+}
+
+function reviewAnchorPacket(packet) {
+ validatePacket(packet);
+
+ const findings = packet.comments.flatMap((comment) => reviewComment(packet, comment));
+
+ return {
+ documentId: packet.documentId,
+ revisionId: packet.revisionId,
+ decision: findings.some((item) => item.severity === "block") ? "block" : "ready",
+ findings,
+ receipt: buildReceipt(packet, findings),
+ };
+}
+
+function validatePacket(packet) {
+ if (!packet.documentId || !packet.revisionId) {
+ throw new Error("Anchor packet must include documentId and revisionId.");
+ }
+ if (!Array.isArray(packet.comments)) {
+ throw new Error(`${packet.documentId} must include comments.`);
+ }
+}
+
+function reviewComment(packet, comment) {
+ const findings = [];
+ const currentSection = packet.sections.find((section) => section.id === comment.currentSectionId);
+ const expectedSection = packet.sections.find((section) => section.id === comment.expectedSectionId);
+ const currentText = currentSection?.text ?? "";
+ const quotePresent = currentText.includes(comment.quotedText);
+
+ if (!quotePresent && !comment.reanchor?.quotedText) {
+ findings.push(
+ finding(
+ "missing_anchor_quote",
+ "block",
+ comment.id,
+ `${comment.id} quoted text no longer exists and no replacement anchor was provided.`,
+ "Re-anchor the comment to exact current text or mark it resolved.",
+ ),
+ );
+ }
+
+ if (comment.currentSectionId !== comment.expectedSectionId) {
+ findings.push(
+ finding(
+ "section_drift",
+ "block",
+ comment.id,
+ `${comment.id} moved from ${expectedSection?.label ?? comment.expectedSectionId} to ${currentSection?.label ?? comment.currentSectionId}.`,
+ "Confirm the comment still applies or re-anchor it in the expected section.",
+ ),
+ );
+ }
+
+ if (comment.anchorType === "notebook-output" && comment.executionRecordChanged && !comment.reviewerConfirmed) {
+ findings.push(
+ finding(
+ "stale_notebook_output_anchor",
+ "block",
+ comment.id,
+ `${comment.id} targets notebook output whose execution record changed.`,
+ "Re-run output review and confirm the comment still applies to the new output.",
+ ),
+ );
+ }
+
+ if (Math.abs(comment.offsetDelta ?? 0) > packet.policy.maxTrustedOffsetDelta && !comment.reviewerConfirmed) {
+ findings.push(
+ finding(
+ "large_anchor_movement",
+ "hold",
+ comment.id,
+ `${comment.id} moved ${comment.offsetDelta} characters, above the trusted movement threshold.`,
+ "Ask the reviewer to confirm or manually re-anchor the comment.",
+ ),
+ );
+ }
+
+ if (comment.reanchor?.quotedText && !currentText.includes(comment.reanchor.quotedText)) {
+ findings.push(
+ finding(
+ "invalid_reanchor_quote",
+ "block",
+ comment.id,
+ `${comment.id} supplied a replacement anchor that does not exist in the current section.`,
+ "Use an exact quote from the current document revision.",
+ ),
+ );
+ }
+
+ return findings;
+}
+
+function finding(code, severity, target, message, action) {
+ return { code, severity, target, message, action };
+}
+
+function buildReceipt(packet, findings) {
+ return {
+ commentsReviewed: packet.comments.length,
+ blockedFindings: findings.filter((item) => item.severity === "block").length,
+ heldFindings: findings.filter((item) => item.severity === "hold").length,
+ nextActions: Array.from(new Set(findings.map((item) => item.action))),
+ };
+}
+
+function summarizeReports(reports) {
+ return reports.reduce(
+ (summary, report) => {
+ summary.total += 1;
+ summary.ready += report.decision === "ready" ? 1 : 0;
+ summary.blocked += report.decision === "block" ? 1 : 0;
+ summary.findings += report.findings.length;
+ return summary;
+ },
+ { total: 0, ready: 0, blocked: 0, findings: 0 },
+ );
+}
+
+module.exports = {
+ reviewAnchorPacket,
+ reviewAnchorPackets,
+ summarizeReports,
+};
diff --git a/real-time-collaborative-editor/comment-anchor-drift-gate/test.js b/real-time-collaborative-editor/comment-anchor-drift-gate/test.js
new file mode 100644
index 00000000..d0c1b5b3
--- /dev/null
+++ b/real-time-collaborative-editor/comment-anchor-drift-gate/test.js
@@ -0,0 +1,58 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const test = require("node:test");
+const packets = require("./data/anchor-packets.json");
+const { reviewAnchorPacket, reviewAnchorPackets, summarizeReports } = require("./gate");
+
+test("marks stable anchors as ready", () => {
+ const report = reviewAnchorPacket(packets[0]);
+
+ assert.equal(report.decision, "ready");
+ assert.deepEqual(report.findings, []);
+ assert.equal(report.receipt.commentsReviewed, 1);
+});
+
+test("blocks comments whose original quote disappeared", () => {
+ const report = reviewAnchorPacket(packets[1]);
+ const codes = report.findings.map((item) => item.code);
+
+ assert.equal(report.decision, "block");
+ assert.ok(codes.includes("missing_anchor_quote"));
+});
+
+test("blocks section drift and holds large unconfirmed movement", () => {
+ const report = reviewAnchorPacket(packets[2]);
+ const codes = report.findings.map((item) => item.code);
+
+ assert.equal(report.decision, "block");
+ assert.ok(codes.includes("section_drift"));
+ assert.ok(codes.includes("large_anchor_movement"));
+});
+
+test("blocks notebook output anchors after execution record changes", () => {
+ const report = reviewAnchorPacket(packets[3]);
+ const codes = report.findings.map((item) => item.code);
+
+ assert.equal(report.decision, "block");
+ assert.ok(codes.includes("stale_notebook_output_anchor"));
+});
+
+test("accepts large movement when reviewer confirmed the re-anchor", () => {
+ const report = reviewAnchorPacket(packets[4]);
+
+ assert.equal(report.decision, "ready");
+ assert.deepEqual(report.findings, []);
+});
+
+test("summarizes anchor readiness", () => {
+ const reports = reviewAnchorPackets(packets);
+ const summary = summarizeReports(reports);
+
+ assert.deepEqual(summary, {
+ total: 5,
+ ready: 2,
+ blocked: 3,
+ findings: 4,
+ });
+});