From 903550cb16893bce4d172d74dc4abcf95d17e1f6 Mon Sep 17 00:00:00 2001 From: attaboy11 Date: Sun, 31 May 2026 06:59:48 +0100 Subject: [PATCH] Add project authoring integrity guard --- project-authoring-integrity-guard/README.md | 36 +++ project-authoring-integrity-guard/demo.js | 16 + project-authoring-integrity-guard/index.js | 305 ++++++++++++++++++ .../render-video.js | 152 +++++++++ .../reports/authoring-integrity-review.json | 168 ++++++++++ .../reports/authoring-integrity-review.md | 39 +++ .../reports/authoring-integrity-review.svg | 1 + .../reports/demo.mp4 | Bin 0 -> 15582 bytes .../sample-data.js | 196 +++++++++++ project-authoring-integrity-guard/test.js | 49 +++ 10 files changed, 962 insertions(+) create mode 100644 project-authoring-integrity-guard/README.md create mode 100644 project-authoring-integrity-guard/demo.js create mode 100644 project-authoring-integrity-guard/index.js create mode 100644 project-authoring-integrity-guard/render-video.js create mode 100644 project-authoring-integrity-guard/reports/authoring-integrity-review.json create mode 100644 project-authoring-integrity-guard/reports/authoring-integrity-review.md create mode 100644 project-authoring-integrity-guard/reports/authoring-integrity-review.svg create mode 100644 project-authoring-integrity-guard/reports/demo.mp4 create mode 100644 project-authoring-integrity-guard/sample-data.js create mode 100644 project-authoring-integrity-guard/test.js 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 [ + '', + '', + 'SCIBASE Project Authoring Integrity Guard', + `Overall ${packet.overallDecision.toUpperCase()} - packet ${packet.packetDigest}`, + `${rows.join('')}`, + '' + ].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 @@ +SCIBASE Project Authoring Integrity GuardOverall HOLD - packet 1cd3e116486fdff2Public Genomics Replication WorkspaceHOLD - 4 high / 4 medium / digest 669c98e63184c219Field Notes Workspace HandoffREVIEW - 0 high / 3 medium / digest c103dbf3b16cefe0Clean Room Notebook ReleaseALLOW - 0 high / 0 medium / digest f2e81888aa407468 \ 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 0000000000000000000000000000000000000000..a11f89b3a3c8d5991319a2b50ac23710c7a1388b GIT binary patch literal 15582 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 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('