diff --git a/project-contribution-credit-gate/README.md b/project-contribution-credit-gate/README.md new file mode 100644 index 00000000..a550b844 --- /dev/null +++ b/project-contribution-credit-gate/README.md @@ -0,0 +1,34 @@ +# Project Contribution Credit Gate + +Self-contained review slice for issue #11, User & Project Management. + +This module validates contribution credit packets before a scientific project is published. It focuses on authorship consent, CRediT role coverage, and artifact contributor omissions rather than broad RBAC, invitation expiry, service-account governance, break-glass access, deletion/erasure, or funding attribution. + +## What it checks + +- Listed contributors have explicitly consented to the credit statement. +- Credited contributors have at least one CRediT taxonomy role. +- Contributors who touched manuscript, code, data, or result artifacts are credited or routed for review. +- Deterministic reviewer actions are emitted for publication blockers. + +## Files + +- `index.js` - contribution credit evaluator and report builder. +- `sample-data.js` - synthetic project/contributor scenarios. +- `test.js` - Node.js built-in test suite. +- `demo.js` - generates reviewer artifacts in `reports/`. +- `reports/credit-packet.json` - generated machine-readable decisions. +- `reports/credit-report.md` - generated reviewer packet. +- `reports/summary.svg` - generated visual summary. +- `reports/demo.mp4` - short demo video artifact for bounty review. + +## Run + +```bash +node project-contribution-credit-gate/test.js +node project-contribution-credit-gate/demo.js +``` + +## Safety + +The sample data is synthetic. The module performs no network calls, touches no live identity providers, and contains no secrets, tokens, private dashboard data, payout information, ORCID credentials, SAML/OAuth data, or private project records. diff --git a/project-contribution-credit-gate/demo.js b/project-contribution-credit-gate/demo.js new file mode 100644 index 00000000..ea3772c5 --- /dev/null +++ b/project-contribution-credit-gate/demo.js @@ -0,0 +1,49 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const {evaluateContributionCredit, buildCreditReport} = require('./index'); +const {scenarios} = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, {recursive: true}); + +const evaluations = scenarios.map((scenario) => ({ + scenario: scenario.name, + ...evaluateContributionCredit(scenario), +})); + +const approved = evaluations.filter((item) => item.decision === 'approved').length; +const remediation = evaluations.filter((item) => item.decision === 'needs-remediation').length; +const blocked = evaluations.filter((item) => item.decision === 'blocked').length; + +const svg = ` + + Project Contribution Credit Gate + Synthetic authorship and CRediT statement review packet + + + Approved + ${approved} + + + + Needs Remediation + ${remediation} + + + + Blocked + ${blocked} + + Checks: consent, CRediT roles, artifact contributors, credit omissions + Outputs: credit-packet.json, credit-report.md, summary.svg, demo video artifact + Synthetic data only. No live ORCID, SAML, OAuth, profile, or private project calls. + +`; + +fs.writeFileSync(path.join(reportsDir, 'credit-packet.json'), `${JSON.stringify(evaluations, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, 'credit-report.md'), evaluations.map(buildCreditReport).join('\n---\n')); +fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg); + +console.log(`Wrote ${evaluations.length} contribution credit evaluations to ${reportsDir}`); +console.log(`Decision counts: approved=${approved}, remediation=${remediation}, blocked=${blocked}`); diff --git a/project-contribution-credit-gate/index.js b/project-contribution-credit-gate/index.js new file mode 100644 index 00000000..14d436d6 --- /dev/null +++ b/project-contribution-credit-gate/index.js @@ -0,0 +1,118 @@ +function list(value) { + return Array.isArray(value) ? value : []; +} + +function action(type, target, reason) { + return {type, target, reason}; +} + +function unique(values) { + return [...new Set(values)]; +} + +function contributorName(contributorsById, contributorId) { + return contributorsById.get(contributorId)?.name || contributorId; +} + +function evaluateContributionCredit(input) { + const contributors = list(input.contributors); + const statements = list(input.creditStatements); + const artifacts = list(input.projectArtifacts); + const contributorsById = new Map(contributors.map((contributor) => [contributor.id, contributor])); + const statementsById = new Map(statements.map((statement) => [statement.contributorId, statement])); + const blockers = []; + const requiredActions = []; + + const artifactContributorIds = unique(artifacts.flatMap((artifact) => list(artifact.touchedBy))); + + for (const statement of statements) { + const name = contributorName(contributorsById, statement.contributorId); + if (!statement.consented) { + blockers.push(`${name} is listed in the credit statement without consent`); + requiredActions.push(action( + 'collect_credit_consent', + statement.contributorId, + 'publication credit requires explicit contributor consent' + )); + } + if (list(statement.creditRoles).length === 0) { + blockers.push(`${name} has no CRediT roles assigned`); + requiredActions.push(action( + 'assign_credit_roles', + statement.contributorId, + 'credited contributors need at least one CRediT taxonomy role' + )); + } + } + + for (const contributorId of artifactContributorIds) { + if (!statementsById.has(contributorId)) { + const name = contributorName(contributorsById, contributorId); + blockers.push(`${name} contributed to project artifacts but is missing from credit statement`); + requiredActions.push(action( + 'review_omitted_artifact_contributor', + contributorId, + 'artifact contributions must be credited or explicitly excluded with review evidence' + )); + } + } + + const hasConsentBlocker = blockers.some((item) => /without consent/i.test(item)); + const consentedCount = statements.filter((statement) => statement.consented).length; + const decision = hasConsentBlocker + ? 'blocked' + : blockers.length > 0 + ? 'needs-remediation' + : 'approved'; + + return { + projectId: input.projectId, + action: input.action, + generatedAt: input.generatedAt, + decision, + blockers, + requiredActions, + coverage: { + artifactContributorCount: artifactContributorIds.length, + creditedContributorCount: statements.length, + consentCoverage: statements.length === 0 ? 1 : consentedCount / statements.length, + }, + }; +} + +function percent(value) { + return `${Math.round(value * 100)}%`; +} + +function buildCreditReport(result) { + return [ + '# Project Contribution Credit Gate Report', + '', + `Project: ${result.projectId}`, + `Action: ${result.action}`, + `Generated: ${result.generatedAt}`, + `Decision: ${result.decision}`, + '', + '## Coverage', + '', + `Artifact contributors: ${result.coverage.artifactContributorCount}`, + `Credited contributors: ${result.coverage.creditedContributorCount}`, + `Consent coverage: ${percent(result.coverage.consentCoverage)}`, + '', + '## Blockers', + '', + ...(result.blockers.length ? result.blockers.map((item) => `- ${item}`) : ['- None']), + '', + '## Required Actions', + '', + ...(result.requiredActions.length + ? result.requiredActions.map((item) => `- ${item.type}: ${item.target} (${item.reason})`) + : ['- None']), + '', + ].join('\n'); +} + +module.exports = { + evaluateContributionCredit, + buildCreditReport, +}; diff --git a/project-contribution-credit-gate/reports/credit-packet.json b/project-contribution-credit-gate/reports/credit-packet.json new file mode 100644 index 00000000..55d5e8f6 --- /dev/null +++ b/project-contribution-credit-gate/reports/credit-packet.json @@ -0,0 +1,66 @@ +[ + { + "scenario": "consent-block", + "projectId": "project-neuro-imaging", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "blocked", + "blockers": [ + "Mika Rao is listed in the credit statement without consent" + ], + "requiredActions": [ + { + "type": "collect_credit_consent", + "target": "u2", + "reason": "publication credit requires explicit contributor consent" + } + ], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 0.5 + } + }, + { + "scenario": "omitted-contributor-remediation", + "projectId": "project-climate-model", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "needs-remediation", + "blockers": [ + "Jon Bell has no CRediT roles assigned", + "Ren Ito contributed to project artifacts but is missing from credit statement" + ], + "requiredActions": [ + { + "type": "assign_credit_roles", + "target": "u2", + "reason": "credited contributors need at least one CRediT taxonomy role" + }, + { + "type": "review_omitted_artifact_contributor", + "target": "u3", + "reason": "artifact contributions must be credited or explicitly excluded with review evidence" + } + ], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 1 + } + }, + { + "scenario": "approved-credit-packet", + "projectId": "project-approved-credit", + "action": "publish-preprint", + "generatedAt": "2026-05-22T12:30:00Z", + "decision": "approved", + "blockers": [], + "requiredActions": [], + "coverage": { + "artifactContributorCount": 2, + "creditedContributorCount": 2, + "consentCoverage": 1 + } + } +] diff --git a/project-contribution-credit-gate/reports/credit-report.md b/project-contribution-credit-gate/reports/credit-report.md new file mode 100644 index 00000000..4db81d35 --- /dev/null +++ b/project-contribution-credit-gate/reports/credit-report.md @@ -0,0 +1,66 @@ +# Project Contribution Credit Gate Report + +Project: project-neuro-imaging +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: blocked + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 50% + +## Blockers + +- Mika Rao is listed in the credit statement without consent + +## Required Actions + +- collect_credit_consent: u2 (publication credit requires explicit contributor consent) + +--- +# Project Contribution Credit Gate Report + +Project: project-climate-model +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: needs-remediation + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 100% + +## Blockers + +- Jon Bell has no CRediT roles assigned +- Ren Ito contributed to project artifacts but is missing from credit statement + +## Required Actions + +- assign_credit_roles: u2 (credited contributors need at least one CRediT taxonomy role) +- review_omitted_artifact_contributor: u3 (artifact contributions must be credited or explicitly excluded with review evidence) + +--- +# Project Contribution Credit Gate Report + +Project: project-approved-credit +Action: publish-preprint +Generated: 2026-05-22T12:30:00Z +Decision: approved + +## Coverage + +Artifact contributors: 2 +Credited contributors: 2 +Consent coverage: 100% + +## Blockers + +- None + +## Required Actions + +- None diff --git a/project-contribution-credit-gate/reports/demo.mp4 b/project-contribution-credit-gate/reports/demo.mp4 new file mode 100644 index 00000000..08060430 Binary files /dev/null and b/project-contribution-credit-gate/reports/demo.mp4 differ diff --git a/project-contribution-credit-gate/reports/summary.svg b/project-contribution-credit-gate/reports/summary.svg new file mode 100644 index 00000000..1b6b8d55 --- /dev/null +++ b/project-contribution-credit-gate/reports/summary.svg @@ -0,0 +1,23 @@ + + + Project Contribution Credit Gate + Synthetic authorship and CRediT statement review packet + + + Approved + 1 + + + + Needs Remediation + 1 + + + + Blocked + 1 + + Checks: consent, CRediT roles, artifact contributors, credit omissions + Outputs: credit-packet.json, credit-report.md, summary.svg, demo video artifact + Synthetic data only. No live ORCID, SAML, OAuth, profile, or private project calls. + diff --git a/project-contribution-credit-gate/sample-data.js b/project-contribution-credit-gate/sample-data.js new file mode 100644 index 00000000..27c3e1f2 --- /dev/null +++ b/project-contribution-credit-gate/sample-data.js @@ -0,0 +1,60 @@ +const scenarios = [ + { + name: 'consent-block', + projectId: 'project-neuro-imaging', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Ava Chen', role: 'Owner', orcid: '0000-0001-1111-1111', activeMember: true}, + {id: 'u2', name: 'Mika Rao', role: 'Contributor', orcid: '0000-0002-2222-2222', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Writing - original draft'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Data curation'], consented: false, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'data/participants.csv', touchedBy: ['u2']}, + ], + }, + { + name: 'omitted-contributor-remediation', + projectId: 'project-climate-model', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Noor Silva', role: 'Owner', orcid: '0000-0001-3333-3333', activeMember: true}, + {id: 'u2', name: 'Jon Bell', role: 'Contributor', orcid: '0000-0002-4444-4444', activeMember: true}, + {id: 'u3', name: 'Ren Ito', role: 'Reviewer', orcid: '0000-0003-5555-5555', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Supervision'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: [], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'code/model.py', touchedBy: ['u2', 'u3']}, + {path: 'results/forecast.svg', touchedBy: ['u3']}, + ], + }, + { + name: 'approved-credit-packet', + projectId: 'project-approved-credit', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Lina Park', role: 'Owner', orcid: '0000-0001-6666-6666', activeMember: true}, + {id: 'u2', name: 'Samir Okafor', role: 'Contributor', orcid: '0000-0002-7777-7777', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Funding acquisition'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Software', 'Formal analysis'], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'code/analysis.py', touchedBy: ['u2']}, + {path: 'results/table.csv', touchedBy: ['u2']}, + ], + }, +]; + +module.exports = {scenarios}; diff --git a/project-contribution-credit-gate/test.js b/project-contribution-credit-gate/test.js new file mode 100644 index 00000000..c69ba75f --- /dev/null +++ b/project-contribution-credit-gate/test.js @@ -0,0 +1,92 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + evaluateContributionCredit, + buildCreditReport, +} = require('./index'); + +test('blocks publication when listed author has not consented to credit statement', () => { + const result = evaluateContributionCredit({ + projectId: 'project-neuro-imaging', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Ava Chen', role: 'Owner', orcid: '0000-0001-1111-1111', activeMember: true}, + {id: 'u2', name: 'Mika Rao', role: 'Contributor', orcid: '0000-0002-2222-2222', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Writing - original draft'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Data curation'], consented: false, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'data/participants.csv', touchedBy: ['u2']}, + ], + }); + + assert.equal(result.decision, 'blocked'); + assert.deepEqual(result.blockers, [ + 'Mika Rao is listed in the credit statement without consent', + ]); + assert.equal(result.requiredActions[0].type, 'collect_credit_consent'); +}); + +test('requires remediation when active artifact contributor is omitted from credit statement', () => { + const result = evaluateContributionCredit({ + projectId: 'project-climate-model', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Noor Silva', role: 'Owner', orcid: '0000-0001-3333-3333', activeMember: true}, + {id: 'u2', name: 'Jon Bell', role: 'Contributor', orcid: '0000-0002-4444-4444', activeMember: true}, + {id: 'u3', name: 'Ren Ito', role: 'Reviewer', orcid: '0000-0003-5555-5555', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Supervision'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: [], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'code/model.py', touchedBy: ['u2', 'u3']}, + {path: 'results/forecast.svg', touchedBy: ['u3']}, + ], + }); + + assert.equal(result.decision, 'needs-remediation'); + assert.deepEqual(result.blockers, [ + 'Jon Bell has no CRediT roles assigned', + 'Ren Ito contributed to project artifacts but is missing from credit statement', + ]); + assert.equal(result.coverage.artifactContributorCount, 2); + assert.equal(result.coverage.creditedContributorCount, 2); +}); + +test('builds deterministic report for approved contribution credit packet', () => { + const result = evaluateContributionCredit({ + projectId: 'project-approved-credit', + action: 'publish-preprint', + generatedAt: '2026-05-22T12:30:00Z', + contributors: [ + {id: 'u1', name: 'Lina Park', role: 'Owner', orcid: '0000-0001-6666-6666', activeMember: true}, + {id: 'u2', name: 'Samir Okafor', role: 'Contributor', orcid: '0000-0002-7777-7777', activeMember: true}, + ], + creditStatements: [ + {contributorId: 'u1', creditRoles: ['Conceptualization', 'Funding acquisition'], consented: true, authorOrder: 1}, + {contributorId: 'u2', creditRoles: ['Software', 'Formal analysis'], consented: true, authorOrder: 2}, + ], + projectArtifacts: [ + {path: 'manuscript/main.md', touchedBy: ['u1']}, + {path: 'code/analysis.py', touchedBy: ['u2']}, + {path: 'results/table.csv', touchedBy: ['u2']}, + ], + }); + + assert.equal(result.decision, 'approved'); + assert.equal(result.blockers.length, 0); + assert.equal(result.coverage.consentCoverage, 1); + + const report = buildCreditReport(result); + assert.match(report, /project-approved-credit/); + assert.match(report, /Decision: approved/); + assert.match(report, /Consent coverage: 100%/); +});