From dbb811a94dc69e80fd2c2ed858e4e73c28be7d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szab=C3=B3=20D=C3=A1vid?= Date: Sat, 27 Jun 2026 10:41:46 +0200 Subject: [PATCH] Add collaborative cover-letter export guard --- collab-cover-letter-export-guard/README.md | 33 ++++ collab-cover-letter-export-guard/demo.js | 29 ++++ .../make-demo-video.js | 50 ++++++ collab-cover-letter-export-guard/package.json | 13 ++ .../reports/clean-review.json | 10 ++ .../reports/demo.mp4 | Bin 0 -> 23125 bytes .../reports/risky-review.json | 59 +++++++ .../reports/risky-review.md | 25 +++ .../reports/summary.svg | 8 + collab-cover-letter-export-guard/src/guard.js | 152 ++++++++++++++++++ .../src/samplePackets.js | 91 +++++++++++ collab-cover-letter-export-guard/test.js | 23 +++ 12 files changed, 493 insertions(+) create mode 100644 collab-cover-letter-export-guard/README.md create mode 100644 collab-cover-letter-export-guard/demo.js create mode 100644 collab-cover-letter-export-guard/make-demo-video.js create mode 100644 collab-cover-letter-export-guard/package.json create mode 100644 collab-cover-letter-export-guard/reports/clean-review.json create mode 100644 collab-cover-letter-export-guard/reports/demo.mp4 create mode 100644 collab-cover-letter-export-guard/reports/risky-review.json create mode 100644 collab-cover-letter-export-guard/reports/risky-review.md create mode 100644 collab-cover-letter-export-guard/reports/summary.svg create mode 100644 collab-cover-letter-export-guard/src/guard.js create mode 100644 collab-cover-letter-export-guard/src/samplePackets.js create mode 100644 collab-cover-letter-export-guard/test.js diff --git a/collab-cover-letter-export-guard/README.md b/collab-cover-letter-export-guard/README.md new file mode 100644 index 00000000..cba46bea --- /dev/null +++ b/collab-cover-letter-export-guard/README.md @@ -0,0 +1,33 @@ +# Collaborative Cover-Letter Export Guard + +Self-contained reviewer artifact for SCIBASE issue #12, focused on the last step before a collaborative manuscript export includes its journal cover letter. + +The guard evaluates synthetic submission packets for: + +- stale manuscript and cover-letter hashes after final collaborative edits +- missing or stale corresponding-author approval +- coauthor approval drift after the final manuscript version +- novelty, ethics, data availability, and conflict statement mismatches +- private reviewer or collaborator notes leaking into the submission-facing cover letter + +It emits deterministic `RELEASE`, `REVIEW`, or `HOLD` decisions with JSON, Markdown, SVG, and MP4 artifacts. It uses no credentials, no private manuscripts, no external APIs, and no live editor service. + +## Requirement Mapping + +| Issue #12 capability | Covered here | +| --- | --- | +| Inline comments, suggestions, and change tracking | Blocks cover-letter export when private or unresolved collaborator notes are included | +| Version history and autosave | Requires cover-letter and approvals to match the final manuscript hash | +| Integrated task workflow | Produces remediation actions for missing approvals and claim drift | +| Scientific formatting and publication workflow | Validates the journal-facing correspondence packet before export | + +## Local Validation + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +Generated artifacts are written to `reports/`. diff --git a/collab-cover-letter-export-guard/demo.js b/collab-cover-letter-export-guard/demo.js new file mode 100644 index 00000000..201dd25b --- /dev/null +++ b/collab-cover-letter-export-guard/demo.js @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { cleanSubmissionPacket, riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket, renderMarkdownReport } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const cleanResult = evaluateCoverLetterExportPacket(cleanSubmissionPacket); +const riskyResult = evaluateCoverLetterExportPacket(riskySubmissionPacket); + +fs.writeFileSync(path.join(reportsDir, "clean-review.json"), `${JSON.stringify(cleanResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.json"), `${JSON.stringify(riskyResult, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(riskySubmissionPacket, riskyResult)); + +const svg = ` + + Collaborative Cover-Letter Export Guard + Decision: ${riskyResult.decision} + Findings: ${riskyResult.findingCount} | High: ${riskyResult.highCount} | Medium: ${riskyResult.mediumCount} + Checks stale hashes, approval drift, claim mismatches, and private-note leakage. + Audit digest: ${riskyResult.auditDigest} + +`; +fs.writeFileSync(path.join(reportsDir, "summary.svg"), svg); + +console.log(`Clean decision: ${cleanResult.decision}`); +console.log(`Risky decision: ${riskyResult.decision}`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/collab-cover-letter-export-guard/make-demo-video.js b/collab-cover-letter-export-guard/make-demo-video.js new file mode 100644 index 00000000..00f8982e --- /dev/null +++ b/collab-cover-letter-export-guard/make-demo-video.js @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket } from "./src/guard.js"; + +const reportsDir = path.join(process.cwd(), "reports"); +const demoMp4 = path.join(reportsDir, "demo.mp4"); +const resultPacket = evaluateCoverLetterExportPacket(riskySubmissionPacket); + +function escapeDrawtext(text) { + return text.replaceAll("\\", "\\\\").replaceAll(":", "\\:").replaceAll("'", "\\'"); +} + +const font = "C\\:/Windows/Fonts/arial.ttf"; +const lines = [ + "Collaborative Cover-Letter Export Guard", + `Decision ${resultPacket.decision} | Findings ${resultPacket.findingCount}`, + "Blocks stale hashes, approval drift, claim mismatches, and private-note leakage.", + `Audit digest ${resultPacket.auditDigest}`, +]; +const drawText = lines + .map( + (line, index) => + `drawtext=fontfile='${font}':text='${escapeDrawtext(line)}':x=48:y=${64 + index * 72}:fontsize=${index === 0 ? 34 : 24}:fontcolor=${index === 1 ? "0xffdddd" : "white"}`, + ) + .join(","); + +const result = spawnSync( + "ffmpeg", + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0x101827:s=960x540:r=12", + "-t", + "4", + "-vf", + `${drawText},format=yuv420p`, + "-an", + demoMp4, + ], + { encoding: "utf8" }, +); + +if (result.status !== 0) { + throw new Error(result.stderr || "ffmpeg failed to render demo.mp4"); +} + +console.log(`Wrote ${path.relative(process.cwd(), demoMp4)}`); diff --git a/collab-cover-letter-export-guard/package.json b/collab-cover-letter-export-guard/package.json new file mode 100644 index 00000000..e49510d4 --- /dev/null +++ b/collab-cover-letter-export-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "collab-cover-letter-export-guard", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Dependency-free guard for collaborative journal cover-letter export readiness.", + "scripts": { + "check": "node --check src/guard.js && node --check src/samplePackets.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node make-demo-video.js" + } +} diff --git a/collab-cover-letter-export-guard/reports/clean-review.json b/collab-cover-letter-export-guard/reports/clean-review.json new file mode 100644 index 00000000..6e0d85d6 --- /dev/null +++ b/collab-cover-letter-export-guard/reports/clean-review.json @@ -0,0 +1,10 @@ +{ + "packetId": "submission-ready-cover-letter", + "decision": "RELEASE", + "findingCount": 0, + "highCount": 0, + "mediumCount": 0, + "findings": [], + "exportReady": true, + "auditDigest": "b987f52224b801a1" +} diff --git a/collab-cover-letter-export-guard/reports/demo.mp4 b/collab-cover-letter-export-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..6d1275ba40c3c22cb48a0a7ad0daa675824adf19 GIT binary patch literal 23125 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 + + Collaborative Cover-Letter Export Guard + Decision: HOLD + Findings: 8 | High: 5 | Medium: 3 + Checks stale hashes, approval drift, claim mismatches, and private-note leakage. + Audit digest: fa958c5cf99c0caa + diff --git a/collab-cover-letter-export-guard/src/guard.js b/collab-cover-letter-export-guard/src/guard.js new file mode 100644 index 00000000..ba8ccc2d --- /dev/null +++ b/collab-cover-letter-export-guard/src/guard.js @@ -0,0 +1,152 @@ +import crypto from "node:crypto"; + +const CLAIM_KEYS = ["novelty", "ethics", "dataAvailability", "conflicts"]; + +function normalizeText(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function textSimilarity(left, right) { + const leftTerms = new Set(normalizeText(left).split(" ").filter(Boolean)); + const rightTerms = new Set(normalizeText(right).split(" ").filter(Boolean)); + if (leftTerms.size === 0 && rightTerms.size === 0) return 1; + const overlap = [...leftTerms].filter((term) => rightTerms.has(term)).length; + return overlap / Math.max(leftTerms.size, rightTerms.size); +} + +function addFinding(findings, severity, code, message, remediation) { + findings.push({ severity, code, message, remediation }); +} + +function hasPrivateLeak(packet) { + const noteText = packet.coverLetter.editorNotes.join("\n").toLowerCase(); + return ( + packet.coverLetter.privateCommentExports.length > 0 || + /private reviewer|internal note|unresolved collaborator|do not disclose/.test(noteText) + ); +} + +function evaluateApprovalFreshness(packet, findings) { + const expectedHash = packet.manuscript.versionHash; + const cover = packet.coverLetter; + + if (cover.manuscriptHash !== expectedHash) { + addFinding( + findings, + "high", + "STALE_COVER_LETTER_HASH", + `Cover letter references ${cover.manuscriptHash}, but final manuscript is ${expectedHash}.`, + "Regenerate the cover-letter packet from the final manuscript version.", + ); + } + + if (!cover.correspondingAuthorApproval || cover.correspondingAuthorApproval.approvedHash !== expectedHash) { + addFinding( + findings, + "high", + "MISSING_CORRESPONDING_AUTHOR_APPROVAL", + "Corresponding author approval is missing or tied to an older manuscript hash.", + "Collect a fresh corresponding-author approval after the final edit.", + ); + } + + const requiredCoauthors = packet.collaborators + .filter((collaborator) => collaborator.role === "coauthor") + .map((collaborator) => collaborator.authorId); + const approvedCoauthors = new Set( + cover.coauthorApprovals + .filter((approval) => approval.approvedHash === expectedHash) + .map((approval) => approval.authorId), + ); + const missing = requiredCoauthors.filter((authorId) => !approvedCoauthors.has(authorId)); + if (missing.length > 0) { + addFinding( + findings, + "medium", + "COAUTHOR_APPROVAL_DRIFT", + `Missing fresh cover-letter approval from ${missing.join(", ")}.`, + "Route the final cover-letter packet to every coauthor who is listed on the manuscript.", + ); + } +} + +function evaluateStatementConsistency(packet, findings) { + for (const key of CLAIM_KEYS) { + const manuscriptValue = packet.manuscript.claims[key]; + const coverValue = packet.coverLetter.statements[key]; + const similarity = textSimilarity(manuscriptValue, coverValue); + if (similarity < 0.45) { + addFinding( + findings, + key === "conflicts" || key === "ethics" ? "high" : "medium", + `CLAIM_MISMATCH_${key.toUpperCase()}`, + `Cover-letter ${key} statement diverges from the manuscript claim.`, + "Align cover-letter statements with the final manuscript disclosure text before export.", + ); + } + } +} + +export function evaluateCoverLetterExportPacket(packet) { + const findings = []; + evaluateApprovalFreshness(packet, findings); + evaluateStatementConsistency(packet, findings); + + if (hasPrivateLeak(packet)) { + addFinding( + findings, + "high", + "PRIVATE_COMMENT_LEAK", + "Cover-letter export includes private reviewer or collaborator notes.", + "Redact private comments and rebuild the editor-facing correspondence packet.", + ); + } + + const highCount = findings.filter((finding) => finding.severity === "high").length; + const mediumCount = findings.filter((finding) => finding.severity === "medium").length; + const decision = highCount > 0 ? "HOLD" : mediumCount > 0 ? "REVIEW" : "RELEASE"; + const auditDigest = crypto + .createHash("sha256") + .update(JSON.stringify({ id: packet.id, decision, findings })) + .digest("hex") + .slice(0, 16); + + return { + packetId: packet.id, + decision, + findingCount: findings.length, + highCount, + mediumCount, + findings, + exportReady: decision === "RELEASE", + auditDigest, + }; +} + +export function renderMarkdownReport(packet, result) { + const lines = [ + `# Cover Letter Export Guard Report`, + "", + `- Packet: ${packet.id}`, + `- Manuscript: ${packet.manuscript.title}`, + `- Decision: ${result.decision}`, + `- Audit digest: ${result.auditDigest}`, + "", + "## Findings", + "", + ]; + + if (result.findings.length === 0) { + lines.push("- No blockers found. Cover-letter export is ready for journal submission."); + } else { + for (const finding of result.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + lines.push(` Remediation: ${finding.remediation}`); + } + } + + return `${lines.join("\n")}\n`; +} diff --git a/collab-cover-letter-export-guard/src/samplePackets.js b/collab-cover-letter-export-guard/src/samplePackets.js new file mode 100644 index 00000000..ff1b5c42 --- /dev/null +++ b/collab-cover-letter-export-guard/src/samplePackets.js @@ -0,0 +1,91 @@ +export const cleanSubmissionPacket = { + id: "submission-ready-cover-letter", + manuscript: { + versionHash: "ms-v9-6f2a", + title: "Longitudinal biomarker stability in synthetic cohort data", + claims: { + novelty: "First preregistered multi-site synthetic stability analysis in this cohort.", + ethics: "Synthetic-only data with no human participant identifiers.", + dataAvailability: "Synthetic data and analysis code are available in the linked repository.", + conflicts: "The authors declare no competing interests.", + }, + finalEditedAt: "2026-06-21T12:00:00Z", + }, + coverLetter: { + manuscriptHash: "ms-v9-6f2a", + editedAt: "2026-06-21T12:08:00Z", + correspondingAuthorApproval: { + authorId: "author-1", + approvedHash: "ms-v9-6f2a", + approvedAt: "2026-06-21T12:12:00Z", + }, + coauthorApprovals: [ + { authorId: "author-2", approvedHash: "ms-v9-6f2a", approvedAt: "2026-06-21T12:20:00Z" }, + { authorId: "author-3", approvedHash: "ms-v9-6f2a", approvedAt: "2026-06-21T12:21:00Z" }, + ], + statements: { + novelty: "First preregistered multi-site synthetic stability analysis in this cohort.", + ethics: "Synthetic-only data with no human participant identifiers.", + dataAvailability: "Synthetic data and analysis code are available in the linked repository.", + conflicts: "The authors declare no competing interests.", + }, + editorNotes: [ + "We believe the study fits the journal scope because it demonstrates reproducible synthetic cohort validation.", + ], + privateCommentExports: [], + }, + collaborators: [ + { authorId: "author-1", role: "corresponding-author" }, + { authorId: "author-2", role: "coauthor" }, + { authorId: "author-3", role: "coauthor" }, + ], +}; + +export const riskySubmissionPacket = { + id: "submission-blocked-cover-letter", + manuscript: { + versionHash: "ms-v11-aa30", + title: "Model-assisted detection of assay drift in synthetic submissions", + claims: { + novelty: "A synthetic benchmark for assay drift detection across three labs.", + ethics: "Synthetic samples only; no patient identifiers are included.", + dataAvailability: "Synthetic data will be released after editorial acceptance.", + conflicts: "Author B consults for LabScale Analytics.", + }, + finalEditedAt: "2026-06-22T18:40:00Z", + }, + coverLetter: { + manuscriptHash: "ms-v10-21bd", + editedAt: "2026-06-22T17:45:00Z", + correspondingAuthorApproval: { + authorId: "author-1", + approvedHash: "ms-v10-21bd", + approvedAt: "2026-06-22T17:50:00Z", + }, + coauthorApprovals: [ + { authorId: "author-2", approvedHash: "ms-v10-21bd", approvedAt: "2026-06-22T17:51:00Z" }, + ], + statements: { + novelty: "The first assay drift detector validated in live clinical data.", + ethics: "No ethics statement is required.", + dataAvailability: "All raw data are publicly available now.", + conflicts: "The authors declare no competing interests.", + }, + editorNotes: [ + "Private reviewer note: Reviewer 2 questioned whether the synthetic cohort is sufficient.", + "Please consider this despite the unresolved collaborator comment in section 4.", + ], + privateCommentExports: [ + { + commentId: "c-private-77", + section: "Results", + text: "Internal note: do not disclose LabScale consulting until legal confirms wording.", + }, + ], + }, + collaborators: [ + { authorId: "author-1", role: "corresponding-author" }, + { authorId: "author-2", role: "coauthor" }, + { authorId: "author-3", role: "coauthor" }, + ], +}; diff --git a/collab-cover-letter-export-guard/test.js b/collab-cover-letter-export-guard/test.js new file mode 100644 index 00000000..21b0d0f0 --- /dev/null +++ b/collab-cover-letter-export-guard/test.js @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { cleanSubmissionPacket, riskySubmissionPacket } from "./src/samplePackets.js"; +import { evaluateCoverLetterExportPacket, renderMarkdownReport } from "./src/guard.js"; + +const cleanResult = evaluateCoverLetterExportPacket(cleanSubmissionPacket); +assert.equal(cleanResult.decision, "RELEASE"); +assert.equal(cleanResult.findingCount, 0); +assert.equal(cleanResult.exportReady, true); + +const riskyResult = evaluateCoverLetterExportPacket(riskySubmissionPacket); +assert.equal(riskyResult.decision, "HOLD"); +assert.equal(riskyResult.exportReady, false); +assert.ok(riskyResult.findings.some((finding) => finding.code === "STALE_COVER_LETTER_HASH")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "MISSING_CORRESPONDING_AUTHOR_APPROVAL")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "PRIVATE_COMMENT_LEAK")); +assert.ok(riskyResult.findings.some((finding) => finding.code === "CLAIM_MISMATCH_CONFLICTS")); + +const report = renderMarkdownReport(riskySubmissionPacket, riskyResult); +assert.match(report, /Cover Letter Export Guard Report/); +assert.match(report, /HOLD/); +assert.match(report, /PRIVATE_COMMENT_LEAK/); + +console.log("4 cover-letter export guard tests passed");