diff --git a/project-authoring-integrity-guard/README.md b/project-authoring-integrity-guard/README.md
new file mode 100644
index 00000000..5365d4c4
--- /dev/null
+++ b/project-authoring-integrity-guard/README.md
@@ -0,0 +1,36 @@
+# Project Authoring Integrity Guard
+
+Self-contained guard for SCIBASE issue #11, User & Project Management.
+
+The module validates research workspace authoring bundles before a project is published, archived, or handed off. It focuses on the project-space authoring layer: Markdown/LaTeX manuscripts, Jupyter notebooks, datasets, code, discussion decisions, citations, contributor attribution, and reproducibility manifests.
+
+## What It Checks
+
+- Manuscripts, notebooks, code, and datasets carry stable checksums.
+- Notebook outputs are fresh against the referenced code and dataset checksums.
+- Citation and DOI metadata list the actual collaborators with ORCID-ready attribution consent.
+- Restricted datasets are not silently referenced by public manuscripts.
+- Major authoring decisions have discussion signoff evidence.
+- Funding, institution, license, and reproducibility manifest fields are present before publication.
+
+## Files
+
+- `index.js` - evaluation engine and report formatters
+- `sample-data.js` - synthetic workspace scenarios
+- `test.js` - dependency-free tests using Node's built-in `assert`
+- `demo.js` - generates reviewer JSON, Markdown, and SVG reports
+- `render-video.js` - renders a short MP4 with `ffmpeg`, or an animated GIF fallback with ImageMagick
+- `reports/demo.mp4` - reviewer demo artifact
+
+## Validation
+
+```bash
+node project-authoring-integrity-guard/test.js
+node project-authoring-integrity-guard/demo.js
+node project-authoring-integrity-guard/render-video.js
+node --check project-authoring-integrity-guard/index.js
+node --check project-authoring-integrity-guard/sample-data.js
+node --check project-authoring-integrity-guard/test.js
+node --check project-authoring-integrity-guard/demo.js
+node --check project-authoring-integrity-guard/render-video.js
+```
diff --git a/project-authoring-integrity-guard/demo.js b/project-authoring-integrity-guard/demo.js
new file mode 100644
index 00000000..c1a4e833
--- /dev/null
+++ b/project-authoring-integrity-guard/demo.js
@@ -0,0 +1,16 @@
+const fs = require('fs');
+const path = require('path');
+const { bundles } = require('./sample-data');
+const { evaluateBundles, formatMarkdown, formatSvg } = require('./index');
+
+const reportsDir = path.join(__dirname, 'reports');
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const packet = evaluateBundles(bundles);
+
+fs.writeFileSync(path.join(reportsDir, 'authoring-integrity-review.json'), `${JSON.stringify(packet, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, 'authoring-integrity-review.md'), formatMarkdown(packet));
+fs.writeFileSync(path.join(reportsDir, 'authoring-integrity-review.svg'), formatSvg(packet));
+
+console.log(`Generated reports in ${reportsDir}`);
+console.log(`Overall decision: ${packet.overallDecision}`);
diff --git a/project-authoring-integrity-guard/index.js b/project-authoring-integrity-guard/index.js
new file mode 100644
index 00000000..06dedf2c
--- /dev/null
+++ b/project-authoring-integrity-guard/index.js
@@ -0,0 +1,305 @@
+const crypto = require('crypto');
+
+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 digest(value) {
+ return crypto.createHash('sha256').update(stableStringify(value)).digest('hex').slice(0, 16);
+}
+
+function addFinding(findings, severity, code, message, evidence = {}) {
+ findings.push({ severity, code, message, evidence });
+}
+
+function byId(items = []) {
+ return new Map(items.map((item) => [item.id, item]));
+}
+
+function normalizeChecksum(value) {
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
+}
+
+function hasConsent(author) {
+ return Boolean(author && author.attributionConsent === true && author.orcid);
+}
+
+function findArtifact(bundle, type) {
+ return (bundle.artifacts || []).find((artifact) => artifact.type === type);
+}
+
+function evaluateProjectAuthoringBundle(bundle) {
+ const findings = [];
+ const artifactMap = byId(bundle.artifacts);
+ const authorsById = byId(bundle.authors);
+ const decisionById = byId(bundle.discussionDecisions);
+ const manuscript = findArtifact(bundle, 'manuscript');
+ const manifest = bundle.reproducibilityManifest;
+
+ if (!manuscript) {
+ addFinding(findings, 'high', 'missing_manuscript', 'Workspace has no primary manuscript artifact.');
+ }
+
+ if (!manifest) {
+ addFinding(findings, 'high', 'missing_reproducibility_manifest', 'Workspace has no reproducibility manifest for reviewer handoff.');
+ } else {
+ validateNotebookFreshness(bundle, manifest, artifactMap, findings);
+ if (!manifest.environmentPinned) {
+ addFinding(findings, 'medium', 'environment_not_pinned', 'Reproducibility manifest does not pin the execution environment.');
+ }
+ }
+
+ for (const artifact of bundle.artifacts || []) {
+ if (!normalizeChecksum(artifact.checksum)) {
+ addFinding(findings, 'high', 'artifact_missing_checksum', `${artifact.path} is missing a stable checksum.`, {
+ artifactId: artifact.id,
+ type: artifact.type
+ });
+ }
+ if (['manuscript', 'notebook', 'code'].includes(artifact.type) && !normalizeChecksum(artifact.sourceChecksum)) {
+ addFinding(findings, 'medium', 'artifact_missing_source_checksum', `${artifact.path} is missing source provenance checksum.`, {
+ artifactId: artifact.id,
+ type: artifact.type
+ });
+ }
+ }
+
+ validateCitationMetadata(bundle, authorsById, findings);
+ validateRestrictedReferences(bundle, artifactMap, findings);
+ validateDiscussionSignoffs(bundle, decisionById, findings);
+
+ const high = findings.filter((finding) => finding.severity === 'high').length;
+ const medium = findings.filter((finding) => finding.severity === 'medium').length;
+ const low = findings.filter((finding) => finding.severity === 'low').length;
+ const decision = high > 0 ? 'hold' : medium > 0 ? 'review' : 'allow';
+
+ return {
+ projectId: bundle.project.id,
+ title: bundle.project.title,
+ decision,
+ summary: { high, medium, low, total: findings.length },
+ findings,
+ requirementMap: {
+ projectSpaces: 'documents, code, datasets, discussion decisions, metadata, and citations are represented as artifacts',
+ authoring: 'Markdown/LaTeX/Jupyter-native provenance is checked with source checksums and notebook execution manifests',
+ attribution: 'ORCID-ready collaborator attribution and funding/institution metadata are validated',
+ accessControl: 'restricted dataset references are blocked from accidental public manuscript release',
+ auditLog: 'decision, artifact, and manifest digests create reviewer-ready audit evidence'
+ },
+ auditDigest: digest({
+ project: bundle.project,
+ artifacts: bundle.artifacts,
+ citationMetadata: bundle.citationMetadata,
+ findings
+ })
+ };
+}
+
+function validateNotebookFreshness(bundle, manifest, artifactMap, findings) {
+ for (const notebook of manifest.notebooks || []) {
+ const artifact = artifactMap.get(notebook.id);
+ if (!artifact) {
+ addFinding(findings, 'high', 'manifest_notebook_missing', `Manifest references missing notebook ${notebook.id}.`, {
+ notebookId: notebook.id
+ });
+ continue;
+ }
+
+ for (const codeRef of notebook.executedAgainst?.code || []) {
+ const codeArtifact = artifactMap.get(codeRef.artifactId);
+ if (!codeArtifact || normalizeChecksum(codeArtifact.checksum) !== normalizeChecksum(codeRef.checksum)) {
+ addFinding(findings, 'high', 'stale_notebook_code', `${artifact.path} was not executed against the current code artifact.`, {
+ notebookId: notebook.id,
+ codeArtifactId: codeRef.artifactId,
+ expected: codeArtifact?.checksum || null,
+ recorded: codeRef.checksum
+ });
+ }
+ }
+
+ for (const dataRef of notebook.executedAgainst?.datasets || []) {
+ const dataArtifact = artifactMap.get(dataRef.artifactId);
+ if (!dataArtifact || normalizeChecksum(dataArtifact.checksum) !== normalizeChecksum(dataRef.checksum)) {
+ addFinding(findings, 'high', 'stale_notebook_dataset', `${artifact.path} was not executed against the current dataset checksum.`, {
+ notebookId: notebook.id,
+ datasetArtifactId: dataRef.artifactId,
+ expected: dataArtifact?.checksum || null,
+ recorded: dataRef.checksum
+ });
+ }
+ }
+ }
+}
+
+function validateCitationMetadata(bundle, authorsById, findings) {
+ const metadata = bundle.citationMetadata || {};
+ if (!metadata.doi && bundle.project.publicationTarget === 'public') {
+ addFinding(findings, 'medium', 'missing_doi', 'Public publication target is missing DOI or pre-registration identifier.');
+ }
+ if (!metadata.license) {
+ addFinding(findings, 'medium', 'missing_license', 'Citation metadata is missing a license.');
+ }
+ if (!Array.isArray(metadata.fundingSources) || metadata.fundingSources.length === 0) {
+ addFinding(findings, 'medium', 'missing_funding_sources', 'Project citation metadata has no funding source acknowledgement.');
+ }
+ if (!Array.isArray(metadata.institutions) || metadata.institutions.length === 0) {
+ addFinding(findings, 'medium', 'missing_institutions', 'Project citation metadata has no institution attribution.');
+ }
+
+ for (const contributorId of metadata.contributors || []) {
+ const author = authorsById.get(contributorId);
+ if (!author) {
+ addFinding(findings, 'high', 'citation_unknown_contributor', `Citation metadata references unknown contributor ${contributorId}.`, {
+ contributorId
+ });
+ continue;
+ }
+ if (!hasConsent(author)) {
+ addFinding(findings, 'high', 'citation_contributor_not_attribution_ready', `${author.name} lacks ORCID-backed attribution consent.`, {
+ contributorId,
+ orcid: author.orcid || null,
+ attributionConsent: author.attributionConsent
+ });
+ }
+ }
+}
+
+function validateRestrictedReferences(bundle, artifactMap, findings) {
+ const publicRelease = bundle.project.publicationTarget === 'public';
+ for (const artifact of bundle.artifacts || []) {
+ if (artifact.type !== 'manuscript') continue;
+ for (const reference of artifact.references || []) {
+ const target = artifactMap.get(reference.artifactId);
+ if (!target) {
+ addFinding(findings, 'high', 'manuscript_reference_missing', `${artifact.path} references missing artifact ${reference.artifactId}.`, {
+ manuscriptId: artifact.id,
+ artifactId: reference.artifactId
+ });
+ continue;
+ }
+ if (publicRelease && target.restricted === true && reference.publicDisclosureNote !== true) {
+ addFinding(findings, 'high', 'restricted_dataset_public_reference', `${artifact.path} references restricted data without a public disclosure note.`, {
+ manuscriptId: artifact.id,
+ artifactId: target.id,
+ path: target.path
+ });
+ }
+ }
+ }
+}
+
+function validateDiscussionSignoffs(bundle, decisionById, findings) {
+ for (const artifact of bundle.artifacts || []) {
+ if (!['manuscript', 'notebook'].includes(artifact.type)) continue;
+ for (const decisionId of artifact.requiredDecisionIds || []) {
+ const decision = decisionById.get(decisionId);
+ if (!decision) {
+ addFinding(findings, 'medium', 'missing_discussion_decision', `${artifact.path} references missing discussion decision ${decisionId}.`, {
+ artifactId: artifact.id,
+ decisionId
+ });
+ continue;
+ }
+ const missing = (decision.requiredSignoffs || []).filter((signoff) => !(decision.signoffs || []).includes(signoff));
+ if (missing.length > 0) {
+ addFinding(findings, 'medium', 'discussion_signoff_missing', `${artifact.path} is waiting on discussion signoff evidence.`, {
+ artifactId: artifact.id,
+ decisionId,
+ missing
+ });
+ }
+ }
+ }
+}
+
+function evaluateBundles(bundles) {
+ const reviews = bundles.map(evaluateProjectAuthoringBundle);
+ return {
+ generatedAt: new Date().toISOString(),
+ overallDecision: reviews.some((review) => review.decision === 'hold')
+ ? 'hold'
+ : reviews.some((review) => review.decision === 'review')
+ ? 'review'
+ : 'allow',
+ reviews,
+ packetDigest: digest(reviews)
+ };
+}
+
+function formatMarkdown(packet) {
+ const lines = [
+ '# Project Authoring Integrity Review',
+ '',
+ `Generated: ${packet.generatedAt}`,
+ `Overall decision: **${packet.overallDecision.toUpperCase()}**`,
+ `Packet digest: \`${packet.packetDigest}\``,
+ ''
+ ];
+
+ for (const review of packet.reviews) {
+ lines.push(`## ${review.title}`);
+ lines.push('');
+ lines.push(`Decision: **${review.decision.toUpperCase()}**`);
+ lines.push(`Audit digest: \`${review.auditDigest}\``);
+ lines.push(`Findings: ${review.summary.total} (${review.summary.high} high, ${review.summary.medium} medium, ${review.summary.low} low)`);
+ lines.push('');
+ if (review.findings.length === 0) {
+ lines.push('- No authoring integrity findings.');
+ } else {
+ for (const finding of review.findings) {
+ lines.push(`- **${finding.severity.toUpperCase()}** \`${finding.code}\`: ${finding.message}`);
+ }
+ }
+ lines.push('');
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+function formatSvg(packet) {
+ const rows = packet.reviews.map((review, index) => {
+ const y = 132 + index * 58;
+ const color = review.decision === 'hold' ? '#ef4444' : review.decision === 'review' ? '#f59e0b' : '#22c55e';
+ return [
+ ``,
+ ``,
+ `${escapeXml(review.title)}`,
+ `${review.decision.toUpperCase()} - ${review.summary.high} high / ${review.summary.medium} medium / digest ${review.auditDigest}`
+ ].join('');
+ });
+
+ return [
+ ''
+ ].join('');
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+module.exports = {
+ digest,
+ evaluateProjectAuthoringBundle,
+ evaluateBundles,
+ formatMarkdown,
+ formatSvg,
+ stableStringify
+};
diff --git a/project-authoring-integrity-guard/render-video.js b/project-authoring-integrity-guard/render-video.js
new file mode 100644
index 00000000..2600e26d
--- /dev/null
+++ b/project-authoring-integrity-guard/render-video.js
@@ -0,0 +1,152 @@
+const path = require('path');
+const fs = require('fs');
+const { spawnSync } = require('child_process');
+
+const output = path.join(__dirname, 'reports', 'demo.mp4');
+const gifOutput = path.join(__dirname, 'reports', 'demo.gif');
+const reportsDir = path.join(__dirname, 'reports');
+
+function main() {
+ fs.mkdirSync(reportsDir, { recursive: true });
+ const ffmpegReady = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;
+ const frames = writeFrames();
+
+ try {
+ if (ffmpegReady) {
+ const result = spawnSync(
+ 'ffmpeg',
+ [
+ '-y',
+ '-framerate',
+ '1',
+ '-i',
+ path.join(reportsDir, 'frame-%02d.ppm'),
+ '-vf',
+ 'scale=960:540:flags=neighbor,format=yuv420p',
+ '-movflags',
+ '+faststart',
+ '-r',
+ '24',
+ output
+ ],
+ { stdio: 'inherit' }
+ );
+
+ if (result.status === 0) {
+ console.log(`Rendered ${output}`);
+ return;
+ }
+ }
+
+ console.log(ffmpegReady ? 'ffmpeg render failed; trying ImageMagick GIF fallback.' : 'ffmpeg is unavailable; trying ImageMagick GIF fallback.');
+ const magick = spawnSync('magick', ['-delay', '140', '-loop', '0', ...frames, gifOutput], {
+ stdio: 'inherit'
+ });
+ if (magick.status !== 0) process.exit(magick.status || 1);
+ console.log(`Rendered ${gifOutput}`);
+ } finally {
+ cleanupFrames(frames);
+ }
+}
+
+function writeFrames() {
+ return [
+ { name: 'frame-01.ppm', title: 'AUTHORING', subtitle: 'MANIFEST CHECK', accent: '#38bdf8' },
+ { name: 'frame-02.ppm', title: 'NOTEBOOKS', subtitle: 'FRESH OUTPUTS', accent: '#22c55e' },
+ { name: 'frame-03.ppm', title: 'CITATIONS', subtitle: 'ORCID CONSENT', accent: '#f97316' },
+ { name: 'frame-04.ppm', title: 'REVIEWER', subtitle: 'HOLD REVIEW ALLOW', accent: '#ef4444' }
+ ].map((frame) => {
+ const file = path.join(reportsDir, frame.name);
+ fs.writeFileSync(file, ppmFrame(frame.title, frame.subtitle, frame.accent));
+ return file;
+ });
+}
+
+function cleanupFrames(frames) {
+ for (const frame of frames) {
+ if (fs.existsSync(frame)) fs.unlinkSync(frame);
+ }
+}
+
+function ppmFrame(title, subtitle, accent) {
+ const width = 480;
+ const height = 270;
+ const pixels = Buffer.alloc(width * height * 3);
+ fill(pixels, width, height, hex('#0f172a'));
+ rect(pixels, width, height, 24, 28, 10, 196, hex(accent));
+ rect(pixels, width, height, 56, 142, 360, 54, hex('#1e293b'));
+ rect(pixels, width, height, 56, 210, 250, 10, hex(accent));
+ drawText(pixels, width, height, title, 56, 58, 6, hex('#f8fafc'));
+ drawText(pixels, width, height, subtitle, 58, 110, 3, hex('#cbd5e1'));
+ drawText(pixels, width, height, 'SCIBASE ISSUE 11', 78, 160, 3, hex('#f8fafc'));
+ drawText(pixels, width, height, 'SYNTHETIC DEMO', 58, 236, 2, hex('#94a3b8'));
+ return Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), pixels]);
+}
+
+function fill(pixels, width, height, color) {
+ rect(pixels, width, height, 0, 0, width, height, color);
+}
+
+function rect(pixels, width, height, x, y, w, h, color) {
+ for (let row = Math.max(0, y); row < Math.min(height, y + h); row += 1) {
+ for (let col = Math.max(0, x); col < Math.min(width, x + w); col += 1) {
+ const offset = (row * width + col) * 3;
+ pixels[offset] = color[0];
+ pixels[offset + 1] = color[1];
+ pixels[offset + 2] = color[2];
+ }
+ }
+}
+
+function drawText(pixels, width, height, text, x, y, scale, color) {
+ let cursor = x;
+ for (const char of text.toUpperCase()) {
+ if (char === ' ') {
+ cursor += 4 * scale;
+ continue;
+ }
+ const glyph = FONT[char] || FONT['?'];
+ for (let row = 0; row < glyph.length; row += 1) {
+ for (let col = 0; col < glyph[row].length; col += 1) {
+ if (glyph[row][col] === '1') {
+ rect(pixels, width, height, cursor + col * scale, y + row * scale, scale, scale, color);
+ }
+ }
+ }
+ cursor += 6 * scale;
+ }
+}
+
+function hex(value) {
+ const clean = value.replace('#', '');
+ return [0, 2, 4].map((index) => parseInt(clean.slice(index, index + 2), 16));
+}
+
+const FONT = {
+ A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
+ B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
+ C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
+ D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
+ E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
+ F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
+ G: ['01111', '10000', '10000', '10111', '10001', '10001', '01111'],
+ H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
+ I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
+ K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
+ L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
+ M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
+ N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
+ O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
+ P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
+ R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
+ S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
+ T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
+ U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
+ V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
+ W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
+ Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
+ 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
+ '?': ['11110', '00001', '00010', '00100', '00100', '00000', '00100']
+};
+
+main();
diff --git a/project-authoring-integrity-guard/reports/authoring-integrity-review.json b/project-authoring-integrity-guard/reports/authoring-integrity-review.json
new file mode 100644
index 00000000..0f3dc4c3
--- /dev/null
+++ b/project-authoring-integrity-guard/reports/authoring-integrity-review.json
@@ -0,0 +1,168 @@
+{
+ "generatedAt": "2026-05-31T05:59:18.640Z",
+ "overallDecision": "hold",
+ "reviews": [
+ {
+ "projectId": "proj-public-genomics",
+ "title": "Public Genomics Replication Workspace",
+ "decision": "hold",
+ "summary": {
+ "high": 4,
+ "medium": 4,
+ "low": 0,
+ "total": 8
+ },
+ "findings": [
+ {
+ "severity": "high",
+ "code": "stale_notebook_code",
+ "message": "notebooks/analysis.ipynb was not executed against the current code artifact.",
+ "evidence": {
+ "notebookId": "nb-analysis",
+ "codeArtifactId": "code-stats",
+ "expected": "sha256:code-current",
+ "recorded": "sha256:code-old"
+ }
+ },
+ {
+ "severity": "high",
+ "code": "stale_notebook_dataset",
+ "message": "notebooks/analysis.ipynb was not executed against the current dataset checksum.",
+ "evidence": {
+ "notebookId": "nb-analysis",
+ "datasetArtifactId": "data-participant",
+ "expected": "sha256:data-current",
+ "recorded": "sha256:data-old"
+ }
+ },
+ {
+ "severity": "medium",
+ "code": "environment_not_pinned",
+ "message": "Reproducibility manifest does not pin the execution environment.",
+ "evidence": {}
+ },
+ {
+ "severity": "medium",
+ "code": "missing_doi",
+ "message": "Public publication target is missing DOI or pre-registration identifier.",
+ "evidence": {}
+ },
+ {
+ "severity": "high",
+ "code": "citation_contributor_not_attribution_ready",
+ "message": "Ben Malik lacks ORCID-backed attribution consent.",
+ "evidence": {
+ "contributorId": "u-ben",
+ "orcid": null,
+ "attributionConsent": false
+ }
+ },
+ {
+ "severity": "high",
+ "code": "restricted_dataset_public_reference",
+ "message": "manuscripts/main.md references restricted data without a public disclosure note.",
+ "evidence": {
+ "manuscriptId": "ms-main",
+ "artifactId": "data-participant",
+ "path": "data/participant-derived.csv"
+ }
+ },
+ {
+ "severity": "medium",
+ "code": "discussion_signoff_missing",
+ "message": "manuscripts/main.md is waiting on discussion signoff evidence.",
+ "evidence": {
+ "artifactId": "ms-main",
+ "decisionId": "decision-release",
+ "missing": [
+ "u-ben"
+ ]
+ }
+ },
+ {
+ "severity": "medium",
+ "code": "discussion_signoff_missing",
+ "message": "notebooks/analysis.ipynb is waiting on discussion signoff evidence.",
+ "evidence": {
+ "artifactId": "nb-analysis",
+ "decisionId": "decision-release",
+ "missing": [
+ "u-ben"
+ ]
+ }
+ }
+ ],
+ "requirementMap": {
+ "projectSpaces": "documents, code, datasets, discussion decisions, metadata, and citations are represented as artifacts",
+ "authoring": "Markdown/LaTeX/Jupyter-native provenance is checked with source checksums and notebook execution manifests",
+ "attribution": "ORCID-ready collaborator attribution and funding/institution metadata are validated",
+ "accessControl": "restricted dataset references are blocked from accidental public manuscript release",
+ "auditLog": "decision, artifact, and manifest digests create reviewer-ready audit evidence"
+ },
+ "auditDigest": "669c98e63184c219"
+ },
+ {
+ "projectId": "proj-field-notes",
+ "title": "Field Notes Workspace Handoff",
+ "decision": "review",
+ "summary": {
+ "high": 0,
+ "medium": 3,
+ "low": 0,
+ "total": 3
+ },
+ "findings": [
+ {
+ "severity": "medium",
+ "code": "artifact_missing_source_checksum",
+ "message": "manuscripts/field-notes.tex is missing source provenance checksum.",
+ "evidence": {
+ "artifactId": "ms-field",
+ "type": "manuscript"
+ }
+ },
+ {
+ "severity": "medium",
+ "code": "missing_license",
+ "message": "Citation metadata is missing a license.",
+ "evidence": {}
+ },
+ {
+ "severity": "medium",
+ "code": "missing_funding_sources",
+ "message": "Project citation metadata has no funding source acknowledgement.",
+ "evidence": {}
+ }
+ ],
+ "requirementMap": {
+ "projectSpaces": "documents, code, datasets, discussion decisions, metadata, and citations are represented as artifacts",
+ "authoring": "Markdown/LaTeX/Jupyter-native provenance is checked with source checksums and notebook execution manifests",
+ "attribution": "ORCID-ready collaborator attribution and funding/institution metadata are validated",
+ "accessControl": "restricted dataset references are blocked from accidental public manuscript release",
+ "auditLog": "decision, artifact, and manifest digests create reviewer-ready audit evidence"
+ },
+ "auditDigest": "c103dbf3b16cefe0"
+ },
+ {
+ "projectId": "proj-clean-room",
+ "title": "Clean Room Notebook Release",
+ "decision": "allow",
+ "summary": {
+ "high": 0,
+ "medium": 0,
+ "low": 0,
+ "total": 0
+ },
+ "findings": [],
+ "requirementMap": {
+ "projectSpaces": "documents, code, datasets, discussion decisions, metadata, and citations are represented as artifacts",
+ "authoring": "Markdown/LaTeX/Jupyter-native provenance is checked with source checksums and notebook execution manifests",
+ "attribution": "ORCID-ready collaborator attribution and funding/institution metadata are validated",
+ "accessControl": "restricted dataset references are blocked from accidental public manuscript release",
+ "auditLog": "decision, artifact, and manifest digests create reviewer-ready audit evidence"
+ },
+ "auditDigest": "f2e81888aa407468"
+ }
+ ],
+ "packetDigest": "1cd3e116486fdff2"
+}
diff --git a/project-authoring-integrity-guard/reports/authoring-integrity-review.md b/project-authoring-integrity-guard/reports/authoring-integrity-review.md
new file mode 100644
index 00000000..6e337d40
--- /dev/null
+++ b/project-authoring-integrity-guard/reports/authoring-integrity-review.md
@@ -0,0 +1,39 @@
+# Project Authoring Integrity Review
+
+Generated: 2026-05-31T05:59:18.640Z
+Overall decision: **HOLD**
+Packet digest: `1cd3e116486fdff2`
+
+## Public Genomics Replication Workspace
+
+Decision: **HOLD**
+Audit digest: `669c98e63184c219`
+Findings: 8 (4 high, 4 medium, 0 low)
+
+- **HIGH** `stale_notebook_code`: notebooks/analysis.ipynb was not executed against the current code artifact.
+- **HIGH** `stale_notebook_dataset`: notebooks/analysis.ipynb was not executed against the current dataset checksum.
+- **MEDIUM** `environment_not_pinned`: Reproducibility manifest does not pin the execution environment.
+- **MEDIUM** `missing_doi`: Public publication target is missing DOI or pre-registration identifier.
+- **HIGH** `citation_contributor_not_attribution_ready`: Ben Malik lacks ORCID-backed attribution consent.
+- **HIGH** `restricted_dataset_public_reference`: manuscripts/main.md references restricted data without a public disclosure note.
+- **MEDIUM** `discussion_signoff_missing`: manuscripts/main.md is waiting on discussion signoff evidence.
+- **MEDIUM** `discussion_signoff_missing`: notebooks/analysis.ipynb is waiting on discussion signoff evidence.
+
+## Field Notes Workspace Handoff
+
+Decision: **REVIEW**
+Audit digest: `c103dbf3b16cefe0`
+Findings: 3 (0 high, 3 medium, 0 low)
+
+- **MEDIUM** `artifact_missing_source_checksum`: manuscripts/field-notes.tex is missing source provenance checksum.
+- **MEDIUM** `missing_license`: Citation metadata is missing a license.
+- **MEDIUM** `missing_funding_sources`: Project citation metadata has no funding source acknowledgement.
+
+## Clean Room Notebook Release
+
+Decision: **ALLOW**
+Audit digest: `f2e81888aa407468`
+Findings: 0 (0 high, 0 medium, 0 low)
+
+- No authoring integrity findings.
+
diff --git a/project-authoring-integrity-guard/reports/authoring-integrity-review.svg b/project-authoring-integrity-guard/reports/authoring-integrity-review.svg
new file mode 100644
index 00000000..6db19b4b
--- /dev/null
+++ b/project-authoring-integrity-guard/reports/authoring-integrity-review.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/project-authoring-integrity-guard/reports/demo.mp4 b/project-authoring-integrity-guard/reports/demo.mp4
new file mode 100644
index 00000000..a11f89b3
Binary files /dev/null and b/project-authoring-integrity-guard/reports/demo.mp4 differ
diff --git a/project-authoring-integrity-guard/sample-data.js b/project-authoring-integrity-guard/sample-data.js
new file mode 100644
index 00000000..fcc8716f
--- /dev/null
+++ b/project-authoring-integrity-guard/sample-data.js
@@ -0,0 +1,196 @@
+const bundles = [
+ {
+ project: {
+ id: 'proj-public-genomics',
+ title: 'Public Genomics Replication Workspace',
+ visibility: 'private',
+ publicationTarget: 'public'
+ },
+ authors: [
+ { id: 'u-ada', name: 'Ada Rivera', orcid: '0000-0002-1111-2222', attributionConsent: true },
+ { id: 'u-ben', name: 'Ben Malik', orcid: null, attributionConsent: false }
+ ],
+ artifacts: [
+ {
+ id: 'ms-main',
+ type: 'manuscript',
+ path: 'manuscripts/main.md',
+ checksum: 'sha256:ms-current',
+ sourceChecksum: 'sha256:ms-source',
+ references: [{ artifactId: 'data-participant', publicDisclosureNote: false }],
+ requiredDecisionIds: ['decision-release']
+ },
+ {
+ id: 'nb-analysis',
+ type: 'notebook',
+ path: 'notebooks/analysis.ipynb',
+ checksum: 'sha256:nb-current',
+ sourceChecksum: 'sha256:nb-source',
+ requiredDecisionIds: ['decision-release']
+ },
+ {
+ id: 'code-stats',
+ type: 'code',
+ path: 'code/stats.py',
+ checksum: 'sha256:code-current',
+ sourceChecksum: 'sha256:code-source'
+ },
+ {
+ id: 'data-participant',
+ type: 'dataset',
+ path: 'data/participant-derived.csv',
+ checksum: 'sha256:data-current',
+ restricted: true
+ }
+ ],
+ discussionDecisions: [
+ {
+ id: 'decision-release',
+ topic: 'Public manuscript release',
+ requiredSignoffs: ['u-ada', 'u-ben'],
+ signoffs: ['u-ada']
+ }
+ ],
+ citationMetadata: {
+ doi: null,
+ license: 'CC-BY-4.0',
+ contributors: ['u-ada', 'u-ben'],
+ fundingSources: ['FUND-42'],
+ institutions: ['Northbridge Lab']
+ },
+ reproducibilityManifest: {
+ environmentPinned: false,
+ notebooks: [
+ {
+ id: 'nb-analysis',
+ executedAgainst: {
+ code: [{ artifactId: 'code-stats', checksum: 'sha256:code-old' }],
+ datasets: [{ artifactId: 'data-participant', checksum: 'sha256:data-old' }]
+ }
+ }
+ ]
+ }
+ },
+ {
+ project: {
+ id: 'proj-field-notes',
+ title: 'Field Notes Workspace Handoff',
+ visibility: 'institutional',
+ publicationTarget: 'institutional'
+ },
+ authors: [
+ { id: 'u-cam', name: 'Cam Torres', orcid: '0000-0003-3333-4444', attributionConsent: true }
+ ],
+ artifacts: [
+ {
+ id: 'ms-field',
+ type: 'manuscript',
+ path: 'manuscripts/field-notes.tex',
+ checksum: 'sha256:field-ms',
+ sourceChecksum: null,
+ references: [{ artifactId: 'data-field', publicDisclosureNote: true }],
+ requiredDecisionIds: ['decision-methods']
+ },
+ {
+ id: 'data-field',
+ type: 'dataset',
+ path: 'data/field-observations.csv',
+ checksum: 'sha256:field-data',
+ restricted: false
+ }
+ ],
+ discussionDecisions: [
+ {
+ id: 'decision-methods',
+ topic: 'Methods wording accepted',
+ requiredSignoffs: ['u-cam'],
+ signoffs: ['u-cam']
+ }
+ ],
+ citationMetadata: {
+ doi: '10.5555/scibase-field-notes',
+ license: null,
+ contributors: ['u-cam'],
+ fundingSources: [],
+ institutions: ['Coastal Institute']
+ },
+ reproducibilityManifest: {
+ environmentPinned: true,
+ notebooks: []
+ }
+ },
+ {
+ project: {
+ id: 'proj-clean-room',
+ title: 'Clean Room Notebook Release',
+ visibility: 'private',
+ publicationTarget: 'public'
+ },
+ authors: [
+ { id: 'u-dia', name: 'Dia Shah', orcid: '0000-0004-5555-6666', attributionConsent: true },
+ { id: 'u-eli', name: 'Eli Park', orcid: '0000-0005-7777-8888', attributionConsent: true }
+ ],
+ artifacts: [
+ {
+ id: 'ms-clean',
+ type: 'manuscript',
+ path: 'manuscripts/clean-room.md',
+ checksum: 'sha256:clean-ms',
+ sourceChecksum: 'sha256:clean-ms-source',
+ references: [{ artifactId: 'data-clean', publicDisclosureNote: true }],
+ requiredDecisionIds: ['decision-clean-release']
+ },
+ {
+ id: 'nb-clean',
+ type: 'notebook',
+ path: 'notebooks/clean-room.ipynb',
+ checksum: 'sha256:clean-nb',
+ sourceChecksum: 'sha256:clean-nb-source',
+ requiredDecisionIds: ['decision-clean-release']
+ },
+ {
+ id: 'code-clean',
+ type: 'code',
+ path: 'code/model.js',
+ checksum: 'sha256:clean-code',
+ sourceChecksum: 'sha256:clean-code-source'
+ },
+ {
+ id: 'data-clean',
+ type: 'dataset',
+ path: 'data/public-synthetic.csv',
+ checksum: 'sha256:clean-data',
+ restricted: false
+ }
+ ],
+ discussionDecisions: [
+ {
+ id: 'decision-clean-release',
+ topic: 'Publish clean-room release',
+ requiredSignoffs: ['u-dia', 'u-eli'],
+ signoffs: ['u-dia', 'u-eli']
+ }
+ ],
+ citationMetadata: {
+ doi: '10.5555/scibase-clean-room',
+ license: 'MIT',
+ contributors: ['u-dia', 'u-eli'],
+ fundingSources: ['OPEN-SCI-7'],
+ institutions: ['Open Systems Lab']
+ },
+ reproducibilityManifest: {
+ environmentPinned: true,
+ notebooks: [
+ {
+ id: 'nb-clean',
+ executedAgainst: {
+ code: [{ artifactId: 'code-clean', checksum: 'sha256:clean-code' }],
+ datasets: [{ artifactId: 'data-clean', checksum: 'sha256:clean-data' }]
+ }
+ }
+ ]
+ }
+ }
+];
+
+module.exports = { bundles };
diff --git a/project-authoring-integrity-guard/test.js b/project-authoring-integrity-guard/test.js
new file mode 100644
index 00000000..bc02d74d
--- /dev/null
+++ b/project-authoring-integrity-guard/test.js
@@ -0,0 +1,49 @@
+const assert = require('assert');
+const { bundles } = require('./sample-data');
+const { evaluateProjectAuthoringBundle, evaluateBundles, formatMarkdown, formatSvg } = require('./index');
+
+function testUnsafeBundleIsHeld() {
+ const review = evaluateProjectAuthoringBundle(bundles[0]);
+ assert.strictEqual(review.decision, 'hold');
+ assert(review.findings.some((finding) => finding.code === 'stale_notebook_code'));
+ assert(review.findings.some((finding) => finding.code === 'stale_notebook_dataset'));
+ assert(review.findings.some((finding) => finding.code === 'restricted_dataset_public_reference'));
+ assert(review.findings.some((finding) => finding.code === 'citation_contributor_not_attribution_ready'));
+}
+
+function testMetadataGapsRequireReview() {
+ const review = evaluateProjectAuthoringBundle(bundles[1]);
+ assert.strictEqual(review.decision, 'review');
+ assert(review.findings.some((finding) => finding.code === 'artifact_missing_source_checksum'));
+ assert(review.findings.some((finding) => finding.code === 'missing_license'));
+ assert(review.findings.some((finding) => finding.code === 'missing_funding_sources'));
+}
+
+function testCleanBundleIsAllowed() {
+ const review = evaluateProjectAuthoringBundle(bundles[2]);
+ assert.strictEqual(review.decision, 'allow');
+ assert.strictEqual(review.findings.length, 0);
+}
+
+function testPacketAndFormats() {
+ const packet = evaluateBundles(bundles);
+ assert.strictEqual(packet.overallDecision, 'hold');
+ assert.strictEqual(packet.reviews.length, 3);
+ assert.match(packet.packetDigest, /^[a-f0-9]{16}$/);
+ assert(formatMarkdown(packet).includes('Project Authoring Integrity Review'));
+ assert(formatSvg(packet).includes('