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 = `
+`;
+
+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 @@
+
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%/);
+});