Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions collab-cover-letter-export-guard/README.md
Original file line number Diff line number Diff line change
@@ -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/`.
29 changes: 29 additions & 0 deletions collab-cover-letter-export-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect width="960" height="540" fill="#101827"/>
<text x="48" y="76" fill="#ffffff" font-family="Arial, sans-serif" font-size="34" font-weight="700">Collaborative Cover-Letter Export Guard</text>
<text x="48" y="134" fill="#fca5a5" font-family="Arial, sans-serif" font-size="28">Decision: ${riskyResult.decision}</text>
<text x="48" y="188" fill="#d1d5db" font-family="Arial, sans-serif" font-size="23">Findings: ${riskyResult.findingCount} | High: ${riskyResult.highCount} | Medium: ${riskyResult.mediumCount}</text>
<text x="48" y="244" fill="#d1d5db" font-family="Arial, sans-serif" font-size="22">Checks stale hashes, approval drift, claim mismatches, and private-note leakage.</text>
<text x="48" y="304" fill="#93c5fd" font-family="Arial, sans-serif" font-size="22">Audit digest: ${riskyResult.auditDigest}</text>
</svg>
`;
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}`);
50 changes: 50 additions & 0 deletions collab-cover-letter-export-guard/make-demo-video.js
Original file line number Diff line number Diff line change
@@ -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)}`);
13 changes: 13 additions & 0 deletions collab-cover-letter-export-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions collab-cover-letter-export-guard/reports/clean-review.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"packetId": "submission-ready-cover-letter",
"decision": "RELEASE",
"findingCount": 0,
"highCount": 0,
"mediumCount": 0,
"findings": [],
"exportReady": true,
"auditDigest": "b987f52224b801a1"
}
Binary file added collab-cover-letter-export-guard/reports/demo.mp4
Binary file not shown.
59 changes: 59 additions & 0 deletions collab-cover-letter-export-guard/reports/risky-review.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"packetId": "submission-blocked-cover-letter",
"decision": "HOLD",
"findingCount": 8,
"highCount": 5,
"mediumCount": 3,
"findings": [
{
"severity": "high",
"code": "STALE_COVER_LETTER_HASH",
"message": "Cover letter references ms-v10-21bd, but final manuscript is ms-v11-aa30.",
"remediation": "Regenerate the cover-letter packet from the final manuscript version."
},
{
"severity": "high",
"code": "MISSING_CORRESPONDING_AUTHOR_APPROVAL",
"message": "Corresponding author approval is missing or tied to an older manuscript hash.",
"remediation": "Collect a fresh corresponding-author approval after the final edit."
},
{
"severity": "medium",
"code": "COAUTHOR_APPROVAL_DRIFT",
"message": "Missing fresh cover-letter approval from author-2, author-3.",
"remediation": "Route the final cover-letter packet to every coauthor who is listed on the manuscript."
},
{
"severity": "medium",
"code": "CLAIM_MISMATCH_NOVELTY",
"message": "Cover-letter novelty statement diverges from the manuscript claim.",
"remediation": "Align cover-letter statements with the final manuscript disclosure text before export."
},
{
"severity": "high",
"code": "CLAIM_MISMATCH_ETHICS",
"message": "Cover-letter ethics statement diverges from the manuscript claim.",
"remediation": "Align cover-letter statements with the final manuscript disclosure text before export."
},
{
"severity": "medium",
"code": "CLAIM_MISMATCH_DATAAVAILABILITY",
"message": "Cover-letter dataAvailability statement diverges from the manuscript claim.",
"remediation": "Align cover-letter statements with the final manuscript disclosure text before export."
},
{
"severity": "high",
"code": "CLAIM_MISMATCH_CONFLICTS",
"message": "Cover-letter conflicts statement diverges from the manuscript claim.",
"remediation": "Align cover-letter statements with the final manuscript disclosure text before export."
},
{
"severity": "high",
"code": "PRIVATE_COMMENT_LEAK",
"message": "Cover-letter export includes private reviewer or collaborator notes.",
"remediation": "Redact private comments and rebuild the editor-facing correspondence packet."
}
],
"exportReady": false,
"auditDigest": "fa958c5cf99c0caa"
}
25 changes: 25 additions & 0 deletions collab-cover-letter-export-guard/reports/risky-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Cover Letter Export Guard Report

- Packet: submission-blocked-cover-letter
- Manuscript: Model-assisted detection of assay drift in synthetic submissions
- Decision: HOLD
- Audit digest: fa958c5cf99c0caa

## Findings

- HIGH STALE_COVER_LETTER_HASH: Cover letter references ms-v10-21bd, but final manuscript is ms-v11-aa30.
Remediation: Regenerate the cover-letter packet from the final manuscript version.
- HIGH MISSING_CORRESPONDING_AUTHOR_APPROVAL: Corresponding author approval is missing or tied to an older manuscript hash.
Remediation: Collect a fresh corresponding-author approval after the final edit.
- MEDIUM COAUTHOR_APPROVAL_DRIFT: Missing fresh cover-letter approval from author-2, author-3.
Remediation: Route the final cover-letter packet to every coauthor who is listed on the manuscript.
- MEDIUM CLAIM_MISMATCH_NOVELTY: Cover-letter novelty statement diverges from the manuscript claim.
Remediation: Align cover-letter statements with the final manuscript disclosure text before export.
- HIGH CLAIM_MISMATCH_ETHICS: Cover-letter ethics statement diverges from the manuscript claim.
Remediation: Align cover-letter statements with the final manuscript disclosure text before export.
- MEDIUM CLAIM_MISMATCH_DATAAVAILABILITY: Cover-letter dataAvailability statement diverges from the manuscript claim.
Remediation: Align cover-letter statements with the final manuscript disclosure text before export.
- HIGH CLAIM_MISMATCH_CONFLICTS: Cover-letter conflicts statement diverges from the manuscript claim.
Remediation: Align cover-letter statements with the final manuscript disclosure text before export.
- HIGH PRIVATE_COMMENT_LEAK: Cover-letter export includes private reviewer or collaborator notes.
Remediation: Redact private comments and rebuild the editor-facing correspondence packet.
8 changes: 8 additions & 0 deletions collab-cover-letter-export-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions collab-cover-letter-export-guard/src/guard.js
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading