From 9a4b7eee76bbfe82b4889ccbc779babd4f0b5493 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:40:17 -0400 Subject: [PATCH] Add repository review decision provenance guard --- .../.gitignore | 1 + .../README.md | 41 ++ .../demo.js | 46 ++ .../index.js | 620 ++++++++++++++++++ .../make-demo-video.js | 130 ++++ .../package.json | 21 + .../reports/clean-audit.json | 140 ++++ .../reports/demo.mp4 | Bin 0 -> 25588 bytes .../reports/manifest.json | 14 + .../reports/risky-audit.json | 282 ++++++++ .../reports/risky-review.md | 78 +++ .../reports/summary.svg | 13 + .../sample-data.js | 174 +++++ .../test.js | 36 + 14 files changed, 1596 insertions(+) create mode 100644 repository-review-decision-provenance-guard/.gitignore create mode 100644 repository-review-decision-provenance-guard/README.md create mode 100644 repository-review-decision-provenance-guard/demo.js create mode 100644 repository-review-decision-provenance-guard/index.js create mode 100644 repository-review-decision-provenance-guard/make-demo-video.js create mode 100644 repository-review-decision-provenance-guard/package.json create mode 100644 repository-review-decision-provenance-guard/reports/clean-audit.json create mode 100644 repository-review-decision-provenance-guard/reports/demo.mp4 create mode 100644 repository-review-decision-provenance-guard/reports/manifest.json create mode 100644 repository-review-decision-provenance-guard/reports/risky-audit.json create mode 100644 repository-review-decision-provenance-guard/reports/risky-review.md create mode 100644 repository-review-decision-provenance-guard/reports/summary.svg create mode 100644 repository-review-decision-provenance-guard/sample-data.js create mode 100644 repository-review-decision-provenance-guard/test.js 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 0000000000000000000000000000000000000000..0c2059583b2fe24e47cbea9b5b7d9389259a9bc1 GIT binary patch literal 25588 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 B(xCtV literal 0 HcmV?d00001 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");