diff --git a/research-editor-authorship-governance/README.md b/research-editor-authorship-governance/README.md new file mode 100644 index 0000000..8f1c8f0 --- /dev/null +++ b/research-editor-authorship-governance/README.md @@ -0,0 +1,23 @@ +# Research Editor Authorship Governance + +This module adds a focused real-time collaborative research editor slice for authorship, contribution, and submission approval governance. It is designed as a final submission gate for collaborative manuscripts. + +## What it checks + +- CRediT contribution role coverage +- Author approvals tied to the current manuscript hash +- Conflict-of-interest disclosure status +- Non-author contributors who edited content without acknowledgement +- Blocking comments and required suggestions that remain open +- Figure, table, and equation owner approvals +- Submission readiness actions and deterministic audit digest + +## Run locally + +```bash +npm run check +npm test +npm run demo +``` + +The sample data intentionally blocks submission because one author has not approved the current manuscript, a COI disclosure is missing, a non-author contributor is unresolved, and blocking editor review objects are still open. diff --git a/research-editor-authorship-governance/demo.js b/research-editor-authorship-governance/demo.js new file mode 100644 index 0000000..07d69d8 --- /dev/null +++ b/research-editor-authorship-governance/demo.js @@ -0,0 +1,25 @@ +"use strict"; + +const sampleBundle = require("./sample-data.json"); +const { + analyzeAuthorshipGovernance, +} = require("./src/research-editor-authorship-governance"); + +const result = analyzeAuthorshipGovernance(sampleBundle); + +console.log(`Manuscript: ${result.title}`); +console.log(`Target journal: ${result.targetJournal}`); +console.log(`Decision: ${result.decision}`); +console.log(`Audit digest: ${result.auditDigest}`); +console.log(""); +console.log("Submission actions:"); +for (const action of result.submissionActions) { + console.log(`- ${action}`); +} +console.log(""); +console.log("Findings:"); +for (const finding of result.findings) { + console.log(`- [${finding.severity}] ${finding.id}: ${finding.title}`); + console.log(` detail: ${finding.detail}`); + console.log(` remediation: ${finding.remediation}`); +} diff --git a/research-editor-authorship-governance/docs/authorship-governance-demo.mp4 b/research-editor-authorship-governance/docs/authorship-governance-demo.mp4 new file mode 100644 index 0000000..028b4d3 Binary files /dev/null and b/research-editor-authorship-governance/docs/authorship-governance-demo.mp4 differ diff --git a/research-editor-authorship-governance/docs/requirement-map.md b/research-editor-authorship-governance/docs/requirement-map.md new file mode 100644 index 0000000..c8ecff4 --- /dev/null +++ b/research-editor-authorship-governance/docs/requirement-map.md @@ -0,0 +1,15 @@ +# Requirement Map + +| Collaborative editor requirement | Implementation | +| --- | --- | +| Scientific document blocks | `manuscript.blocks` models sections, figures, and tables with owner and edit metadata. | +| Comments and suggestions | Blocking comments and required suggestions must be resolved before submission readiness. | +| Version history and approval | Author approvals are checked against the current manuscript hash, preventing stale approval reuse. | +| Task workflow | Findings produce actionable submission tasks such as collecting approvals, resolving suggestions, and reviewing contributors. | +| Collaboration governance | Non-author contributors who edited manuscript content are surfaced for authorship or acknowledgement review. | +| Publication readiness | CRediT role coverage, COI disclosures, and asset owner approvals are validated before journal export. | +| Auditability | Results include findings, actions, manuscript hash, and a deterministic `sha256` audit digest. | + +## Demo Video + +The PR includes `docs/authorship-governance-demo.mp4`, a real terminal walkthrough running the local check, test, and demo scripts. diff --git a/research-editor-authorship-governance/package.json b/research-editor-authorship-governance/package.json new file mode 100644 index 0000000..4070803 --- /dev/null +++ b/research-editor-authorship-governance/package.json @@ -0,0 +1,12 @@ +{ + "name": "research-editor-authorship-governance", + "version": "1.0.0", + "description": "Authorship and submission approval governance for collaborative research editors.", + "main": "src/research-editor-authorship-governance.js", + "scripts": { + "check": "node --check src/research-editor-authorship-governance.js && node --check test.js && node --check demo.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/research-editor-authorship-governance/sample-data.json b/research-editor-authorship-governance/sample-data.json new file mode 100644 index 0000000..fc8e2b3 --- /dev/null +++ b/research-editor-authorship-governance/sample-data.json @@ -0,0 +1,88 @@ +{ + "now": "2026-05-15T00:00:00.000Z", + "manuscript": { + "id": "ms-cell-atlas-submission", + "title": "Cross-lab validation of a cell atlas workflow", + "targetJournal": "Journal of Reproducible Biology", + "currentHash": "sha256:8c7f71060072b1a5f135ad78a11a6fcd3cb4000f4028720df6b99592dc401f4c", + "blocks": [ + { + "id": "section-methods", + "type": "section", + "lastEditedBy": ["author-mira", "contrib-dan"] + }, + { + "id": "fig-2", + "type": "figure", + "ownerAuthorId": "author-leo", + "lastEditedBy": ["author-leo"] + }, + { + "id": "table-1", + "type": "table", + "ownerAuthorId": "author-mira", + "lastEditedBy": ["author-mira"] + } + ] + }, + "authors": [ + { + "id": "author-mira", + "name": "Mira Chen", + "creditRoles": ["Conceptualization", "Methodology", "Writing - original draft"], + "approvals": [ + { + "manuscriptHash": "sha256:8c7f71060072b1a5f135ad78a11a6fcd3cb4000f4028720df6b99592dc401f4c", + "status": "approved", + "approvedAt": "2026-05-14T10:00:00.000Z" + } + ], + "coiDisclosure": { + "status": "submitted", + "submittedAt": "2026-05-14T09:00:00.000Z" + }, + "assetApprovals": ["table-1"] + }, + { + "id": "author-leo", + "name": "Leo Alvarez", + "creditRoles": ["Data curation", "Formal analysis"], + "approvals": [ + { + "manuscriptHash": "sha256:old-draft", + "status": "approved", + "approvedAt": "2026-05-01T10:00:00.000Z" + } + ], + "coiDisclosure": { + "status": "missing" + }, + "assetApprovals": [] + } + ], + "contributors": [ + { + "id": "contrib-dan", + "name": "Dan Reviewer", + "acknowledgementStatus": "unresolved" + } + ], + "comments": [ + { + "id": "comment-24", + "blockId": "section-methods", + "status": "open", + "blocking": true, + "text": "Clarify preprocessing parameter lock before submission." + } + ], + "suggestions": [ + { + "id": "suggestion-12", + "blockId": "fig-2", + "status": "open", + "requiredBeforeSubmission": true, + "text": "Add colorblind-safe legend labels." + } + ] +} diff --git a/research-editor-authorship-governance/src/research-editor-authorship-governance.js b/research-editor-authorship-governance/src/research-editor-authorship-governance.js new file mode 100644 index 0000000..bbe1b05 --- /dev/null +++ b/research-editor-authorship-governance/src/research-editor-authorship-governance.js @@ -0,0 +1,236 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const REQUIRED_CREDIT_ROLES = [ + "Conceptualization", + "Methodology", + "Data curation", + "Formal analysis", + "Writing - original draft", + "Writing - review & editing", +]; + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function stableHash(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function requireFields(object, fields, label) { + const missing = fields.filter((field) => object[field] === undefined || object[field] === null); + if (missing.length > 0) throw new Error(`${label} is missing required field(s): ${missing.join(", ")}`); +} + +function finding(severity, id, title, detail, remediation, targetIds = []) { + return { severity, id, title, detail, remediation, targetIds }; +} + +function parseDate(value) { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date: ${value}`); + return parsed; +} + +function assertBundle(bundle) { + requireFields(bundle, ["manuscript", "authors", "contributors", "comments", "suggestions"], "authorship governance bundle"); + requireFields(bundle.manuscript, ["id", "title", "currentHash", "targetJournal", "blocks"], "manuscript"); +} + +function evaluateCreditCoverage(authors) { + const findings = []; + const coveredRoles = new Set(authors.flatMap((author) => author.creditRoles || [])); + + for (const role of REQUIRED_CREDIT_ROLES) { + if (!coveredRoles.has(role)) { + findings.push(finding( + "blocker", + "credit-role-uncovered", + "Required CRediT contribution role is uncovered", + `${role} has no assigned author.`, + "Assign an author to every required contribution role before submission.", + [role], + )); + } + } + + return findings; +} + +function evaluateAuthorApprovals(authors, manuscript) { + const findings = []; + + for (const author of authors) { + const approved = author.approvals.some((approval) => { + return approval.manuscriptHash === manuscript.currentHash && approval.status === "approved"; + }); + if (!approved) { + findings.push(finding( + "blocker", + "author-approval-missing", + "Author has not approved the current manuscript hash", + `${author.name} has not approved ${manuscript.currentHash}.`, + "Collect current-version approval from every listed author.", + [author.id], + )); + } + + if (!author.coiDisclosure || author.coiDisclosure.status !== "submitted") { + findings.push(finding( + "blocker", + "coi-disclosure-missing", + "Author conflict-of-interest disclosure is missing", + `${author.name} has no submitted COI disclosure.`, + "Require a submitted COI disclosure before final submission.", + [author.id], + )); + } + } + + return findings; +} + +function evaluateContributorGaps(authors, contributors, manuscript) { + const findings = []; + const authorIds = new Set(authors.map((author) => author.id)); + const blockEditorIds = new Set(manuscript.blocks.flatMap((block) => block.lastEditedBy || [])); + + for (const contributor of contributors) { + if (authorIds.has(contributor.id)) continue; + if (blockEditorIds.has(contributor.id) && contributor.acknowledgementStatus !== "acknowledged") { + findings.push(finding( + "warning", + "contributor-acknowledgement-missing", + "Non-author contributor edited manuscript content without acknowledgement", + `${contributor.name} edited manuscript blocks but is not an author or acknowledged contributor.`, + "Resolve whether the contributor should be an author or acknowledged before submission.", + [contributor.id], + )); + } + } + + return findings; +} + +function evaluateReviewObjects(comments, suggestions) { + const findings = []; + const blockingComments = comments.filter((comment) => comment.status === "open" && comment.blocking === true); + const blockingSuggestions = suggestions.filter((suggestion) => { + return suggestion.status === "open" && suggestion.requiredBeforeSubmission === true; + }); + + if (blockingComments.length > 0) { + findings.push(finding( + "blocker", + "blocking-comments-open", + "Blocking editor comments remain open", + `${blockingComments.length} blocking comment(s) must be resolved before submission.`, + "Resolve or explicitly waive every blocking comment.", + blockingComments.map((comment) => comment.id), + )); + } + + if (blockingSuggestions.length > 0) { + findings.push(finding( + "blocker", + "required-suggestions-open", + "Required suggestions remain open", + `${blockingSuggestions.length} required suggestion(s) are still pending.`, + "Accept, reject with rationale, or waive required suggestions before submission.", + blockingSuggestions.map((suggestion) => suggestion.id), + )); + } + + return findings; +} + +function evaluateFigureTableApprovals(manuscript, authors) { + const findings = []; + const authorApprovalById = new Map(authors.map((author) => [author.id, new Set(author.assetApprovals || [])])); + + for (const block of manuscript.blocks) { + if (!["figure", "table", "equation"].includes(block.type)) continue; + const ownerApprovals = authorApprovalById.get(block.ownerAuthorId); + if (!ownerApprovals || !ownerApprovals.has(block.id)) { + findings.push(finding( + "warning", + "asset-owner-approval-missing", + "Scientific asset lacks owner approval", + `${block.type} ${block.id} has no approval from owner ${block.ownerAuthorId}.`, + "Collect owner approval for figures, tables, and equations before journal export.", + [block.id], + )); + } + } + + return findings; +} + +function buildSubmissionActions(findings) { + const actions = []; + if (findings.some((item) => item.id === "author-approval-missing")) actions.push("collect-author-approvals"); + if (findings.some((item) => item.id === "coi-disclosure-missing")) actions.push("collect-coi-disclosures"); + if (findings.some((item) => item.id === "credit-role-uncovered")) actions.push("assign-credit-roles"); + if (findings.some((item) => item.id === "blocking-comments-open")) actions.push("resolve-blocking-comments"); + if (findings.some((item) => item.id === "required-suggestions-open")) actions.push("resolve-required-suggestions"); + if (findings.some((item) => item.id === "contributor-acknowledgement-missing")) actions.push("review-non-author-contributors"); + if (findings.some((item) => item.id === "asset-owner-approval-missing")) actions.push("collect-asset-owner-approvals"); + return actions; +} + +function decide(findings) { + if (findings.some((item) => item.severity === "blocker")) return "submission-blocked"; + if (findings.some((item) => item.severity === "warning")) return "manual-review"; + return "ready-for-submission"; +} + +function analyzeAuthorshipGovernance(bundle, options = {}) { + assertBundle(bundle); + const now = parseDate(options.now || bundle.now || new Date().toISOString()); + const findings = [ + ...evaluateCreditCoverage(bundle.authors), + ...evaluateAuthorApprovals(bundle.authors, bundle.manuscript), + ...evaluateContributorGaps(bundle.authors, bundle.contributors, bundle.manuscript), + ...evaluateReviewObjects(bundle.comments, bundle.suggestions), + ...evaluateFigureTableApprovals(bundle.manuscript, bundle.authors), + ]; + const submissionActions = buildSubmissionActions(findings); + const decision = decide(findings); + const auditDigest = stableHash({ + manuscriptId: bundle.manuscript.id, + manuscriptHash: bundle.manuscript.currentHash, + decision, + findings, + submissionActions, + evaluatedAt: now.toISOString(), + }); + + return { + manuscriptId: bundle.manuscript.id, + title: bundle.manuscript.title, + targetJournal: bundle.manuscript.targetJournal, + manuscriptHash: bundle.manuscript.currentHash, + evaluatedAt: now.toISOString(), + decision, + findings, + submissionActions, + auditDigest: `sha256:${auditDigest}`, + }; +} + +module.exports = { + REQUIRED_CREDIT_ROLES, + analyzeAuthorshipGovernance, + stableHash, + stableStringify, +}; diff --git a/research-editor-authorship-governance/test.js b/research-editor-authorship-governance/test.js new file mode 100644 index 0000000..9efa24f --- /dev/null +++ b/research-editor-authorship-governance/test.js @@ -0,0 +1,54 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const sampleBundle = require("./sample-data.json"); +const { + analyzeAuthorshipGovernance, + stableHash, +} = require("./src/research-editor-authorship-governance"); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function ids(result) { + return new Set(result.findings.map((finding) => finding.id)); +} + +const blocked = analyzeAuthorshipGovernance(sampleBundle); +const blockedIds = ids(blocked); + +assert.equal(blocked.decision, "submission-blocked"); +assert.match(blocked.auditDigest, /^sha256:[a-f0-9]{64}$/); +assert(blockedIds.has("credit-role-uncovered")); +assert(blockedIds.has("author-approval-missing")); +assert(blockedIds.has("coi-disclosure-missing")); +assert(blockedIds.has("contributor-acknowledgement-missing")); +assert(blockedIds.has("blocking-comments-open")); +assert(blockedIds.has("required-suggestions-open")); +assert(blockedIds.has("asset-owner-approval-missing")); +assert(blocked.submissionActions.includes("collect-author-approvals")); + +const readyBundle = clone(sampleBundle); +readyBundle.authors[1].creditRoles.push("Writing - review & editing"); +readyBundle.authors[1].approvals = [ + { + manuscriptHash: readyBundle.manuscript.currentHash, + status: "approved", + approvedAt: "2026-05-14T12:00:00.000Z", + }, +]; +readyBundle.authors[1].coiDisclosure = { status: "submitted", submittedAt: "2026-05-14T12:10:00.000Z" }; +readyBundle.authors[1].assetApprovals = ["fig-2"]; +readyBundle.contributors[0].acknowledgementStatus = "acknowledged"; +readyBundle.comments[0].status = "resolved"; +readyBundle.suggestions[0].status = "accepted"; + +const ready = analyzeAuthorshipGovernance(readyBundle); +assert.equal(ready.decision, "ready-for-submission"); +assert.equal(ready.findings.length, 0); +assert.equal(ready.submissionActions.length, 0); +assert.notEqual(blocked.auditDigest, ready.auditDigest); +assert.equal(stableHash({ b: true, a: 4 }), stableHash({ a: 4, b: true })); + +console.log("research editor authorship governance tests passed");