diff --git a/collaborative-reference-merge-guard/README.md b/collaborative-reference-merge-guard/README.md
new file mode 100644
index 00000000..df04753f
--- /dev/null
+++ b/collaborative-reference-merge-guard/README.md
@@ -0,0 +1,37 @@
+# Collaborative Reference Merge Guard
+
+This module adds a self-contained reference library merge guard for issue #12, Real-time collaborative research editor & interface.
+
+It validates concurrent Zotero, BibTeX, and EndNote-style library merges before a collaborative manuscript is exported. It detects DOI/PMID duplicate records, citation-key collisions, stale in-text citation anchors, locked-reference edits, incomplete source metadata, and unresolved merge conflicts.
+
+The module is dependency-free, uses only synthetic sample data, and performs no calls to Zotero, EndNote, Crossref, PubMed, browser sessions, credentials, or external services.
+
+## Files
+
+- `index.js`: merge evaluation logic and reviewer packet formatter.
+- `sample-data.js`: three synthetic collaborative editor scenarios.
+- `test.js`: Node test coverage for hold, review, and export-ready decisions.
+- `demo.js`: writes JSON, Markdown, SVG, and MP4 reviewer artifacts under `reports/`.
+- `requirements-map.md`: maps the implementation to issue #12 requirements.
+
+## Run
+
+```bash
+node --test collaborative-reference-merge-guard/test.js
+node collaborative-reference-merge-guard/demo.js
+```
+
+To generate `reports/demo.mp4`, set `FFMPEG_PATH` to a local ffmpeg binary before running `demo.js`.
+
+## Outputs
+
+- `reports/reference-merge-packet.json`
+- `reports/reference-merge-review.md`
+- `reports/summary.svg`
+- `reports/demo.mp4`
+
+## Decision Model
+
+- `hold-export`: blocking duplicate identifiers, citation-key collisions, stale anchors, or locked-reference edits are present.
+- `merge-review`: no blockers, but incomplete metadata or unresolved import conflicts need human review.
+- `export-ready`: merged references and manuscript citations are safe to sync and export.
diff --git a/collaborative-reference-merge-guard/demo.js b/collaborative-reference-merge-guard/demo.js
new file mode 100644
index 00000000..4dc5e4fe
--- /dev/null
+++ b/collaborative-reference-merge-guard/demo.js
@@ -0,0 +1,148 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluateReferenceLibraryMerge,
+ buildReferenceMergePacket,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateReferenceLibraryMerge(scenario),
+}));
+
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+
+const totals = evaluations.reduce(
+ (sum, item) => {
+ sum.findings += item.summary.findingCount;
+ sum.blocking += item.summary.blockingFindingCount;
+ sum.review += item.summary.reviewFindingCount;
+ sum.gated += item.gatedActions.length;
+ return sum;
+ },
+ {findings: 0, blocking: 0, review: 0, gated: 0}
+);
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerPacket = evaluations.map(buildReferenceMergePacket).join('\n---\n');
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'reference-merge-packet.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'reference-merge-review.md'), reviewerPacket);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const height = Math.floor(buffer.length / (width * 3));
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(height, y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 24;
+ buffer[i * 3 + 1] = 37;
+ buffer[i * 3 + 2] = 31;
+ }
+
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 42, 42, 556, 42, [248, 250, 252]);
+ fillRect(buffer, width, 42, 112, 150, 118, [153, 27, 27]);
+ fillRect(buffer, width, 245, 112, 150, 118, [161, 98, 7]);
+ fillRect(buffer, width, 448, 112, 150, 118, [4, 120, 87]);
+ fillRect(buffer, width, 78, 260, 112, 30, [248, 113, 113]);
+ fillRect(buffer, width, 264, 260, 112, 30, [251, 191, 36]);
+ fillRect(buffer, width, 450, 260, 112, 30, [52, 211, 153]);
+ fillRect(buffer, width, 42, 318, Math.round(556 * progress), 18, [187, 247, 208]);
+ fillRect(buffer, width, 42 + Math.round(508 * progress), 309, 48, 36, [240, 253, 244]);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed');
+ }
+}
+
+createDemoVideo();
+
+console.log(JSON.stringify({
+ scenarios: evaluations.length,
+ reportsDir,
+ decisions: decisionCounts,
+ totals,
+}, null, 2));
diff --git a/collaborative-reference-merge-guard/index.js b/collaborative-reference-merge-guard/index.js
new file mode 100644
index 00000000..f5fd954b
--- /dev/null
+++ b/collaborative-reference-merge-guard/index.js
@@ -0,0 +1,361 @@
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function clean(value) {
+ return String(value || '').trim();
+}
+
+function normalize(value) {
+ return clean(value).toLowerCase();
+}
+
+function normalizeDoi(value) {
+ return normalize(value)
+ .replace(/^https?:\/\/(dx\.)?doi\.org\//, '')
+ .replace(/^doi:/, '');
+}
+
+function normalizePmid(value) {
+ return normalize(value).replace(/^pmid:/, '');
+}
+
+function finding(type, severity, target, message) {
+ return {type, severity, target, message};
+}
+
+function remediation(type, target, reason) {
+ return {type, target, reason};
+}
+
+function referenceLabel(reference) {
+ return reference.id || reference.citationKey || 'unknown-reference';
+}
+
+function referenceIdentifiers(reference) {
+ const identifiers = [];
+ const doi = normalizeDoi(reference.doi);
+ const pmid = normalizePmid(reference.pmid);
+ if (doi) {
+ identifiers.push({type: 'doi', value: doi, label: reference.doi});
+ }
+ if (pmid) {
+ identifiers.push({type: 'pmid', value: pmid, label: reference.pmid});
+ }
+ return identifiers;
+}
+
+function collectBy(items, keyFn) {
+ const buckets = new Map();
+ for (const item of items) {
+ const key = keyFn(item);
+ if (!key) {
+ continue;
+ }
+ if (!buckets.has(key)) {
+ buckets.set(key, []);
+ }
+ buckets.get(key).push(item);
+ }
+ return buckets;
+}
+
+function validateDuplicateIdentifiers(references, findings, remediationPlan) {
+ const identifierRefs = new Map();
+ for (const reference of references) {
+ for (const identifier of referenceIdentifiers(reference)) {
+ const key = `${identifier.type}:${identifier.value}`;
+ if (!identifierRefs.has(key)) {
+ identifierRefs.set(key, []);
+ }
+ identifierRefs.get(key).push({reference, identifier});
+ }
+ }
+
+ for (const [key, entries] of identifierRefs.entries()) {
+ const uniqueIds = new Set(entries.map((entry) => referenceLabel(entry.reference)));
+ if (uniqueIds.size <= 1) {
+ continue;
+ }
+ const labels = [...uniqueIds].join(', ');
+ const identifier = entries[0].identifier.label || key.split(':').slice(1).join(':');
+ findings.push(
+ finding(
+ 'duplicate-source-identifier',
+ 'block',
+ key,
+ `References ${labels} share source identifier ${identifier}.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'deduplicate-source-record',
+ key,
+ `Merge or remove duplicate reference records for ${identifier} before syncing the collaborative library.`
+ )
+ );
+ }
+}
+
+function validateCitationKeyCollisions(references, findings, remediationPlan) {
+ const byKey = collectBy(references, (reference) => normalize(reference.citationKey));
+ for (const [key, refs] of byKey.entries()) {
+ const uniqueIds = new Set(refs.map(referenceLabel));
+ if (uniqueIds.size <= 1) {
+ continue;
+ }
+ const labels = [...uniqueIds].join(', ');
+ findings.push(
+ finding(
+ 'citation-key-collision',
+ 'block',
+ key,
+ `Citation key ${refs[0].citationKey} points to multiple references: ${labels}.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'rename-citation-key',
+ key,
+ `Assign unique citation keys for ${labels} and update manuscript anchors before export.`
+ )
+ );
+ }
+}
+
+function validateCitationAnchors(references, citations, findings, remediationPlan) {
+ const keys = new Set(references.map((reference) => normalize(reference.citationKey)).filter(Boolean));
+ for (const citation of citations) {
+ const key = normalize(citation.citationKey);
+ if (!key || keys.has(key)) {
+ continue;
+ }
+ const target = citation.anchorId || citation.citationKey || 'unknown-anchor';
+ findings.push(
+ finding(
+ 'stale-citation-anchor',
+ 'block',
+ target,
+ `Citation anchor ${target} references ${citation.citationKey || 'missing key'}, which is absent from the merged library.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'repair-citation-anchor',
+ target,
+ `Reconnect ${target} to an active reference key or remove the stale in-text citation before export.`
+ )
+ );
+ }
+}
+
+function validateLockedEdits(references, proposedEdits, findings, remediationPlan) {
+ const byId = new Map(references.map((reference) => [reference.id, reference]));
+ for (const edit of proposedEdits) {
+ const reference = byId.get(edit.referenceId);
+ if (!reference?.locked || list(edit.changedFields).length === 0) {
+ continue;
+ }
+ const target = edit.referenceId || 'unknown-reference';
+ findings.push(
+ finding(
+ 'locked-reference-edit',
+ 'block',
+ target,
+ `Locked reference ${target} has proposed changes to ${list(edit.changedFields).join(', ')}.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'review-locked-reference-edit',
+ target,
+ `Require reference owner approval before applying locked ${target} edits from ${edit.editor || 'unknown editor'}.`
+ )
+ );
+ }
+}
+
+function hasBibliographicCore(reference) {
+ return Boolean(clean(reference.title)) && list(reference.authors).length > 0 && Boolean(reference.year);
+}
+
+function validateSourceMetadata(references, findings, remediationPlan) {
+ for (const reference of references) {
+ const identifiers = referenceIdentifiers(reference);
+ if (identifiers.length > 0 && hasBibliographicCore(reference)) {
+ continue;
+ }
+ const target = referenceLabel(reference);
+ findings.push(
+ finding(
+ 'missing-source-metadata',
+ 'review',
+ target,
+ `Reference ${target} is missing DOI/PMID or core title, author, and year metadata.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'complete-source-metadata',
+ target,
+ `Complete DOI/PMID plus title, author, and year metadata for ${target} before final manuscript export.`
+ )
+ );
+ }
+}
+
+function validateReferenceConflicts(references, findings, remediationPlan) {
+ for (const reference of references) {
+ if (!reference.conflict) {
+ continue;
+ }
+ const target = referenceLabel(reference);
+ findings.push(
+ finding(
+ 'unresolved-reference-conflict',
+ 'review',
+ target,
+ `Reference ${target} still has an unresolved merge conflict from a collaborator library import.`
+ )
+ );
+ remediationPlan.push(
+ remediation(
+ 'resolve-reference-conflict',
+ target,
+ `Pick the winning source metadata for ${target} and record the merge decision before export.`
+ )
+ );
+ }
+}
+
+function summarize(findings) {
+ const blockingFindingCount = findings.filter((item) => item.severity === 'block').length;
+ const reviewFindingCount = findings.filter((item) => item.severity !== 'block').length;
+ return {
+ findingCount: findings.length,
+ blockingFindingCount,
+ reviewFindingCount,
+ };
+}
+
+function decide(summary) {
+ if (summary.blockingFindingCount > 0) {
+ return 'hold-export';
+ }
+ if (summary.reviewFindingCount > 0) {
+ return 'merge-review';
+ }
+ return 'export-ready';
+}
+
+function visibleAndGated(actionsRequested, decision) {
+ const requested = list(actionsRequested);
+ if (decision === 'export-ready') {
+ return {visibleActions: requested, gatedActions: []};
+ }
+ if (decision === 'merge-review') {
+ return {
+ visibleActions: requested.filter((action) => action !== 'export-manuscript'),
+ gatedActions: requested.filter((action) => action === 'export-manuscript'),
+ };
+ }
+ return {
+ visibleActions: requested.filter((action) => action === 'notify-collaborators'),
+ gatedActions: requested.filter((action) => action !== 'notify-collaborators'),
+ };
+}
+
+function evaluateReferenceLibraryMerge(input = {}) {
+ const generatedAt = input.generatedAt || new Date(0).toISOString();
+ const references = list(input.references);
+ const findings = [];
+ const remediationPlan = [];
+
+ validateDuplicateIdentifiers(references, findings, remediationPlan);
+ validateCitationKeyCollisions(references, findings, remediationPlan);
+ validateCitationAnchors(references, list(input.citations), findings, remediationPlan);
+ validateLockedEdits(references, list(input.proposedEdits), findings, remediationPlan);
+ validateSourceMetadata(references, findings, remediationPlan);
+ validateReferenceConflicts(references, findings, remediationPlan);
+
+ const summary = summarize(findings);
+ const decision = decide(summary);
+ const {visibleActions, gatedActions} = visibleAndGated(input.actionsRequested, decision);
+
+ return {
+ generatedAt,
+ manuscriptId: input.manuscriptId || 'unknown-manuscript',
+ libraryRevision: input.libraryRevision || 'unknown-revision',
+ decision,
+ summary,
+ findings,
+ remediationPlan,
+ visibleActions,
+ gatedActions,
+ reviewerPacket: {
+ manuscriptId: input.manuscriptId || 'unknown-manuscript',
+ libraryRevision: input.libraryRevision || 'unknown-revision',
+ referenceCount: references.length,
+ citationCount: list(input.citations).length,
+ decision,
+ gatedActions,
+ visibleActions,
+ },
+ };
+}
+
+function buildReferenceMergePacket(result) {
+ const lines = [
+ `# Collaborative Reference Merge Guard: ${result.manuscriptId}`,
+ '',
+ `Library revision: ${result.libraryRevision}`,
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ `References: ${result.reviewerPacket.referenceCount}`,
+ `Citation anchors: ${result.reviewerPacket.citationCount}`,
+ '',
+ '## Visible Actions',
+ ];
+
+ if (result.visibleActions.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const action of result.visibleActions) {
+ lines.push(`- ${action}`);
+ }
+ }
+
+ lines.push('', '## Gated Actions');
+ if (result.gatedActions.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const action of result.gatedActions) {
+ lines.push(`- ${action}`);
+ }
+ }
+
+ lines.push('', '## Findings');
+ if (result.findings.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const item of result.findings) {
+ lines.push(`- ${item.type}: ${item.target} - ${item.message}`);
+ }
+ }
+
+ lines.push('', '## Remediation Plan');
+ if (result.remediationPlan.length === 0) {
+ lines.push('- No remediation required');
+ } else {
+ for (const item of result.remediationPlan) {
+ lines.push(`- ${item.type}: ${item.target} - ${item.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluateReferenceLibraryMerge,
+ buildReferenceMergePacket,
+};
diff --git a/collaborative-reference-merge-guard/reports/demo.mp4 b/collaborative-reference-merge-guard/reports/demo.mp4
new file mode 100644
index 00000000..575e7d74
Binary files /dev/null and b/collaborative-reference-merge-guard/reports/demo.mp4 differ
diff --git a/collaborative-reference-merge-guard/reports/reference-merge-packet.json b/collaborative-reference-merge-guard/reports/reference-merge-packet.json
new file mode 100644
index 00000000..f3aec5bd
--- /dev/null
+++ b/collaborative-reference-merge-guard/reports/reference-merge-packet.json
@@ -0,0 +1,175 @@
+[
+ {
+ "scenario": "duplicate-key-and-locked-edit",
+ "generatedAt": "2026-05-22T16:10:00Z",
+ "manuscriptId": "collab-editor-neuro-042",
+ "libraryRevision": "refs-r17",
+ "decision": "hold-export",
+ "summary": {
+ "findingCount": 4,
+ "blockingFindingCount": 4,
+ "reviewFindingCount": 0
+ },
+ "findings": [
+ {
+ "type": "duplicate-source-identifier",
+ "severity": "block",
+ "target": "doi:10.1101/2026.04.01.123456",
+ "message": "References zotero-a1, bibtex-b9 share source identifier 10.1101/2026.04.01.123456."
+ },
+ {
+ "type": "citation-key-collision",
+ "severity": "block",
+ "target": "nguyen2026atlas",
+ "message": "Citation key Nguyen2026Atlas points to multiple references: zotero-a1, bibtex-b9."
+ },
+ {
+ "type": "stale-citation-anchor",
+ "severity": "block",
+ "target": "sec-discussion-p2-c1",
+ "message": "Citation anchor sec-discussion-p2-c1 references Missing2023Trial, which is absent from the merged library."
+ },
+ {
+ "type": "locked-reference-edit",
+ "severity": "block",
+ "target": "endnote-locked",
+ "message": "Locked reference endnote-locked has proposed changes to title, doi."
+ }
+ ],
+ "remediationPlan": [
+ {
+ "type": "deduplicate-source-record",
+ "target": "doi:10.1101/2026.04.01.123456",
+ "reason": "Merge or remove duplicate reference records for 10.1101/2026.04.01.123456 before syncing the collaborative library."
+ },
+ {
+ "type": "rename-citation-key",
+ "target": "nguyen2026atlas",
+ "reason": "Assign unique citation keys for zotero-a1, bibtex-b9 and update manuscript anchors before export."
+ },
+ {
+ "type": "repair-citation-anchor",
+ "target": "sec-discussion-p2-c1",
+ "reason": "Reconnect sec-discussion-p2-c1 to an active reference key or remove the stale in-text citation before export."
+ },
+ {
+ "type": "review-locked-reference-edit",
+ "target": "endnote-locked",
+ "reason": "Require reference owner approval before applying locked endnote-locked edits from orcid:0000-0002-2222-3333."
+ }
+ ],
+ "visibleActions": [
+ "notify-collaborators"
+ ],
+ "gatedActions": [
+ "sync-reference-library",
+ "export-manuscript"
+ ],
+ "reviewerPacket": {
+ "manuscriptId": "collab-editor-neuro-042",
+ "libraryRevision": "refs-r17",
+ "referenceCount": 3,
+ "citationCount": 2,
+ "decision": "hold-export",
+ "gatedActions": [
+ "sync-reference-library",
+ "export-manuscript"
+ ],
+ "visibleActions": [
+ "notify-collaborators"
+ ]
+ }
+ },
+ {
+ "scenario": "review-incomplete-field-note",
+ "generatedAt": "2026-05-22T16:20:00Z",
+ "manuscriptId": "collab-editor-ecology-019",
+ "libraryRevision": "refs-r08",
+ "decision": "merge-review",
+ "summary": {
+ "findingCount": 2,
+ "blockingFindingCount": 0,
+ "reviewFindingCount": 2
+ },
+ "findings": [
+ {
+ "type": "missing-source-metadata",
+ "severity": "review",
+ "target": "zotero-fieldnote",
+ "message": "Reference zotero-fieldnote is missing DOI/PMID or core title, author, and year metadata."
+ },
+ {
+ "type": "unresolved-reference-conflict",
+ "severity": "review",
+ "target": "zotero-fieldnote",
+ "message": "Reference zotero-fieldnote still has an unresolved merge conflict from a collaborator library import."
+ }
+ ],
+ "remediationPlan": [
+ {
+ "type": "complete-source-metadata",
+ "target": "zotero-fieldnote",
+ "reason": "Complete DOI/PMID plus title, author, and year metadata for zotero-fieldnote before final manuscript export."
+ },
+ {
+ "type": "resolve-reference-conflict",
+ "target": "zotero-fieldnote",
+ "reason": "Pick the winning source metadata for zotero-fieldnote and record the merge decision before export."
+ }
+ ],
+ "visibleActions": [
+ "sync-reference-library",
+ "notify-collaborators"
+ ],
+ "gatedActions": [
+ "export-manuscript"
+ ],
+ "reviewerPacket": {
+ "manuscriptId": "collab-editor-ecology-019",
+ "libraryRevision": "refs-r08",
+ "referenceCount": 2,
+ "citationCount": 2,
+ "decision": "merge-review",
+ "gatedActions": [
+ "export-manuscript"
+ ],
+ "visibleActions": [
+ "sync-reference-library",
+ "notify-collaborators"
+ ]
+ }
+ },
+ {
+ "scenario": "clean-multi-manager-merge",
+ "generatedAt": "2026-05-22T16:30:00Z",
+ "manuscriptId": "collab-editor-physics-007",
+ "libraryRevision": "refs-r22",
+ "decision": "export-ready",
+ "summary": {
+ "findingCount": 0,
+ "blockingFindingCount": 0,
+ "reviewFindingCount": 0
+ },
+ "findings": [],
+ "remediationPlan": [],
+ "visibleActions": [
+ "sync-reference-library",
+ "export-manuscript",
+ "notify-collaborators"
+ ],
+ "gatedActions": [],
+ "reviewerPacket": {
+ "manuscriptId": "collab-editor-physics-007",
+ "libraryRevision": "refs-r22",
+ "referenceCount": 2,
+ "citationCount": 2,
+ "decision": "export-ready",
+ "gatedActions": [],
+ "visibleActions": [
+ "sync-reference-library",
+ "export-manuscript",
+ "notify-collaborators"
+ ]
+ }
+ }
+]
diff --git a/collaborative-reference-merge-guard/reports/reference-merge-review.md b/collaborative-reference-merge-guard/reports/reference-merge-review.md
new file mode 100644
index 00000000..c85a50de
--- /dev/null
+++ b/collaborative-reference-merge-guard/reports/reference-merge-review.md
@@ -0,0 +1,73 @@
+# Collaborative Reference Merge Guard: collab-editor-neuro-042
+
+Library revision: refs-r17
+Decision: hold-export
+Generated: 2026-05-22T16:10:00Z
+References: 3
+Citation anchors: 2
+
+## Visible Actions
+- notify-collaborators
+
+## Gated Actions
+- sync-reference-library
+- export-manuscript
+
+## Findings
+- duplicate-source-identifier: doi:10.1101/2026.04.01.123456 - References zotero-a1, bibtex-b9 share source identifier 10.1101/2026.04.01.123456.
+- citation-key-collision: nguyen2026atlas - Citation key Nguyen2026Atlas points to multiple references: zotero-a1, bibtex-b9.
+- stale-citation-anchor: sec-discussion-p2-c1 - Citation anchor sec-discussion-p2-c1 references Missing2023Trial, which is absent from the merged library.
+- locked-reference-edit: endnote-locked - Locked reference endnote-locked has proposed changes to title, doi.
+
+## Remediation Plan
+- deduplicate-source-record: doi:10.1101/2026.04.01.123456 - Merge or remove duplicate reference records for 10.1101/2026.04.01.123456 before syncing the collaborative library.
+- rename-citation-key: nguyen2026atlas - Assign unique citation keys for zotero-a1, bibtex-b9 and update manuscript anchors before export.
+- repair-citation-anchor: sec-discussion-p2-c1 - Reconnect sec-discussion-p2-c1 to an active reference key or remove the stale in-text citation before export.
+- review-locked-reference-edit: endnote-locked - Require reference owner approval before applying locked endnote-locked edits from orcid:0000-0002-2222-3333.
+
+---
+# Collaborative Reference Merge Guard: collab-editor-ecology-019
+
+Library revision: refs-r08
+Decision: merge-review
+Generated: 2026-05-22T16:20:00Z
+References: 2
+Citation anchors: 2
+
+## Visible Actions
+- sync-reference-library
+- notify-collaborators
+
+## Gated Actions
+- export-manuscript
+
+## Findings
+- missing-source-metadata: zotero-fieldnote - Reference zotero-fieldnote is missing DOI/PMID or core title, author, and year metadata.
+- unresolved-reference-conflict: zotero-fieldnote - Reference zotero-fieldnote still has an unresolved merge conflict from a collaborator library import.
+
+## Remediation Plan
+- complete-source-metadata: zotero-fieldnote - Complete DOI/PMID plus title, author, and year metadata for zotero-fieldnote before final manuscript export.
+- resolve-reference-conflict: zotero-fieldnote - Pick the winning source metadata for zotero-fieldnote and record the merge decision before export.
+
+---
+# Collaborative Reference Merge Guard: collab-editor-physics-007
+
+Library revision: refs-r22
+Decision: export-ready
+Generated: 2026-05-22T16:30:00Z
+References: 2
+Citation anchors: 2
+
+## Visible Actions
+- sync-reference-library
+- export-manuscript
+- notify-collaborators
+
+## Gated Actions
+- None
+
+## Findings
+- None
+
+## Remediation Plan
+- No remediation required
diff --git a/collaborative-reference-merge-guard/reports/summary.svg b/collaborative-reference-merge-guard/reports/summary.svg
new file mode 100644
index 00000000..2c3ea640
--- /dev/null
+++ b/collaborative-reference-merge-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/collaborative-reference-merge-guard/requirements-map.md b/collaborative-reference-merge-guard/requirements-map.md
new file mode 100644
index 00000000..cff51adc
--- /dev/null
+++ b/collaborative-reference-merge-guard/requirements-map.md
@@ -0,0 +1,33 @@
+# Requirements Map
+
+Issue #12 asks for a real-time collaborative editor that supports scientific formatting, reference manager integration, version history, comments, and export-ready publication workflows. This slice covers the reference manager merge-safety path.
+
+## Reference Manager Integration
+
+- Supports synthetic Zotero, BibTeX, and EndNote-style reference records.
+- Normalizes DOI, DOI URL, and PMID identifiers before comparing imported references.
+- Detects duplicate source records that would create ambiguous collaborative library state.
+
+## Rich Scientific Formatting
+
+- Checks citation keys used by manuscript sections before export.
+- Detects citation-key collisions that would make Markdown, LaTeX, or publication-format output point to the wrong source.
+- Detects stale in-text citation anchors after a concurrent library merge.
+
+## Collaborative Editing Safety
+
+- Blocks edits to locked reference records until the reference owner approves them.
+- Routes unresolved collaborator import conflicts to merge review rather than final export.
+- Produces deterministic visible and gated actions for sync, export, and collaborator notification.
+
+## Version History And Auditability
+
+- Records `manuscriptId`, `libraryRevision`, generation timestamp, reference count, citation count, findings, and remediation actions.
+- Emits JSON and Markdown reviewer packets for pull request review.
+- Emits an SVG summary and MP4 demo artifact for Algora reviewer evidence.
+
+## Safety Boundaries
+
+- Synthetic fixtures only.
+- No private manuscripts, credentials, browser cookies, payment data, Zotero/EndNote accounts, DOI registry calls, PubMed calls, Crossref calls, or external network access.
+- Dependency-free Node implementation.
diff --git a/collaborative-reference-merge-guard/sample-data.js b/collaborative-reference-merge-guard/sample-data.js
new file mode 100644
index 00000000..3c2315e0
--- /dev/null
+++ b/collaborative-reference-merge-guard/sample-data.js
@@ -0,0 +1,110 @@
+const scenarios = [
+ {
+ name: 'duplicate-key-and-locked-edit',
+ generatedAt: '2026-05-22T16:10:00Z',
+ manuscriptId: 'collab-editor-neuro-042',
+ libraryRevision: 'refs-r17',
+ references: [
+ {
+ id: 'zotero-a1',
+ source: 'zotero',
+ citationKey: 'Nguyen2026Atlas',
+ doi: '10.1101/2026.04.01.123456',
+ title: 'Single-cell atlas for glial repair',
+ authors: ['Nguyen T', 'Patel R'],
+ year: 2026,
+ },
+ {
+ id: 'bibtex-b9',
+ source: 'bibtex',
+ citationKey: 'Nguyen2026Atlas',
+ doi: 'https://doi.org/10.1101/2026.04.01.123456',
+ title: 'Duplicated preprint import',
+ authors: ['Nguyen T'],
+ year: 2026,
+ },
+ {
+ id: 'endnote-locked',
+ source: 'endnote',
+ citationKey: 'Smith2024Methods',
+ doi: '10.7554/elife.12345',
+ title: 'Locked method source',
+ authors: ['Smith A'],
+ year: 2024,
+ locked: true,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-results-p4-c2', citationKey: 'Nguyen2026Atlas', sectionId: 'results'},
+ {anchorId: 'sec-discussion-p2-c1', citationKey: 'Missing2023Trial', sectionId: 'discussion'},
+ ],
+ proposedEdits: [
+ {referenceId: 'endnote-locked', changedFields: ['title', 'doi'], editor: 'orcid:0000-0002-2222-3333'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ },
+ {
+ name: 'review-incomplete-field-note',
+ generatedAt: '2026-05-22T16:20:00Z',
+ manuscriptId: 'collab-editor-ecology-019',
+ libraryRevision: 'refs-r08',
+ references: [
+ {
+ id: 'zotero-fieldnote',
+ source: 'zotero',
+ citationKey: 'Lopez2025FieldNote',
+ title: 'Longitudinal field notebook appendix',
+ authors: ['Lopez M'],
+ year: 2025,
+ conflict: true,
+ },
+ {
+ id: 'bibtex-dataset',
+ source: 'bibtex',
+ citationKey: 'RiverData2024',
+ doi: '10.5281/zenodo.424242',
+ title: 'River sensor dataset',
+ authors: ['Data Consortium'],
+ year: 2024,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-methods-p1-c1', citationKey: 'Lopez2025FieldNote', sectionId: 'methods'},
+ {anchorId: 'sec-data-p3-c1', citationKey: 'RiverData2024', sectionId: 'data'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ },
+ {
+ name: 'clean-multi-manager-merge',
+ generatedAt: '2026-05-22T16:30:00Z',
+ manuscriptId: 'collab-editor-physics-007',
+ libraryRevision: 'refs-r22',
+ references: [
+ {
+ id: 'zotero-quantum',
+ source: 'zotero',
+ citationKey: 'Chen2026Quantum',
+ doi: '10.1038/s41586-026-11111-1',
+ title: 'Quantum sensor calibration',
+ authors: ['Chen L', 'Ibrahim N'],
+ year: 2026,
+ },
+ {
+ id: 'bibtex-control',
+ source: 'bibtex',
+ citationKey: 'Patel2024Controls',
+ pmid: '39555123',
+ title: 'Control experiment checklist',
+ authors: ['Patel R'],
+ year: 2024,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-intro-p2-c1', citationKey: 'Chen2026Quantum', sectionId: 'intro'},
+ {anchorId: 'sec-methods-p4-c1', citationKey: 'Patel2024Controls', sectionId: 'methods'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/collaborative-reference-merge-guard/test.js b/collaborative-reference-merge-guard/test.js
new file mode 100644
index 00000000..0a2cb333
--- /dev/null
+++ b/collaborative-reference-merge-guard/test.js
@@ -0,0 +1,151 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateReferenceLibraryMerge,
+ buildReferenceMergePacket,
+} = require('./index');
+
+test('holds manuscript export when merged reference libraries contain duplicate identifiers, key collisions, stale citations, and locked edits', () => {
+ const result = evaluateReferenceLibraryMerge({
+ generatedAt: '2026-05-22T16:10:00Z',
+ manuscriptId: 'collab-editor-neuro-042',
+ libraryRevision: 'refs-r17',
+ references: [
+ {
+ id: 'zotero-a1',
+ source: 'zotero',
+ citationKey: 'Nguyen2026Atlas',
+ doi: '10.1101/2026.04.01.123456',
+ title: 'Single-cell atlas for glial repair',
+ authors: ['Nguyen T', 'Patel R'],
+ year: 2026,
+ },
+ {
+ id: 'bibtex-b9',
+ source: 'bibtex',
+ citationKey: 'Nguyen2026Atlas',
+ doi: 'https://doi.org/10.1101/2026.04.01.123456',
+ title: 'Duplicated preprint import',
+ authors: ['Nguyen T'],
+ year: 2026,
+ },
+ {
+ id: 'endnote-locked',
+ source: 'endnote',
+ citationKey: 'Smith2024Methods',
+ doi: '10.7554/elife.12345',
+ title: 'Locked method source',
+ authors: ['Smith A'],
+ year: 2024,
+ locked: true,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-results-p4-c2', citationKey: 'Nguyen2026Atlas', sectionId: 'results'},
+ {anchorId: 'sec-discussion-p2-c1', citationKey: 'Missing2023Trial', sectionId: 'discussion'},
+ ],
+ proposedEdits: [
+ {referenceId: 'endnote-locked', changedFields: ['title', 'doi'], editor: 'orcid:0000-0002-2222-3333'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ });
+
+ assert.equal(result.decision, 'hold-export');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'duplicate-source-identifier',
+ 'citation-key-collision',
+ 'stale-citation-anchor',
+ 'locked-reference-edit',
+ ]
+ );
+ assert.equal(result.summary.blockingFindingCount, 4);
+ assert.deepEqual(result.gatedActions.sort(), ['export-manuscript', 'sync-reference-library']);
+ assert.deepEqual(result.visibleActions, ['notify-collaborators']);
+ assert.match(result.remediationPlan[0].reason, /10.1101\/2026.04.01.123456/);
+});
+
+test('routes unresolved reference conflicts and incomplete source metadata to merge review', () => {
+ const result = evaluateReferenceLibraryMerge({
+ generatedAt: '2026-05-22T16:20:00Z',
+ manuscriptId: 'collab-editor-ecology-019',
+ libraryRevision: 'refs-r08',
+ references: [
+ {
+ id: 'zotero-fieldnote',
+ source: 'zotero',
+ citationKey: 'Lopez2025FieldNote',
+ title: 'Longitudinal field notebook appendix',
+ authors: ['Lopez M'],
+ year: 2025,
+ conflict: true,
+ },
+ {
+ id: 'bibtex-dataset',
+ source: 'bibtex',
+ citationKey: 'RiverData2024',
+ doi: '10.5281/zenodo.424242',
+ title: 'River sensor dataset',
+ authors: ['Data Consortium'],
+ year: 2024,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-methods-p1-c1', citationKey: 'Lopez2025FieldNote', sectionId: 'methods'},
+ {anchorId: 'sec-data-p3-c1', citationKey: 'RiverData2024', sectionId: 'data'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ });
+
+ assert.equal(result.decision, 'merge-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['missing-source-metadata', 'unresolved-reference-conflict']
+ );
+ assert.equal(result.summary.reviewFindingCount, 2);
+ assert.deepEqual(result.gatedActions, ['export-manuscript']);
+ assert.deepEqual(result.visibleActions.sort(), ['notify-collaborators', 'sync-reference-library']);
+});
+
+test('approves clean reference merges and builds a reviewer packet', () => {
+ const result = evaluateReferenceLibraryMerge({
+ generatedAt: '2026-05-22T16:30:00Z',
+ manuscriptId: 'collab-editor-physics-007',
+ libraryRevision: 'refs-r22',
+ references: [
+ {
+ id: 'zotero-quantum',
+ source: 'zotero',
+ citationKey: 'Chen2026Quantum',
+ doi: '10.1038/s41586-026-11111-1',
+ title: 'Quantum sensor calibration',
+ authors: ['Chen L', 'Ibrahim N'],
+ year: 2026,
+ },
+ {
+ id: 'bibtex-control',
+ source: 'bibtex',
+ citationKey: 'Patel2024Controls',
+ pmid: '39555123',
+ title: 'Control experiment checklist',
+ authors: ['Patel R'],
+ year: 2024,
+ },
+ ],
+ citations: [
+ {anchorId: 'sec-intro-p2-c1', citationKey: 'Chen2026Quantum', sectionId: 'intro'},
+ {anchorId: 'sec-methods-p4-c1', citationKey: 'Patel2024Controls', sectionId: 'methods'},
+ ],
+ actionsRequested: ['sync-reference-library', 'export-manuscript', 'notify-collaborators'],
+ });
+ const packet = buildReferenceMergePacket(result);
+
+ assert.equal(result.decision, 'export-ready');
+ assert.equal(result.summary.findingCount, 0);
+ assert.deepEqual(result.gatedActions, []);
+ assert.deepEqual(result.visibleActions.sort(), ['export-manuscript', 'notify-collaborators', 'sync-reference-library']);
+ assert.match(packet, /collab-editor-physics-007/);
+ assert.match(packet, /No remediation required/);
+});