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 [
+ ``
+ ].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 @@
+
\ 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, /