diff --git a/collab-supplementary-materials-guard/package.json b/collab-supplementary-materials-guard/package.json new file mode 100644 index 00000000..92cab4e5 --- /dev/null +++ b/collab-supplementary-materials-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "collab-supplementary-materials-guard", + "version": "1.0.0", + "description": "Deterministic supplementary-material export guard for collaborative scientific editor manuscripts.", + "type": "module", + "scripts": { + "demo": "node scripts/demo.js", + "test": "node --test test/*.test.js" + }, + "license": "MIT" +} diff --git a/collab-supplementary-materials-guard/readme.md b/collab-supplementary-materials-guard/readme.md new file mode 100644 index 00000000..472e77e0 --- /dev/null +++ b/collab-supplementary-materials-guard/readme.md @@ -0,0 +1,37 @@ +# Collaborative Supplementary Materials Guard + +This module is a focused slice for SCIBASE.AI issue #12, Real-time collaborative research editor & interface. + +It validates whether supplementary materials can safely ship with a collaborative manuscript export. The guard is intentionally separate from broad editor foundations, notebook execution queues, data-availability statements, statistics wording checks, alt-text accessibility, terminology governance, and table formula integrity. + +## What It Checks + +- Every exported supplement is cited from the manuscript. +- Exported supplement hashes match the current source artifact hashes. +- Notebook-derived supplements have fresh execution evidence. +- Restricted-data supplements have approved redaction review. +- Private reviewer or collaborator notes are not included in public exports. +- Blocking supplement comments are resolved or waived. +- Publication licenses are approved. +- Corresponding author and data steward approvals are present. +- Supplement sections are locked or snapshotted before export. + +## Decisions + +- `hold`: one or more blocker findings must be fixed before export. +- `revise`: no blockers, but warning-level publication readiness gaps remain. +- `export-ready`: the supplement packet is cited, fresh, approved, redacted, and locked. + +## Run + +```bash +npm test +npm run demo +``` + +## Reviewer Artifacts + +- `reports/supplementary-materials-manifest.json` +- `reports/supplementary-materials-report.md` +- `reports/summary.svg` +- `reports/supplementary-materials-demo.mp4` diff --git a/collab-supplementary-materials-guard/reports/summary.svg b/collab-supplementary-materials-guard/reports/summary.svg new file mode 100644 index 00000000..1dda8727 --- /dev/null +++ b/collab-supplementary-materials-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + + + Collaborative Supplementary Materials Guard + SCIBASE.AI issue #12: real-time editor export readiness + + + 3 supplements + figure, table, protocol + + 6 blockers + stale, leaked, uncited + + 3 warnings + license, approvals, locks + + + Checks manuscript citations, export hashes, notebook freshness, redaction review, + private-note leakage, blocking comments, licenses, approvals, and section locks. + Decision: HOLD + Regenerate stale supplements and resolve reviewer/data-steward gates before export. + + diff --git a/collab-supplementary-materials-guard/reports/summary.svg.png b/collab-supplementary-materials-guard/reports/summary.svg.png new file mode 100644 index 00000000..9c7ddce8 Binary files /dev/null and b/collab-supplementary-materials-guard/reports/summary.svg.png differ diff --git a/collab-supplementary-materials-guard/reports/supplementary-materials-demo.mp4 b/collab-supplementary-materials-guard/reports/supplementary-materials-demo.mp4 new file mode 100644 index 00000000..8dbb8266 Binary files /dev/null and b/collab-supplementary-materials-guard/reports/supplementary-materials-demo.mp4 differ diff --git a/collab-supplementary-materials-guard/reports/supplementary-materials-manifest.json b/collab-supplementary-materials-guard/reports/supplementary-materials-manifest.json new file mode 100644 index 00000000..2f66cea9 --- /dev/null +++ b/collab-supplementary-materials-guard/reports/supplementary-materials-manifest.json @@ -0,0 +1,99 @@ +{ + "generatedAt": "2026-06-05T15:10:00.000Z", + "manuscriptId": "ms-collab-neuro-042", + "decision": "hold", + "supplements": [ + { + "id": "supp-figure-1", + "type": "figure-panel", + "sourceHash": "sha256:figure-current", + "exportHash": "sha256:figure-current", + "referenced": true, + "lockedAtExport": true, + "license": "CC-BY-4.0" + }, + { + "id": "supp-table-2", + "type": "csv-table", + "sourceHash": "sha256:table-current", + "exportHash": "sha256:table-stale", + "referenced": true, + "lockedAtExport": false, + "license": null + }, + { + "id": "supp-protocol-3", + "type": "protocol-pdf", + "sourceHash": "sha256:protocol-current", + "exportHash": "sha256:protocol-current", + "referenced": false, + "lockedAtExport": true, + "license": "institutional-approved" + } + ], + "findings": [ + { + "severity": "blocker", + "code": "stale-export-hash", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 export hash does not match the current source hash.", + "remediation": "Regenerate the exported supplement from the current source artifact before publication export." + }, + { + "severity": "blocker", + "code": "stale-notebook-output", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 was generated from a notebook with stale or missing execution evidence.", + "remediation": "Re-run the notebook, attach the execution digest, and rebuild the supplement export." + }, + { + "severity": "blocker", + "code": "missing-redaction-review", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 contains restricted data without an approved redaction review.", + "remediation": "Hold export until restricted fields are redacted and the data steward approves the redaction review." + }, + { + "severity": "blocker", + "code": "private-reviewer-note-leak", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 includes private reviewer or collaborator notes in the public export.", + "remediation": "Strip private notes from the export bundle and regenerate the artifact manifest." + }, + { + "severity": "blocker", + "code": "unresolved-blocking-comment", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 has 1 unresolved blocking comment(s).", + "remediation": "Resolve or explicitly waive blocking supplement comments before export." + }, + { + "severity": "warning", + "code": "license-needs-review", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 has missing or unsupported license metadata.", + "remediation": "Attach an approved publication license or route the supplement for institutional licensing review." + }, + { + "severity": "warning", + "code": "missing-export-approval", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 is missing export approval from: data-steward.", + "remediation": "Collect the missing collaborator approvals before marking the manuscript export-ready." + }, + { + "severity": "warning", + "code": "unlocked-supplement-section", + "supplementId": "supp-table-2", + "message": "Supplement supp-table-2 remains editable while the export packet is being prepared.", + "remediation": "Lock the supplement section or create a named version snapshot before export." + }, + { + "severity": "blocker", + "code": "unreferenced-supplement", + "supplementId": "supp-protocol-3", + "message": "Supplement supp-protocol-3 is present in the export packet but not cited by the manuscript.", + "remediation": "Add a stable manuscript citation for the supplement or remove it from the export bundle." + } + ] +} diff --git a/collab-supplementary-materials-guard/reports/supplementary-materials-report.md b/collab-supplementary-materials-guard/reports/supplementary-materials-report.md new file mode 100644 index 00000000..202f340e --- /dev/null +++ b/collab-supplementary-materials-guard/reports/supplementary-materials-report.md @@ -0,0 +1,35 @@ +# Supplementary Materials Export Report + +Generated: 2026-06-05T15:10:00.000Z + +Manuscript: `ms-collab-neuro-042` + +Decision: `hold` + +## Summary + +- Supplements checked: 3 +- Blockers: 6 +- Warnings: 3 + +## Release Blockers + +- `supp-table-2`: exported hash is stale against the current source artifact. +- `supp-table-2`: notebook-derived output is stale or missing execution evidence. +- `supp-table-2`: restricted data lacks approved redaction review. +- `supp-table-2`: private reviewer notes are included in the public export. +- `supp-table-2`: one blocking supplement comment is still unresolved. +- `supp-protocol-3`: supplement is included in the export packet but not cited by the manuscript. + +## Required Remediation + +1. Re-run notebook-backed supplementary materials and regenerate exports. +2. Complete data-steward redaction review for restricted tables. +3. Strip private reviewer notes from public artifacts. +4. Resolve blocking comments or record explicit waiver evidence. +5. Add stable manuscript citations for every exported supplement. +6. Attach approved license metadata and lock supplement sections before export. + +## Scope Mapping + +This supports SCIBASE.AI issue #12 by adding a publication-export readiness guard inside the real-time collaborative editor workflow for scientific manuscript supplements. diff --git a/collab-supplementary-materials-guard/scripts/demo.js b/collab-supplementary-materials-guard/scripts/demo.js new file mode 100644 index 00000000..7f151d35 --- /dev/null +++ b/collab-supplementary-materials-guard/scripts/demo.js @@ -0,0 +1,13 @@ +import { + buildSupplementManifest, + evaluateSupplementaryExport, + samplePackets, +} from '../src/index.js'; + +const blockedEvaluation = evaluateSupplementaryExport(samplePackets.blocked); +const readyEvaluation = evaluateSupplementaryExport(samplePackets.ready); + +console.log(JSON.stringify({ + blocked: buildSupplementManifest(samplePackets.blocked, blockedEvaluation), + ready: buildSupplementManifest(samplePackets.ready, readyEvaluation), +}, null, 2)); diff --git a/collab-supplementary-materials-guard/src/index.js b/collab-supplementary-materials-guard/src/index.js new file mode 100644 index 00000000..82117f55 --- /dev/null +++ b/collab-supplementary-materials-guard/src/index.js @@ -0,0 +1,281 @@ +const ACCEPTED_LICENSES = new Set(['cc-by-4.0', 'cc0-1.0', 'mit', 'apache-2.0', 'institutional-approved']); +const REQUIRED_APPROVAL_ROLES = ['corresponding-author', 'data-steward']; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function text(value) { + return String(value || '').trim(); +} + +function idSet(values) { + return new Set(list(values).map((value) => text(value)).filter(Boolean)); +} + +function addFinding(findings, severity, code, message, remediation, supplementId) { + findings.push({ severity, code, supplementId, message, remediation }); +} + +function approvalRoles(supplement) { + return new Set(list(supplement.approvals).filter((approval) => approval.approved).map((approval) => approval.role)); +} + +function blockingCommentsFor(comments, supplementId) { + return list(comments).filter((comment) => ( + comment.supplementId === supplementId + && comment.blocking + && comment.status !== 'resolved' + )); +} + +export function evaluateSupplementaryExport(packet) { + const findings = []; + const referencedIds = idSet(packet.manuscript?.supplementReferences); + const supplements = list(packet.supplements); + const comments = list(packet.comments); + + for (const supplement of supplements) { + const supplementId = text(supplement.id); + const roles = approvalRoles(supplement); + const missingRoles = REQUIRED_APPROVAL_ROLES.filter((role) => !roles.has(role)); + const unresolved = blockingCommentsFor(comments, supplementId); + + if (!referencedIds.has(supplementId)) { + addFinding( + findings, + 'blocker', + 'unreferenced-supplement', + `Supplement ${supplementId} is present in the export packet but not cited by the manuscript.`, + 'Add a stable manuscript citation for the supplement or remove it from the export bundle.', + supplementId + ); + } + + if (text(supplement.sourceHash) !== text(supplement.exportHash)) { + addFinding( + findings, + 'blocker', + 'stale-export-hash', + `Supplement ${supplementId} export hash does not match the current source hash.`, + 'Regenerate the exported supplement from the current source artifact before publication export.', + supplementId + ); + } + + if (supplement.generatedFromNotebook && !supplement.notebookExecutionFresh) { + addFinding( + findings, + 'blocker', + 'stale-notebook-output', + `Supplement ${supplementId} was generated from a notebook with stale or missing execution evidence.`, + 'Re-run the notebook, attach the execution digest, and rebuild the supplement export.', + supplementId + ); + } + + if (supplement.containsRestrictedData && !supplement.redactionReview?.approved) { + addFinding( + findings, + 'blocker', + 'missing-redaction-review', + `Supplement ${supplementId} contains restricted data without an approved redaction review.`, + 'Hold export until restricted fields are redacted and the data steward approves the redaction review.', + supplementId + ); + } + + if (supplement.privateReviewerNotesIncluded) { + addFinding( + findings, + 'blocker', + 'private-reviewer-note-leak', + `Supplement ${supplementId} includes private reviewer or collaborator notes in the public export.`, + 'Strip private notes from the export bundle and regenerate the artifact manifest.', + supplementId + ); + } + + if (unresolved.length > 0) { + addFinding( + findings, + 'blocker', + 'unresolved-blocking-comment', + `Supplement ${supplementId} has ${unresolved.length} unresolved blocking comment(s).`, + 'Resolve or explicitly waive blocking supplement comments before export.', + supplementId + ); + } + + if (!ACCEPTED_LICENSES.has(text(supplement.license).toLowerCase())) { + addFinding( + findings, + 'warning', + 'license-needs-review', + `Supplement ${supplementId} has missing or unsupported license metadata.`, + 'Attach an approved publication license or route the supplement for institutional licensing review.', + supplementId + ); + } + + if (missingRoles.length > 0) { + addFinding( + findings, + 'warning', + 'missing-export-approval', + `Supplement ${supplementId} is missing export approval from: ${missingRoles.join(', ')}.`, + 'Collect the missing collaborator approvals before marking the manuscript export-ready.', + supplementId + ); + } + + if (!supplement.lockedAtExport) { + addFinding( + findings, + 'warning', + 'unlocked-supplement-section', + `Supplement ${supplementId} remains editable while the export packet is being prepared.`, + 'Lock the supplement section or create a named version snapshot before export.', + supplementId + ); + } + } + + const blockers = findings.filter((finding) => finding.severity === 'blocker'); + const warnings = findings.filter((finding) => finding.severity === 'warning'); + + return { + manuscriptId: packet.manuscript?.id, + supplementCount: supplements.length, + decision: blockers.length > 0 ? 'hold' : warnings.length > 0 ? 'revise' : 'export-ready', + blockers: blockers.length, + warnings: warnings.length, + findings, + }; +} + +export function buildSupplementManifest(packet, evaluation) { + return { + generatedAt: '2026-06-05T15:10:00.000Z', + manuscriptId: evaluation.manuscriptId, + decision: evaluation.decision, + supplements: list(packet.supplements).map((supplement) => ({ + id: supplement.id, + type: supplement.type, + sourceHash: supplement.sourceHash, + exportHash: supplement.exportHash, + referenced: idSet(packet.manuscript?.supplementReferences).has(supplement.id), + lockedAtExport: Boolean(supplement.lockedAtExport), + license: supplement.license || null, + })), + findings: evaluation.findings, + }; +} + +export const samplePackets = { + blocked: { + manuscript: { + id: 'ms-collab-neuro-042', + supplementReferences: ['supp-figure-1', 'supp-table-2'], + }, + supplements: [ + { + id: 'supp-figure-1', + type: 'figure-panel', + sourceHash: 'sha256:figure-current', + exportHash: 'sha256:figure-current', + generatedFromNotebook: true, + notebookExecutionFresh: true, + containsRestrictedData: false, + redactionReview: { approved: true }, + privateReviewerNotesIncluded: false, + license: 'CC-BY-4.0', + lockedAtExport: true, + approvals: [ + { role: 'corresponding-author', approved: true }, + { role: 'data-steward', approved: true }, + ], + }, + { + id: 'supp-table-2', + type: 'csv-table', + sourceHash: 'sha256:table-current', + exportHash: 'sha256:table-stale', + generatedFromNotebook: true, + notebookExecutionFresh: false, + containsRestrictedData: true, + redactionReview: { approved: false }, + privateReviewerNotesIncluded: true, + license: '', + lockedAtExport: false, + approvals: [{ role: 'corresponding-author', approved: true }], + }, + { + id: 'supp-protocol-3', + type: 'protocol-pdf', + sourceHash: 'sha256:protocol-current', + exportHash: 'sha256:protocol-current', + generatedFromNotebook: false, + notebookExecutionFresh: true, + containsRestrictedData: false, + redactionReview: { approved: true }, + privateReviewerNotesIncluded: false, + license: 'institutional-approved', + lockedAtExport: true, + approvals: [ + { role: 'corresponding-author', approved: true }, + { role: 'data-steward', approved: true }, + ], + }, + ], + comments: [ + { supplementId: 'supp-table-2', blocking: true, status: 'open' }, + { supplementId: 'supp-figure-1', blocking: false, status: 'open' }, + ], + }, + ready: { + manuscript: { + id: 'ms-collab-neuro-042', + supplementReferences: ['supp-figure-1', 'supp-table-2'], + }, + supplements: [ + { + id: 'supp-figure-1', + type: 'figure-panel', + sourceHash: 'sha256:figure-current', + exportHash: 'sha256:figure-current', + generatedFromNotebook: true, + notebookExecutionFresh: true, + containsRestrictedData: false, + redactionReview: { approved: true }, + privateReviewerNotesIncluded: false, + license: 'cc-by-4.0', + lockedAtExport: true, + approvals: [ + { role: 'corresponding-author', approved: true }, + { role: 'data-steward', approved: true }, + ], + }, + { + id: 'supp-table-2', + type: 'csv-table', + sourceHash: 'sha256:table-current', + exportHash: 'sha256:table-current', + generatedFromNotebook: true, + notebookExecutionFresh: true, + containsRestrictedData: true, + redactionReview: { approved: true }, + privateReviewerNotesIncluded: false, + license: 'cc0-1.0', + lockedAtExport: true, + approvals: [ + { role: 'corresponding-author', approved: true }, + { role: 'data-steward', approved: true }, + ], + }, + ], + comments: [ + { supplementId: 'supp-table-2', blocking: true, status: 'resolved' }, + ], + }, +}; diff --git a/collab-supplementary-materials-guard/test/index.test.js b/collab-supplementary-materials-guard/test/index.test.js new file mode 100644 index 00000000..e496a396 --- /dev/null +++ b/collab-supplementary-materials-guard/test/index.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildSupplementManifest, + evaluateSupplementaryExport, + samplePackets, +} from '../src/index.js'; + +test('holds export when a supplement is stale, unreconciled, or leaking review notes', () => { + const result = evaluateSupplementaryExport(samplePackets.blocked); + + assert.equal(result.decision, 'hold'); + assert.equal(result.blockers, 6); + assert.ok(result.findings.some((finding) => finding.code === 'stale-export-hash')); + assert.ok(result.findings.some((finding) => finding.code === 'stale-notebook-output')); + assert.ok(result.findings.some((finding) => finding.code === 'missing-redaction-review')); + assert.ok(result.findings.some((finding) => finding.code === 'private-reviewer-note-leak')); + assert.ok(result.findings.some((finding) => finding.code === 'unresolved-blocking-comment')); + assert.ok(result.findings.some((finding) => finding.code === 'unreferenced-supplement')); +}); + +test('returns revise when only approval, license, or lock warnings remain', () => { + const packet = { + ...samplePackets.ready, + supplements: [ + { + ...samplePackets.ready.supplements[0], + license: 'custom-lab-license', + lockedAtExport: false, + approvals: [{ role: 'corresponding-author', approved: true }], + }, + ], + comments: [], + }; + + const result = evaluateSupplementaryExport(packet); + + assert.equal(result.decision, 'revise'); + assert.equal(result.blockers, 0); + assert.equal(result.warnings, 3); + assert.deepEqual(result.findings.map((finding) => finding.code), [ + 'license-needs-review', + 'missing-export-approval', + 'unlocked-supplement-section', + ]); +}); + +test('marks a manuscript export-ready when supplements are cited, locked, fresh, and approved', () => { + const result = evaluateSupplementaryExport(samplePackets.ready); + + assert.equal(result.decision, 'export-ready'); + assert.equal(result.blockers, 0); + assert.equal(result.warnings, 0); + assert.deepEqual(result.findings, []); +}); + +test('builds a deterministic reviewer manifest', () => { + const evaluation = evaluateSupplementaryExport(samplePackets.blocked); + const manifest = buildSupplementManifest(samplePackets.blocked, evaluation); + + assert.equal(manifest.generatedAt, '2026-06-05T15:10:00.000Z'); + assert.equal(manifest.manuscriptId, 'ms-collab-neuro-042'); + assert.equal(manifest.decision, 'hold'); + assert.equal(manifest.supplements.length, 3); + assert.equal(manifest.supplements[0].referenced, true); + assert.equal(manifest.supplements[2].referenced, false); +});