diff --git a/repository-review-decision-provenance-guard/.gitignore b/repository-review-decision-provenance-guard/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/repository-review-decision-provenance-guard/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/repository-review-decision-provenance-guard/README.md b/repository-review-decision-provenance-guard/README.md new file mode 100644 index 00000000..b77a63bf --- /dev/null +++ b/repository-review-decision-provenance-guard/README.md @@ -0,0 +1,41 @@ +# Repository Review-Decision Provenance Guard + +Self-contained synthetic module for SCIBASE issue #10, Project Repository & Version Control. + +The guard focuses on a narrow release/export decision point: resolved merge-request review discussions should not disappear after merge. Before a tagged scientific repository version is exported, the module checks that reviewer decisions are durable, anchored to files and commits, role-appropriate, public-safe, and linked from the export manifest. + +## What It Checks + +- Stable release tag, export bundle hash, and DOI or persistent identifier evidence. +- A review-decision packet entry in the export manifest. +- Resolved review threads mapped to scientific repository components. +- File and commit anchors for reviewed manuscript, data, code, notebook, and metadata changes. +- Reviewer role eligibility by component. +- Resolution rationales that can survive future audit. +- Export inclusion for resolved decisions. +- Private reviewer notes redacted before public release. +- Evidence references such as rendered manuscripts, data dictionaries, notebook hashes, or DataCite previews. + +This is distinct from an unresolved-discussion merge gate. It preserves already-resolved scientific review decisions into release and export evidence. + +## Run + +```sh +npm run check +npm test +npm run demo +npm run verify-video +``` + +## Outputs + +`npm run demo` writes: + +- `reports/clean-audit.json` +- `reports/risky-audit.json` +- `reports/risky-review.md` +- `reports/summary.svg` +- `reports/manifest.json` +- `reports/demo.mp4` + +The sample data is synthetic only. The module does not call GitHub, DOI providers, identity systems, payment processors, credentials, private repositories, or external APIs. diff --git a/repository-review-decision-provenance-guard/demo.js b/repository-review-decision-provenance-guard/demo.js new file mode 100644 index 00000000..b8b70015 --- /dev/null +++ b/repository-review-decision-provenance-guard/demo.js @@ -0,0 +1,46 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateReviewDecisionProvenance, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateReviewDecisionProvenance(cleanPacket); +const risky = evaluateReviewDecisionProvenance(riskyPacket); + +fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky)); +fs.writeFileSync( + path.join(reportsDir, "manifest.json"), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + module: "repository-review-decision-provenance-guard", + cleanStatus: clean.status, + riskyStatus: risky.status, + riskyFindings: risky.findings.length, + artifacts: [ + "clean-audit.json", + "risky-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ] + }, + null, + 2 + )}\n` +); + +console.log(`Clean packet: ${clean.status} (${clean.findings.length} findings)`); +console.log(`Risky packet: ${risky.status} (${risky.findings.length} findings)`); +console.log(`Wrote reports to ${reportsDir}`); diff --git a/repository-review-decision-provenance-guard/index.js b/repository-review-decision-provenance-guard/index.js new file mode 100644 index 00000000..025eaf7c --- /dev/null +++ b/repository-review-decision-provenance-guard/index.js @@ -0,0 +1,620 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; + +const DEFAULT_POLICY = { + requiredDecisionComponents: ["manuscript", "data", "code", "notebook", "metadata"], + requiredReviewerRolesByComponent: { + manuscript: ["scientific-editor", "methods-reviewer"], + data: ["data-steward", "methods-reviewer"], + code: ["reproducibility-reviewer", "maintainer"], + notebook: ["reproducibility-reviewer", "methods-reviewer"], + metadata: ["metadata-curator", "scientific-editor"], + protocol: ["protocol-reviewer", "methods-reviewer"], + results: ["methods-reviewer", "scientific-editor"] + }, + maxResolutionAgeHoursAfterAnchor: 72, + requireExportManifestDecisionPacket: true +}; + +function evaluateReviewDecisionProvenance(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateReviewDecisionProvenance expects a packet object"); + } + + const now = options.now ?? new Date().toISOString(); + const policy = mergePolicy(packet.policy); + const releaseCandidate = isPlainObject(packet.releaseCandidate) ? packet.releaseCandidate : {}; + const mergeRequests = asArray(packet.mergeRequests); + const components = asArray(packet.repositoryComponents); + const findings = []; + + inspectReleaseCandidate(releaseCandidate, policy, findings); + inspectComponentCoverage(components, mergeRequests, policy, findings); + + const decisionRecords = []; + mergeRequests.forEach((mergeRequest, mergeRequestIndex) => { + inspectMergeRequest(mergeRequest, mergeRequestIndex, policy, findings, decisionRecords); + }); + + const sortedFindings = sortFindings(findings); + const status = determineStatus(sortedFindings); + const coverage = buildCoverageSummary(policy, decisionRecords, components); + const releaseReadiness = buildReleaseReadiness(status, sortedFindings, decisionRecords, releaseCandidate); + const manifestPatch = buildManifestPatch(packet, releaseCandidate, decisionRecords, sortedFindings); + const remediationActions = sortedFindings.map((finding) => ({ + code: finding.code, + owner: finding.owner, + action: finding.remediation, + path: finding.path + })); + const fingerprint = buildFingerprint({ policy, releaseCandidate, mergeRequests, sortedFindings, decisionRecords }); + + return { + generatedAt: now, + status, + summary: summarize(status, sortedFindings, decisionRecords, coverage), + findingCounts: countBySeverity(sortedFindings), + findings: sortedFindings, + coverage, + decisionRecords, + releaseReadiness, + manifestPatch, + remediationActions, + fingerprint + }; +} + +function renderMarkdownReport(result, packet) { + const release = packet.releaseCandidate ?? {}; + const lines = [ + "# Repository Review-Decision Provenance Guard", + "", + `Packet: ${packet.id ?? "unknown"}`, + `Release candidate: ${release.tag ?? "untagged"}`, + `Status: ${result.status}`, + `Fingerprint: ${result.fingerprint}`, + "", + "## Summary", + "", + result.summary, + "", + "## Coverage", + "", + `- Components requiring decision evidence: ${result.coverage.requiredComponents.join(", ")}`, + `- Covered components: ${result.coverage.coveredComponents.join(", ") || "none"}`, + `- Missing components: ${result.coverage.missingComponents.join(", ") || "none"}`, + "", + "## Decision Records", + "" + ]; + + if (result.decisionRecords.length === 0) { + lines.push("- No resolved review-decision records are ready for export."); + } else { + result.decisionRecords.forEach((record) => { + lines.push(`- ${record.id}: ${record.component} ${record.decision} by ${record.reviewerRole}`); + lines.push(` - Anchor: ${record.anchorPath} @ ${record.anchorCommit}`); + lines.push(` - Export state: ${record.exportState}`); + }); + } + + lines.push("", "## Findings", ""); + if (result.findings.length === 0) { + lines.push("- No release-blocking decision-provenance findings."); + } else { + result.findings.forEach((finding) => { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + lines.push(` - Evidence: ${finding.evidence}`); + lines.push(` - Remediation: ${finding.remediation}`); + }); + } + + lines.push("", "## Manifest Patch", ""); + lines.push("```json"); + lines.push(JSON.stringify(result.manifestPatch, null, 2)); + lines.push("```"); + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const counts = result.findingCounts; + const blockers = (counts.critical ?? 0) + (counts.high ?? 0); + const warning = counts.warning ?? 0; + const covered = result.coverage.coveredComponents.length; + const missing = result.coverage.missingComponents.length; + const statusColor = result.status === "READY" ? "#16794c" : result.status === "REVIEW" ? "#a15c00" : "#a11b32"; + const coveredWidth = Math.min(390, covered * 64); + const missingWidth = Math.min(260, missing * 64); + const warningWidth = Math.min(190, warning * 38); + + return [ + ``, + ``, + ``, + `Review-decision provenance`, + `Status ${escapeXml(result.status)} - fingerprint ${escapeXml(result.fingerprint)}`, + ``, + ``, + ``, + `DECISION PACKET`, + `Critical/high blockers: ${blockers}`, + `Decision records: ${result.decisionRecords.length}`, + `Coverage: ${covered}/${result.coverage.requiredComponents.length} components`, + `` + ].join("\n"); +} + +function inspectReleaseCandidate(releaseCandidate, policy, findings) { + if (!releaseCandidate.tag) { + findings.push( + finding( + "RELEASE_TAG_MISSING", + "high", + "The repository release candidate has no stable tag.", + "Decision provenance must bind to a tag or export version.", + "releaseCandidate.tag", + "Attach the semantic tag or export version before publishing the review-decision packet.", + "repository maintainer" + ) + ); + } + + if (!releaseCandidate.exportManifest || !isPlainObject(releaseCandidate.exportManifest)) { + findings.push( + finding( + "EXPORT_MANIFEST_MISSING", + "high", + "The release candidate has no export manifest.", + "Review decisions need to be discoverable from the repository export bundle.", + "releaseCandidate.exportManifest", + "Add an export manifest with DOI, bundle hash, component hashes, and review decision packet references.", + "release engineer" + ) + ); + return; + } + + const manifest = releaseCandidate.exportManifest; + if (!manifest.doi && !manifest.persistentId) { + findings.push( + finding( + "PERSISTENT_ID_MISSING", + "warning", + "The export manifest has no DOI or persistent identifier.", + "Citation and redirect tooling cannot trace reviewer decisions to a durable release.", + "releaseCandidate.exportManifest.doi", + "Add DOI/DataCite or a stable persistent id before public export.", + "metadata curator" + ) + ); + } + + if (!manifest.bundleHash) { + findings.push( + finding( + "EXPORT_BUNDLE_HASH_MISSING", + "high", + "The export manifest does not declare a bundle hash.", + "Decision records cannot prove which export they reviewed.", + "releaseCandidate.exportManifest.bundleHash", + "Record the export bundle SHA-256 or equivalent content digest.", + "release engineer" + ) + ); + } + + if (policy.requireExportManifestDecisionPacket && !manifest.reviewDecisionPacket) { + findings.push( + finding( + "REVIEW_DECISION_PACKET_NOT_LINKED", + "high", + "The export manifest does not link the review-decision provenance packet.", + "Resolved scientific review decisions must be carried with the release/export evidence.", + "releaseCandidate.exportManifest.reviewDecisionPacket", + "Add a reviewDecisionPacket entry with packet hash, record count, and covered components.", + "release engineer" + ) + ); + } +} + +function inspectComponentCoverage(components, mergeRequests, policy, findings) { + const touchedComponents = new Set(); + mergeRequests.forEach((mergeRequest) => { + asArray(mergeRequest.changedComponents).forEach((component) => touchedComponents.add(component)); + asArray(mergeRequest.reviewThreads).forEach((thread) => { + if (thread.component) { + touchedComponents.add(thread.component); + } + }); + }); + + const declaredComponents = new Set(components.map((component) => component.type).filter(Boolean)); + policy.requiredDecisionComponents.forEach((component) => { + if (!declaredComponents.has(component) && !touchedComponents.has(component)) { + findings.push( + finding( + "REQUIRED_COMPONENT_NOT_DECLARED", + "warning", + `No repository component declaration or review touchpoint was found for ${component}.`, + "Release provenance is strongest when all core scientific repository components are represented.", + "repositoryComponents", + `Declare ${component} in repositoryComponents or document why it is out of scope for this release.`, + "repository maintainer" + ) + ); + } + }); +} + +function inspectMergeRequest(mergeRequest, mergeRequestIndex, policy, findings, decisionRecords) { + const basePath = `mergeRequests[${mergeRequestIndex}]`; + const mergeRequestId = mergeRequest.id ?? `mr-${mergeRequestIndex}`; + const threads = asArray(mergeRequest.reviewThreads); + + if (!mergeRequest.id) { + findings.push( + finding( + "MERGE_REQUEST_ID_MISSING", + "warning", + `Merge request at index ${mergeRequestIndex} has no stable id.`, + "Decision records should remain traceable to a merge request id.", + `${basePath}.id`, + "Assign a stable merge request id before generating release provenance.", + "repository maintainer", + mergeRequestId + ) + ); + } + + if (threads.length === 0) { + findings.push( + finding( + "NO_REVIEW_THREADS", + "high", + `${mergeRequestId} has no review discussion threads.`, + "The guard preserves resolved scientific decisions; a release-changing merge needs review decisions.", + `${basePath}.reviewThreads`, + "Attach resolved scientific review threads or mark the merge request as documentation-only with justification.", + "scientific editor", + mergeRequestId + ) + ); + } + + threads.forEach((thread, threadIndex) => + inspectThread(thread, threadIndex, mergeRequest, basePath, policy, findings, decisionRecords) + ); +} + +function inspectThread(thread, threadIndex, mergeRequest, basePath, policy, findings, decisionRecords) { + const threadPath = `${basePath}.reviewThreads[${threadIndex}]`; + const threadId = thread.id ?? `${mergeRequest.id ?? "mr"}-thread-${threadIndex}`; + const component = thread.component ?? inferComponentFromPath(thread.anchor?.path ?? thread.filePath ?? ""); + const allowedRoles = policy.requiredReviewerRolesByComponent[component] ?? []; + const status = String(thread.status ?? "").toLowerCase(); + + if (!thread.id) { + findings.push( + finding( + "THREAD_ID_MISSING", + "warning", + `Review thread at index ${threadIndex} has no stable id.`, + "Decision records need stable thread ids for later audit and citation package lookup.", + `${threadPath}.id`, + "Add a stable review thread id.", + "repository maintainer", + mergeRequest.id, + threadId + ) + ); + } + + if (status !== "resolved") { + findings.push( + finding( + "THREAD_NOT_RESOLVED_FOR_EXPORT", + "high", + `${threadId} is not resolved but is present in the release decision packet.`, + "This guard archives resolved decision provenance; open discussions should be routed back to merge review.", + `${threadPath}.status`, + "Resolve the discussion with a rationale or remove it from the release decision packet until review is complete.", + "scientific editor", + mergeRequest.id, + threadId + ) + ); + } + + if (!component) { + findings.push( + finding( + "THREAD_COMPONENT_MISSING", + "high", + `${threadId} is not mapped to a repository component.`, + "Scientific review decisions must state whether they apply to manuscript, data, code, notebook, protocol, results, or metadata.", + `${threadPath}.component`, + "Map the thread to a repository component before export.", + "scientific editor", + mergeRequest.id, + threadId + ) + ); + } + + if (component && allowedRoles.length > 0 && !allowedRoles.includes(thread.reviewerRole)) { + findings.push( + finding( + "REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT", + "high", + `${threadId} uses reviewer role ${thread.reviewerRole ?? "unknown"} for ${component}.`, + `Allowed reviewer roles for ${component}: ${allowedRoles.join(", ")}.`, + `${threadPath}.reviewerRole`, + "Add an eligible reviewer decision or document an escalation override in the decision record.", + "scientific editor", + mergeRequest.id, + threadId + ) + ); + } + + const anchor = isPlainObject(thread.anchor) ? thread.anchor : {}; + if (!anchor.path || !anchor.commit) { + findings.push( + finding( + "THREAD_ANCHOR_INCOMPLETE", + "high", + `${threadId} is missing file path or commit anchoring.`, + "Decision records cannot prove which repository content was reviewed without path and commit anchors.", + `${threadPath}.anchor`, + "Attach anchor.path and anchor.commit for the reviewed file or notebook output.", + "repository maintainer", + mergeRequest.id, + threadId + ) + ); + } + + if (status === "resolved" && (!thread.resolutionRationale || String(thread.resolutionRationale).trim().length < 16)) { + findings.push( + finding( + "RESOLUTION_RATIONALE_TOO_THIN", + "high", + `${threadId} has no durable resolution rationale.`, + "A resolved scientific review thread needs enough rationale for future auditors and citation readers.", + `${threadPath}.resolutionRationale`, + "Record the accepted change, scientific reasoning, or deferral basis in the resolution rationale.", + "reviewer" + ) + ); + } + + if (thread.containsPrivateNote && !thread.redactedForExport) { + findings.push( + finding( + "PRIVATE_REVIEW_NOTE_EXPORTED", + "critical", + `${threadId} contains a private reviewer note without export redaction.`, + "Repository exports must not leak private reviewer notes, blind-review details, or hidden institutional comments.", + `${threadPath}.redactedForExport`, + "Redact private notes and preserve only a public-safe reason code in the exported decision record.", + "scientific editor", + mergeRequest.id, + threadId + ) + ); + } + + if (status === "resolved" && thread.exported !== true) { + findings.push( + finding( + "RESOLVED_DECISION_NOT_EXPORTED", + "high", + `${threadId} is resolved but not marked for export.`, + "Resolved scientific review decisions should be carried into release/export evidence instead of disappearing after merge.", + `${threadPath}.exported`, + "Set exported=true and include the record in the reviewDecisionPacket manifest entry.", + "release engineer", + mergeRequest.id, + threadId + ) + ); + } + + if (asArray(thread.evidenceRefs).length === 0) { + findings.push( + finding( + "DECISION_EVIDENCE_REFS_MISSING", + "warning", + `${threadId} has no linked evidence references.`, + "Decision records should link tests, rendered outputs, reviewer screenshots, or manifest entries.", + `${threadPath}.evidenceRefs`, + "Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments.", + "reviewer", + mergeRequest.id, + threadId + ) + ); + } + + decisionRecords.push({ + id: threadId, + mergeRequestId: mergeRequest.id ?? null, + component: component ?? "unknown", + category: thread.category ?? "review-decision", + decision: thread.decision ?? "unspecified", + reviewerRole: thread.reviewerRole ?? "unknown", + reviewerId: thread.reviewerId ?? "unknown", + anchorPath: anchor.path ?? null, + anchorCommit: anchor.commit ?? null, + resolutionRationale: thread.resolutionRationale ?? "", + exportState: thread.exported === true ? "included" : "missing", + redactionState: thread.containsPrivateNote ? (thread.redactedForExport ? "redacted" : "leaking") : "public-safe", + evidenceRefs: asArray(thread.evidenceRefs) + }); +} + +function buildCoverageSummary(policy, decisionRecords, components) { + const covered = new Set( + decisionRecords + .filter((record) => record.exportState === "included" && record.redactionState !== "leaking") + .map((record) => record.component) + ); + const declared = new Set(components.map((component) => component.type).filter(Boolean)); + const requiredComponents = policy.requiredDecisionComponents.filter((component) => declared.has(component) || covered.has(component)); + const required = requiredComponents.length > 0 ? requiredComponents : policy.requiredDecisionComponents; + + return { + requiredComponents: required, + coveredComponents: required.filter((component) => covered.has(component)), + missingComponents: required.filter((component) => !covered.has(component)), + exportedRecordCount: decisionRecords.filter((record) => record.exportState === "included").length + }; +} + +function buildReleaseReadiness(status, findings, decisionRecords, releaseCandidate) { + const blockers = findings.filter((item) => item.severity === "critical" || item.severity === "high"); + return { + decision: status === "READY" ? "release" : status === "REVIEW" ? "review" : "hold", + releaseCandidate: releaseCandidate.tag ?? null, + blockerCodes: blockers.map((item) => item.code), + exportedDecisionRecords: decisionRecords.filter((record) => record.exportState === "included").length, + privateLeakRecords: decisionRecords.filter((record) => record.redactionState === "leaking").map((record) => record.id) + }; +} + +function buildManifestPatch(packet, releaseCandidate, decisionRecords, findings) { + const exportedRecords = decisionRecords.filter((record) => record.exportState === "included" && record.redactionState !== "leaking"); + const packetHash = crypto + .createHash("sha256") + .update(JSON.stringify({ packetId: packet.id, exportedRecords })) + .digest("hex"); + + return { + reviewDecisionPacket: { + packetId: packet.id ?? "review-decision-provenance-packet", + packetHash, + releaseTag: releaseCandidate.tag ?? null, + decisionRecordCount: exportedRecords.length, + coveredComponents: [...new Set(exportedRecords.map((record) => record.component))].sort(), + findingCount: findings.length + } + }; +} + +function summarize(status, findings, decisionRecords, coverage) { + if (status === "READY") { + return `Release/export is ready with ${decisionRecords.length} review decision record(s) and coverage for ${coverage.coveredComponents.length} component(s).`; + } + + const blockers = findings.filter((finding) => finding.severity === "critical" || finding.severity === "high").length; + if (status === "HOLD") { + return `Hold release/export: ${blockers} critical or high decision-provenance blocker(s) need remediation before reviewer decisions are durable and public-safe.`; + } + + return `Reviewer follow-up needed: ${findings.length} finding(s) remain, mostly warning-level evidence gaps.`; +} + +function finding(code, severity, message, evidence, path, remediation, owner, mergeRequestId = null, threadId = null) { + return { + code, + severity, + message, + evidence, + path, + remediation, + owner, + mergeRequestId, + threadId + }; +} + +function mergePolicy(policy) { + const incoming = isPlainObject(policy) ? policy : {}; + return { + ...DEFAULT_POLICY, + ...incoming, + requiredReviewerRolesByComponent: { + ...DEFAULT_POLICY.requiredReviewerRolesByComponent, + ...(isPlainObject(incoming.requiredReviewerRolesByComponent) ? incoming.requiredReviewerRolesByComponent : {}) + } + }; +} + +function sortFindings(findings) { + return [...findings].sort((a, b) => { + const severityDelta = SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return a.code.localeCompare(b.code); + }); +} + +function determineStatus(findings) { + if (findings.some((finding) => finding.severity === "critical" || finding.severity === "high")) { + return "HOLD"; + } + if (findings.some((finding) => finding.severity === "warning")) { + return "REVIEW"; + } + return "READY"; +} + +function countBySeverity(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] ?? 0) + 1; + return counts; + }, {}); +} + +function buildFingerprint(value) { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function inferComponentFromPath(filePath) { + if (!filePath) { + return null; + } + const first = String(filePath).split("/")[0]; + const map = { + manuscript: "manuscript", + manuscripts: "manuscript", + data: "data", + code: "code", + notebooks: "notebook", + notebook: "notebook", + metadata: "metadata", + protocols: "protocol", + protocol: "protocol", + results: "results" + }; + return map[first] ?? null; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + DEFAULT_POLICY, + evaluateReviewDecisionProvenance, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/repository-review-decision-provenance-guard/make-demo-video.js b/repository-review-decision-provenance-guard/make-demo-video.js new file mode 100644 index 00000000..25304a23 --- /dev/null +++ b/repository-review-decision-provenance-guard/make-demo-video.js @@ -0,0 +1,130 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + 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"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + X: ["10001", "01010", "00100", "00100", "00100", "01010", "10001"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "DECISION PACK", color: [22, 121, 76], fill: 0.76 }, + { label: "THREAD ANCHOR", color: [22, 121, 76], fill: 0.68 }, + { label: "PRIVATE NOTE", color: [161, 27, 50], fill: 0.92 }, + { label: "EXPORT HOLD", color: [161, 92, 0], fill: 0.82 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [24, 33, 47]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 192, 800, 86, [226, 232, 240]); + fillRect(pixels, 80, 192, Math.round(800 * slide.fill * progress), 86, slide.color); + fillRect(pixels, 80, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 332, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 584, 324, 220, 42, [226, 232, 240]); + fillRect(pixels, 80, 324, 180, 42, [22, 121, 76]); + fillRect(pixels, 332, 324, 120, 42, [161, 92, 0]); + fillRect(pixels, 584, 324, 160, 42, [161, 27, 50]); + drawText(pixels, "REVIEW DECISIONS", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 216, 7, [255, 255, 255]); + drawText(pixels, "RELEASE EVIDENCE", 82, 416, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; 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, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/repository-review-decision-provenance-guard/package.json b/repository-review-decision-provenance-guard/package.json new file mode 100644 index 00000000..28e9a1a7 --- /dev/null +++ b/repository-review-decision-provenance-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "repository-review-decision-provenance-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic repository review-decision provenance guard for scientific project releases and exports.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "scientific-repository", + "review-provenance", + "merge-request", + "export-bundle", + "synthetic" + ], + "license": "MIT" +} diff --git a/repository-review-decision-provenance-guard/reports/clean-audit.json b/repository-review-decision-provenance-guard/reports/clean-audit.json new file mode 100644 index 00000000..414d8c35 --- /dev/null +++ b/repository-review-decision-provenance-guard/reports/clean-audit.json @@ -0,0 +1,140 @@ +{ + "generatedAt": "2026-06-01T10:39:58.090Z", + "status": "READY", + "summary": "Release/export is ready with 5 review decision record(s) and coverage for 5 component(s).", + "findingCounts": {}, + "findings": [], + "coverage": { + "requiredComponents": [ + "manuscript", + "data", + "code", + "notebook", + "metadata" + ], + "coveredComponents": [ + "manuscript", + "data", + "code", + "notebook", + "metadata" + ], + "missingComponents": [], + "exportedRecordCount": 5 + }, + "decisionRecords": [ + { + "id": "T-manuscript-claims", + "mergeRequestId": "MR-242", + "component": "manuscript", + "category": "review-decision", + "decision": "accepted", + "reviewerRole": "scientific-editor", + "reviewerId": "reviewer-a", + "anchorPath": "manuscript/main.md", + "anchorCommit": "0e5fbb1", + "resolutionRationale": "The discussion now records the revised claim language and the corresponding figure reference.", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [ + "reports/rendered-manuscript-v2.pdf", + "reviews/thread-T-manuscript-claims.md" + ] + }, + { + "id": "T-data-units", + "mergeRequestId": "MR-242", + "component": "data", + "category": "review-decision", + "decision": "changed", + "reviewerRole": "data-steward", + "reviewerId": "reviewer-b", + "anchorPath": "data/observations.csv", + "anchorCommit": "0e5fbb1", + "resolutionRationale": "The dataset header now states SI units and links to the source instrument calibration sheet.", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [ + "data/README.md", + "reports/data-dictionary.json" + ] + }, + { + "id": "T-code-seed", + "mergeRequestId": "MR-242", + "component": "code", + "category": "review-decision", + "decision": "changed", + "reviewerRole": "reproducibility-reviewer", + "reviewerId": "reviewer-c", + "anchorPath": "code/analysis.py", + "anchorCommit": "0e5fbb1", + "resolutionRationale": "The analysis entry point now sets a deterministic seed and exports run metadata.", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [ + "reports/replay-log.json" + ] + }, + { + "id": "T-notebook-output", + "mergeRequestId": "MR-242", + "component": "notebook", + "category": "review-decision", + "decision": "accepted", + "reviewerRole": "reproducibility-reviewer", + "reviewerId": "reviewer-c", + "anchorPath": "notebooks/replay.ipynb", + "anchorCommit": "0e5fbb1", + "resolutionRationale": "Notebook output hashes match the exported result bundle after the data-unit correction.", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [ + "reports/notebook-output-hashes.json" + ] + }, + { + "id": "T-metadata-doi", + "mergeRequestId": "MR-242", + "component": "metadata", + "category": "review-decision", + "decision": "accepted", + "reviewerRole": "metadata-curator", + "reviewerId": "reviewer-d", + "anchorPath": "metadata.json", + "anchorCommit": "0e5fbb1", + "resolutionRationale": "DataCite creator, funder, and relatedIdentifier entries now match the release manifest.", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [ + "metadata.json", + "reports/datacite-preview.json" + ] + } + ], + "releaseReadiness": { + "decision": "release", + "releaseCandidate": "v2.1-review-ready", + "blockerCodes": [], + "exportedDecisionRecords": 5, + "privateLeakRecords": [] + }, + "manifestPatch": { + "reviewDecisionPacket": { + "packetId": "packet-clean-review-decisions", + "packetHash": "3147d13fb2e62d6b6061aad7ab0a35699663814854348412f62ab513f3743a9b", + "releaseTag": "v2.1-review-ready", + "decisionRecordCount": 5, + "coveredComponents": [ + "code", + "data", + "manuscript", + "metadata", + "notebook" + ], + "findingCount": 0 + } + }, + "remediationActions": [], + "fingerprint": "7958e872619a283c" +} diff --git a/repository-review-decision-provenance-guard/reports/demo.mp4 b/repository-review-decision-provenance-guard/reports/demo.mp4 new file mode 100644 index 00000000..0c205958 Binary files /dev/null and b/repository-review-decision-provenance-guard/reports/demo.mp4 differ diff --git a/repository-review-decision-provenance-guard/reports/manifest.json b/repository-review-decision-provenance-guard/reports/manifest.json new file mode 100644 index 00000000..cbd0114d --- /dev/null +++ b/repository-review-decision-provenance-guard/reports/manifest.json @@ -0,0 +1,14 @@ +{ + "generatedAt": "2026-06-01T10:39:58.097Z", + "module": "repository-review-decision-provenance-guard", + "cleanStatus": "READY", + "riskyStatus": "HOLD", + "riskyFindings": 9, + "artifacts": [ + "clean-audit.json", + "risky-audit.json", + "risky-review.md", + "summary.svg", + "demo.mp4" + ] +} diff --git a/repository-review-decision-provenance-guard/reports/risky-audit.json b/repository-review-decision-provenance-guard/reports/risky-audit.json new file mode 100644 index 00000000..5dc21af5 --- /dev/null +++ b/repository-review-decision-provenance-guard/reports/risky-audit.json @@ -0,0 +1,282 @@ +{ + "generatedAt": "2026-06-01T10:39:58.091Z", + "status": "HOLD", + "summary": "Hold release/export: 7 critical or high decision-provenance blocker(s) need remediation before reviewer decisions are durable and public-safe.", + "findingCounts": { + "critical": 1, + "high": 6, + "warning": 2 + }, + "findings": [ + { + "code": "PRIVATE_REVIEW_NOTE_EXPORTED", + "severity": "critical", + "message": "T-private-note contains a private reviewer note without export redaction.", + "evidence": "Repository exports must not leak private reviewer notes, blind-review details, or hidden institutional comments.", + "path": "mergeRequests[0].reviewThreads[0].redactedForExport", + "remediation": "Redact private notes and preserve only a public-safe reason code in the exported decision record.", + "owner": "scientific editor", + "mergeRequestId": "MR-317", + "threadId": "T-private-note" + }, + { + "code": "RESOLUTION_RATIONALE_TOO_THIN", + "severity": "high", + "message": "T-notebook-role has no durable resolution rationale.", + "evidence": "A resolved scientific review thread needs enough rationale for future auditors and citation readers.", + "path": "mergeRequests[0].reviewThreads[2].resolutionRationale", + "remediation": "Record the accepted change, scientific reasoning, or deferral basis in the resolution rationale.", + "owner": "reviewer", + "mergeRequestId": null, + "threadId": null + }, + { + "code": "RESOLVED_DECISION_NOT_EXPORTED", + "severity": "high", + "message": "T-metadata-missing-export is resolved but not marked for export.", + "evidence": "Resolved scientific review decisions should be carried into release/export evidence instead of disappearing after merge.", + "path": "mergeRequests[0].reviewThreads[3].exported", + "remediation": "Set exported=true and include the record in the reviewDecisionPacket manifest entry.", + "owner": "release engineer", + "mergeRequestId": "MR-317", + "threadId": "T-metadata-missing-export" + }, + { + "code": "REVIEW_DECISION_PACKET_NOT_LINKED", + "severity": "high", + "message": "The export manifest does not link the review-decision provenance packet.", + "evidence": "Resolved scientific review decisions must be carried with the release/export evidence.", + "path": "releaseCandidate.exportManifest.reviewDecisionPacket", + "remediation": "Add a reviewDecisionPacket entry with packet hash, record count, and covered components.", + "owner": "release engineer", + "mergeRequestId": null, + "threadId": null + }, + { + "code": "REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT", + "severity": "high", + "message": "T-notebook-role uses reviewer role viewer for notebook.", + "evidence": "Allowed reviewer roles for notebook: reproducibility-reviewer, methods-reviewer.", + "path": "mergeRequests[0].reviewThreads[2].reviewerRole", + "remediation": "Add an eligible reviewer decision or document an escalation override in the decision record.", + "owner": "scientific editor", + "mergeRequestId": "MR-317", + "threadId": "T-notebook-role" + }, + { + "code": "THREAD_ANCHOR_INCOMPLETE", + "severity": "high", + "message": "T-notebook-role is missing file path or commit anchoring.", + "evidence": "Decision records cannot prove which repository content was reviewed without path and commit anchors.", + "path": "mergeRequests[0].reviewThreads[2].anchor", + "remediation": "Attach anchor.path and anchor.commit for the reviewed file or notebook output.", + "owner": "repository maintainer", + "mergeRequestId": "MR-317", + "threadId": "T-notebook-role" + }, + { + "code": "THREAD_NOT_RESOLVED_FOR_EXPORT", + "severity": "high", + "message": "T-code-open is not resolved but is present in the release decision packet.", + "evidence": "This guard archives resolved decision provenance; open discussions should be routed back to merge review.", + "path": "mergeRequests[0].reviewThreads[1].status", + "remediation": "Resolve the discussion with a rationale or remove it from the release decision packet until review is complete.", + "owner": "scientific editor", + "mergeRequestId": "MR-317", + "threadId": "T-code-open" + }, + { + "code": "DECISION_EVIDENCE_REFS_MISSING", + "severity": "warning", + "message": "T-code-open has no linked evidence references.", + "evidence": "Decision records should link tests, rendered outputs, reviewer screenshots, or manifest entries.", + "path": "mergeRequests[0].reviewThreads[1].evidenceRefs", + "remediation": "Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments.", + "owner": "reviewer", + "mergeRequestId": "MR-317", + "threadId": "T-code-open" + }, + { + "code": "DECISION_EVIDENCE_REFS_MISSING", + "severity": "warning", + "message": "T-notebook-role has no linked evidence references.", + "evidence": "Decision records should link tests, rendered outputs, reviewer screenshots, or manifest entries.", + "path": "mergeRequests[0].reviewThreads[2].evidenceRefs", + "remediation": "Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments.", + "owner": "reviewer", + "mergeRequestId": "MR-317", + "threadId": "T-notebook-role" + } + ], + "coverage": { + "requiredComponents": [ + "manuscript", + "data", + "code", + "notebook", + "metadata" + ], + "coveredComponents": [ + "notebook" + ], + "missingComponents": [ + "manuscript", + "data", + "code", + "metadata" + ], + "exportedRecordCount": 2 + }, + "decisionRecords": [ + { + "id": "T-private-note", + "mergeRequestId": "MR-317", + "component": "data", + "category": "review-decision", + "decision": "deferred", + "reviewerRole": "data-steward", + "reviewerId": "reviewer-z", + "anchorPath": "data/patient-observations.csv", + "anchorCommit": "feedbee", + "resolutionRationale": "Data row issue discussed in private.", + "exportState": "included", + "redactionState": "leaking", + "evidenceRefs": [ + "reviews/private-note.md" + ] + }, + { + "id": "T-code-open", + "mergeRequestId": "MR-317", + "component": "code", + "category": "review-decision", + "decision": "unspecified", + "reviewerRole": "maintainer", + "reviewerId": "reviewer-c", + "anchorPath": "code/analysis.py", + "anchorCommit": "feedbee", + "resolutionRationale": "", + "exportState": "missing", + "redactionState": "public-safe", + "evidenceRefs": [] + }, + { + "id": "T-notebook-role", + "mergeRequestId": "MR-317", + "component": "notebook", + "category": "review-decision", + "decision": "accepted", + "reviewerRole": "viewer", + "reviewerId": "reviewer-q", + "anchorPath": "notebooks/replay.ipynb", + "anchorCommit": null, + "resolutionRationale": "Looks fine", + "exportState": "included", + "redactionState": "public-safe", + "evidenceRefs": [] + }, + { + "id": "T-metadata-missing-export", + "mergeRequestId": "MR-317", + "component": "metadata", + "category": "review-decision", + "decision": "changed", + "reviewerRole": "metadata-curator", + "reviewerId": "reviewer-d", + "anchorPath": "metadata.json", + "anchorCommit": "feedbee", + "resolutionRationale": "Added related identifiers for the cited dataset version.", + "exportState": "missing", + "redactionState": "public-safe", + "evidenceRefs": [ + "metadata.json" + ] + } + ], + "releaseReadiness": { + "decision": "hold", + "releaseCandidate": "v2.2-public-export", + "blockerCodes": [ + "PRIVATE_REVIEW_NOTE_EXPORTED", + "RESOLUTION_RATIONALE_TOO_THIN", + "RESOLVED_DECISION_NOT_EXPORTED", + "REVIEW_DECISION_PACKET_NOT_LINKED", + "REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT", + "THREAD_ANCHOR_INCOMPLETE", + "THREAD_NOT_RESOLVED_FOR_EXPORT" + ], + "exportedDecisionRecords": 2, + "privateLeakRecords": [ + "T-private-note" + ] + }, + "manifestPatch": { + "reviewDecisionPacket": { + "packetId": "packet-risky-review-decisions", + "packetHash": "aceced7e057804cb8815b37798951f94595973bc5a5af72ac5aedf10b70afcb5", + "releaseTag": "v2.2-public-export", + "decisionRecordCount": 1, + "coveredComponents": [ + "notebook" + ], + "findingCount": 9 + } + }, + "remediationActions": [ + { + "code": "PRIVATE_REVIEW_NOTE_EXPORTED", + "owner": "scientific editor", + "action": "Redact private notes and preserve only a public-safe reason code in the exported decision record.", + "path": "mergeRequests[0].reviewThreads[0].redactedForExport" + }, + { + "code": "RESOLUTION_RATIONALE_TOO_THIN", + "owner": "reviewer", + "action": "Record the accepted change, scientific reasoning, or deferral basis in the resolution rationale.", + "path": "mergeRequests[0].reviewThreads[2].resolutionRationale" + }, + { + "code": "RESOLVED_DECISION_NOT_EXPORTED", + "owner": "release engineer", + "action": "Set exported=true and include the record in the reviewDecisionPacket manifest entry.", + "path": "mergeRequests[0].reviewThreads[3].exported" + }, + { + "code": "REVIEW_DECISION_PACKET_NOT_LINKED", + "owner": "release engineer", + "action": "Add a reviewDecisionPacket entry with packet hash, record count, and covered components.", + "path": "releaseCandidate.exportManifest.reviewDecisionPacket" + }, + { + "code": "REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT", + "owner": "scientific editor", + "action": "Add an eligible reviewer decision or document an escalation override in the decision record.", + "path": "mergeRequests[0].reviewThreads[2].reviewerRole" + }, + { + "code": "THREAD_ANCHOR_INCOMPLETE", + "owner": "repository maintainer", + "action": "Attach anchor.path and anchor.commit for the reviewed file or notebook output.", + "path": "mergeRequests[0].reviewThreads[2].anchor" + }, + { + "code": "THREAD_NOT_RESOLVED_FOR_EXPORT", + "owner": "scientific editor", + "action": "Resolve the discussion with a rationale or remove it from the release decision packet until review is complete.", + "path": "mergeRequests[0].reviewThreads[1].status" + }, + { + "code": "DECISION_EVIDENCE_REFS_MISSING", + "owner": "reviewer", + "action": "Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments.", + "path": "mergeRequests[0].reviewThreads[1].evidenceRefs" + }, + { + "code": "DECISION_EVIDENCE_REFS_MISSING", + "owner": "reviewer", + "action": "Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments.", + "path": "mergeRequests[0].reviewThreads[2].evidenceRefs" + } + ], + "fingerprint": "ecea7282a32c0a38" +} diff --git a/repository-review-decision-provenance-guard/reports/risky-review.md b/repository-review-decision-provenance-guard/reports/risky-review.md new file mode 100644 index 00000000..98cd0a4e --- /dev/null +++ b/repository-review-decision-provenance-guard/reports/risky-review.md @@ -0,0 +1,78 @@ +# Repository Review-Decision Provenance Guard + +Packet: packet-risky-review-decisions +Release candidate: v2.2-public-export +Status: HOLD +Fingerprint: ecea7282a32c0a38 + +## Summary + +Hold release/export: 7 critical or high decision-provenance blocker(s) need remediation before reviewer decisions are durable and public-safe. + +## Coverage + +- Components requiring decision evidence: manuscript, data, code, notebook, metadata +- Covered components: notebook +- Missing components: manuscript, data, code, metadata + +## Decision Records + +- T-private-note: data deferred by data-steward + - Anchor: data/patient-observations.csv @ feedbee + - Export state: included +- T-code-open: code unspecified by maintainer + - Anchor: code/analysis.py @ feedbee + - Export state: missing +- T-notebook-role: notebook accepted by viewer + - Anchor: notebooks/replay.ipynb @ null + - Export state: included +- T-metadata-missing-export: metadata changed by metadata-curator + - Anchor: metadata.json @ feedbee + - Export state: missing + +## Findings + +- CRITICAL PRIVATE_REVIEW_NOTE_EXPORTED: T-private-note contains a private reviewer note without export redaction. + - Evidence: Repository exports must not leak private reviewer notes, blind-review details, or hidden institutional comments. + - Remediation: Redact private notes and preserve only a public-safe reason code in the exported decision record. +- HIGH RESOLUTION_RATIONALE_TOO_THIN: T-notebook-role has no durable resolution rationale. + - Evidence: A resolved scientific review thread needs enough rationale for future auditors and citation readers. + - Remediation: Record the accepted change, scientific reasoning, or deferral basis in the resolution rationale. +- HIGH RESOLVED_DECISION_NOT_EXPORTED: T-metadata-missing-export is resolved but not marked for export. + - Evidence: Resolved scientific review decisions should be carried into release/export evidence instead of disappearing after merge. + - Remediation: Set exported=true and include the record in the reviewDecisionPacket manifest entry. +- HIGH REVIEW_DECISION_PACKET_NOT_LINKED: The export manifest does not link the review-decision provenance packet. + - Evidence: Resolved scientific review decisions must be carried with the release/export evidence. + - Remediation: Add a reviewDecisionPacket entry with packet hash, record count, and covered components. +- HIGH REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT: T-notebook-role uses reviewer role viewer for notebook. + - Evidence: Allowed reviewer roles for notebook: reproducibility-reviewer, methods-reviewer. + - Remediation: Add an eligible reviewer decision or document an escalation override in the decision record. +- HIGH THREAD_ANCHOR_INCOMPLETE: T-notebook-role is missing file path or commit anchoring. + - Evidence: Decision records cannot prove which repository content was reviewed without path and commit anchors. + - Remediation: Attach anchor.path and anchor.commit for the reviewed file or notebook output. +- HIGH THREAD_NOT_RESOLVED_FOR_EXPORT: T-code-open is not resolved but is present in the release decision packet. + - Evidence: This guard archives resolved decision provenance; open discussions should be routed back to merge review. + - Remediation: Resolve the discussion with a rationale or remove it from the release decision packet until review is complete. +- WARNING DECISION_EVIDENCE_REFS_MISSING: T-code-open has no linked evidence references. + - Evidence: Decision records should link tests, rendered outputs, reviewer screenshots, or manifest entries. + - Remediation: Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments. +- WARNING DECISION_EVIDENCE_REFS_MISSING: T-notebook-role has no linked evidence references. + - Evidence: Decision records should link tests, rendered outputs, reviewer screenshots, or manifest entries. + - Remediation: Attach evidenceRefs for tests, outputs, exported artifacts, or reviewer attachments. + +## Manifest Patch + +```json +{ + "reviewDecisionPacket": { + "packetId": "packet-risky-review-decisions", + "packetHash": "aceced7e057804cb8815b37798951f94595973bc5a5af72ac5aedf10b70afcb5", + "releaseTag": "v2.2-public-export", + "decisionRecordCount": 1, + "coveredComponents": [ + "notebook" + ], + "findingCount": 9 + } +} +``` diff --git a/repository-review-decision-provenance-guard/reports/summary.svg b/repository-review-decision-provenance-guard/reports/summary.svg new file mode 100644 index 00000000..e57bd671 --- /dev/null +++ b/repository-review-decision-provenance-guard/reports/summary.svg @@ -0,0 +1,13 @@ + + + +Review-decision provenance +Status HOLD - fingerprint ecea7282a32c0a38 + + + +DECISION PACKET +Critical/high blockers: 7 +Decision records: 4 +Coverage: 1/5 components + \ No newline at end of file diff --git a/repository-review-decision-provenance-guard/sample-data.js b/repository-review-decision-provenance-guard/sample-data.js new file mode 100644 index 00000000..a3803f74 --- /dev/null +++ b/repository-review-decision-provenance-guard/sample-data.js @@ -0,0 +1,174 @@ +"use strict"; + +const cleanPacket = { + id: "packet-clean-review-decisions", + repositoryComponents: [ + { type: "manuscript", path: "manuscript/main.md" }, + { type: "data", path: "data/observations.csv" }, + { type: "code", path: "code/analysis.py" }, + { type: "notebook", path: "notebooks/replay.ipynb" }, + { type: "metadata", path: "metadata.json" } + ], + releaseCandidate: { + tag: "v2.1-review-ready", + exportManifest: { + doi: "10.5555/scibase.review.clean", + bundleHash: "sha256:1b2c3d4e5f6a7b8c", + reviewDecisionPacket: { + packetHash: "sha256:pending", + recordCount: 5 + } + } + }, + mergeRequests: [ + { + id: "MR-242", + title: "Revise field protocol and reproduce notebook outputs", + changedComponents: ["manuscript", "data", "code", "notebook", "metadata"], + reviewThreads: [ + { + id: "T-manuscript-claims", + component: "manuscript", + status: "resolved", + decision: "accepted", + reviewerRole: "scientific-editor", + reviewerId: "reviewer-a", + anchor: { path: "manuscript/main.md", line: 118, commit: "0e5fbb1" }, + resolutionRationale: "The discussion now records the revised claim language and the corresponding figure reference.", + exported: true, + evidenceRefs: ["reports/rendered-manuscript-v2.pdf", "reviews/thread-T-manuscript-claims.md"] + }, + { + id: "T-data-units", + component: "data", + status: "resolved", + decision: "changed", + reviewerRole: "data-steward", + reviewerId: "reviewer-b", + anchor: { path: "data/observations.csv", line: 1, commit: "0e5fbb1" }, + resolutionRationale: "The dataset header now states SI units and links to the source instrument calibration sheet.", + exported: true, + evidenceRefs: ["data/README.md", "reports/data-dictionary.json"] + }, + { + id: "T-code-seed", + component: "code", + status: "resolved", + decision: "changed", + reviewerRole: "reproducibility-reviewer", + reviewerId: "reviewer-c", + anchor: { path: "code/analysis.py", line: 42, commit: "0e5fbb1" }, + resolutionRationale: "The analysis entry point now sets a deterministic seed and exports run metadata.", + exported: true, + evidenceRefs: ["reports/replay-log.json"] + }, + { + id: "T-notebook-output", + component: "notebook", + status: "resolved", + decision: "accepted", + reviewerRole: "reproducibility-reviewer", + reviewerId: "reviewer-c", + anchor: { path: "notebooks/replay.ipynb", line: 12, commit: "0e5fbb1" }, + resolutionRationale: "Notebook output hashes match the exported result bundle after the data-unit correction.", + exported: true, + evidenceRefs: ["reports/notebook-output-hashes.json"] + }, + { + id: "T-metadata-doi", + component: "metadata", + status: "resolved", + decision: "accepted", + reviewerRole: "metadata-curator", + reviewerId: "reviewer-d", + anchor: { path: "metadata.json", line: 1, commit: "0e5fbb1" }, + resolutionRationale: "DataCite creator, funder, and relatedIdentifier entries now match the release manifest.", + exported: true, + evidenceRefs: ["metadata.json", "reports/datacite-preview.json"] + } + ] + } + ] +}; + +const riskyPacket = { + id: "packet-risky-review-decisions", + repositoryComponents: [ + { type: "manuscript", path: "manuscript/main.md" }, + { type: "data", path: "data/patient-observations.csv" }, + { type: "code", path: "code/analysis.py" }, + { type: "notebook", path: "notebooks/replay.ipynb" }, + { type: "metadata", path: "metadata.json" } + ], + releaseCandidate: { + tag: "v2.2-public-export", + exportManifest: { + doi: "10.5555/scibase.review.risky", + bundleHash: "sha256:risky" + } + }, + mergeRequests: [ + { + id: "MR-317", + title: "Prepare public export after restricted data review", + changedComponents: ["manuscript", "data", "code", "notebook", "metadata"], + reviewThreads: [ + { + id: "T-private-note", + component: "data", + status: "resolved", + decision: "deferred", + reviewerRole: "data-steward", + reviewerId: "reviewer-z", + anchor: { path: "data/patient-observations.csv", line: 24, commit: "feedbee" }, + resolutionRationale: "Data row issue discussed in private.", + containsPrivateNote: true, + redactedForExport: false, + exported: true, + evidenceRefs: ["reviews/private-note.md"] + }, + { + id: "T-code-open", + component: "code", + status: "open", + decision: "unspecified", + reviewerRole: "maintainer", + reviewerId: "reviewer-c", + anchor: { path: "code/analysis.py", line: 88, commit: "feedbee" }, + resolutionRationale: "", + exported: false, + evidenceRefs: [] + }, + { + id: "T-notebook-role", + component: "notebook", + status: "resolved", + decision: "accepted", + reviewerRole: "viewer", + reviewerId: "reviewer-q", + anchor: { path: "notebooks/replay.ipynb", line: 9 }, + resolutionRationale: "Looks fine", + exported: true, + evidenceRefs: [] + }, + { + id: "T-metadata-missing-export", + component: "metadata", + status: "resolved", + decision: "changed", + reviewerRole: "metadata-curator", + reviewerId: "reviewer-d", + anchor: { path: "metadata.json", line: 1, commit: "feedbee" }, + resolutionRationale: "Added related identifiers for the cited dataset version.", + exported: false, + evidenceRefs: ["metadata.json"] + } + ] + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/repository-review-decision-provenance-guard/test.js b/repository-review-decision-provenance-guard/test.js new file mode 100644 index 00000000..d5ca7e02 --- /dev/null +++ b/repository-review-decision-provenance-guard/test.js @@ -0,0 +1,36 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateReviewDecisionProvenance, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateReviewDecisionProvenance(cleanPacket, { now: "2026-06-01T10:35:00.000Z" }); +assert.equal(clean.status, "READY"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.decisionRecords.length, 5); +assert.deepEqual(clean.coverage.missingComponents, []); +assert.equal(clean.manifestPatch.reviewDecisionPacket.decisionRecordCount, 5); + +const risky = evaluateReviewDecisionProvenance(riskyPacket, { now: "2026-06-01T10:35:00.000Z" }); +assert.equal(risky.status, "HOLD"); +assert.ok(risky.findings.some((finding) => finding.code === "PRIVATE_REVIEW_NOTE_EXPORTED")); +assert.ok(risky.findings.some((finding) => finding.code === "THREAD_NOT_RESOLVED_FOR_EXPORT")); +assert.ok(risky.findings.some((finding) => finding.code === "REVIEW_DECISION_PACKET_NOT_LINKED")); +assert.ok(risky.findings.some((finding) => finding.code === "REVIEWER_ROLE_NOT_ELIGIBLE_FOR_COMPONENT")); +assert.ok(risky.releaseReadiness.privateLeakRecords.includes("T-private-note")); + +const markdown = renderMarkdownReport(risky, riskyPacket); +assert.match(markdown, /Repository Review-Decision Provenance Guard/); +assert.match(markdown, /PRIVATE_REVIEW_NOTE_EXPORTED/); + +const svg = renderSvgSummary(risky); +assert.match(svg, / evaluateReviewDecisionProvenance(null), /expects a packet object/); + +console.log("repository-review-decision-provenance-guard tests passed");