diff --git a/collab-review-decision-ledger/README.md b/collab-review-decision-ledger/README.md
new file mode 100644
index 0000000..cca302a
--- /dev/null
+++ b/collab-review-decision-ledger/README.md
@@ -0,0 +1,44 @@
+# Collaborative Review Decision Ledger
+
+This is a focused Real-Time Collaborative Research Editor slice for SCIBASE issue #12. It evaluates whether a collaborative manuscript is ready to freeze or publish by checking unresolved review blockers across comments, suggestions, section locks, approvals, notebook freshness, decision records, and restore-safe snapshots.
+
+## Scope
+
+- Detects unresolved blocking comments.
+- Detects open suggestions that must be accepted or rejected.
+- Flags stale notebook outputs before release.
+- Checks required role approvals for each section.
+- Requires freeze-window section locks.
+- Requires recorded sidebar decision records.
+- Requires restore-ready snapshots before publish.
+- Emits deterministic section decision digests and document-level priority actions.
+
+It intentionally does not duplicate broad editor foundations, operation replay, offline conflict rebasing, notebook workbenches, reference formatting, authorship governance, lock/checkpoint recovery, freeze lanes, figure/table review lanes, discussion-sidebar audit, autosave recovery, or round-trip fidelity checks.
+
+## Run
+
+```powershell
+node collab-review-decision-ledger/test.js
+node collab-review-decision-ledger/demo.js
+```
+
+The demo writes:
+
+- `collab-review-decision-ledger/demo-output/review-decision-ledger.json`
+- `collab-review-decision-ledger/demo-output/demo.svg`
+
+This PR also includes the required short MP4 demo artifact:
+
+- `collab-review-decision-ledger/demo-output/demo.mp4`
+
+## API
+
+```js
+const {
+ evaluateReviewDecision,
+ buildReleaseSummary,
+ createDecisionDigest,
+} = require("./collab-review-decision-ledger");
+
+const audit = evaluateReviewDecision({ manuscript, generatedAt });
+```
diff --git a/collab-review-decision-ledger/acceptance-notes.md b/collab-review-decision-ledger/acceptance-notes.md
new file mode 100644
index 0000000..75d7810
--- /dev/null
+++ b/collab-review-decision-ledger/acceptance-notes.md
@@ -0,0 +1,27 @@
+# Acceptance Notes
+
+## What This Adds
+
+- Dependency-free Node.js module under `collab-review-decision-ledger/`.
+- Deterministic section-level publish/freeze decisions for collaborative research manuscripts.
+- Tests for unresolved release blockers, clean section readiness, document release summaries, and stable decision digests.
+- Demo JSON, SVG, and MP4 artifacts for bounty review.
+
+## Verification
+
+Use these commands from the repository root:
+
+```powershell
+node collab-review-decision-ledger/test.js
+node collab-review-decision-ledger/demo.js
+node --check collab-review-decision-ledger/index.js
+node --check collab-review-decision-ledger/test.js
+node --check collab-review-decision-ledger/demo.js
+node --check collab-review-decision-ledger/sample-data.js
+ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 collab-review-decision-ledger/demo-output/demo.mp4
+git diff --check
+```
+
+## AI Assistance Disclosure
+
+This contribution was prepared with AI assistance from OpenAI Codex and reviewed through local deterministic tests and artifact checks before submission.
diff --git a/collab-review-decision-ledger/demo-output/demo.mp4 b/collab-review-decision-ledger/demo-output/demo.mp4
new file mode 100644
index 0000000..1222a17
Binary files /dev/null and b/collab-review-decision-ledger/demo-output/demo.mp4 differ
diff --git a/collab-review-decision-ledger/demo-output/demo.svg b/collab-review-decision-ledger/demo-output/demo.svg
new file mode 100644
index 0000000..3cc9819
--- /dev/null
+++ b/collab-review-decision-ledger/demo-output/demo.svg
@@ -0,0 +1,57 @@
+
\ No newline at end of file
diff --git a/collab-review-decision-ledger/demo-output/review-decision-ledger.json b/collab-review-decision-ledger/demo-output/review-decision-ledger.json
new file mode 100644
index 0000000..62f4dd7
--- /dev/null
+++ b/collab-review-decision-ledger/demo-output/review-decision-ledger.json
@@ -0,0 +1,92 @@
+{
+ "generatedAt": "2026-05-20T13:00:00.000Z",
+ "documentId": "doc-quantum-12",
+ "title": "Quantum catalyst manuscript",
+ "sectionDecisions": [
+ {
+ "sectionId": "intro",
+ "sectionTitle": "Introduction",
+ "generatedAt": "2026-05-20T13:00:00.000Z",
+ "decision": "ready_to_freeze",
+ "flags": [],
+ "riskScore": 0,
+ "blockingComments": [],
+ "openSuggestions": [],
+ "staleNotebooks": [],
+ "missingApprovals": [],
+ "releaseActions": [
+ "Keep section frozen for publish review"
+ ],
+ "decisionDigest": "crdl_bf687c130eb59eeabd2c84e3"
+ },
+ {
+ "sectionId": "results",
+ "sectionTitle": "Results",
+ "generatedAt": "2026-05-20T13:00:00.000Z",
+ "decision": "hold_publish",
+ "flags": [
+ "BLOCKING_COMMENT_OPEN",
+ "OPEN_SUGGESTION",
+ "NOTEBOOK_OUTPUT_STALE",
+ "APPROVAL_MISSING",
+ "SECTION_UNLOCKED",
+ "DECISION_RECORD_MISSING",
+ "RESTORE_SNAPSHOT_UNSAFE"
+ ],
+ "riskScore": 20,
+ "blockingComments": [
+ "c-7"
+ ],
+ "openSuggestions": [
+ "sg-7"
+ ],
+ "staleNotebooks": [
+ "nb-7"
+ ],
+ "missingApprovals": [
+ "stat-reviewer"
+ ],
+ "releaseActions": [
+ "Resolve 1 blocking comment in Results",
+ "Accept or reject 1 open suggestion in Results",
+ "Refresh 1 stale notebook output before freeze",
+ "Collect approval from stat-reviewer",
+ "Move Results into freeze-window lock mode",
+ "Create restore-ready snapshot for Results"
+ ],
+ "decisionDigest": "crdl_58fa90d9db1ded935bd59ca8"
+ },
+ {
+ "sectionId": "methods",
+ "sectionTitle": "Methods",
+ "generatedAt": "2026-05-20T13:00:00.000Z",
+ "decision": "ready_to_freeze",
+ "flags": [],
+ "riskScore": 0,
+ "blockingComments": [],
+ "openSuggestions": [],
+ "staleNotebooks": [],
+ "missingApprovals": [],
+ "releaseActions": [
+ "Keep section frozen for publish review"
+ ],
+ "decisionDigest": "crdl_ed49826af04eabfc3f989582"
+ }
+ ],
+ "releaseSummary": {
+ "counts": {
+ "sections": 3,
+ "readyToFreeze": 2,
+ "holdPublish": 1,
+ "blockingComments": 1,
+ "staleNotebooks": 1,
+ "missingApprovals": 1
+ },
+ "priorityActions": [
+ "Hold publish: 1 section has unresolved release blockers.",
+ "Resolve 1 blocking comment before freeze.",
+ "Refresh 1 stale notebook output before release.",
+ "Collect 1 missing role approval before publish."
+ ]
+ }
+}
diff --git a/collab-review-decision-ledger/demo.js b/collab-review-decision-ledger/demo.js
new file mode 100644
index 0000000..ad6d842
--- /dev/null
+++ b/collab-review-decision-ledger/demo.js
@@ -0,0 +1,80 @@
+const fs = require("fs");
+const path = require("path");
+
+const { evaluateReviewDecision } = require("./index");
+const { manuscript } = require("./sample-data");
+
+const generatedAt = "2026-05-20T13:00:00.000Z";
+const outputDir = path.join(__dirname, "demo-output");
+
+fs.mkdirSync(outputDir, { recursive: true });
+
+const audit = evaluateReviewDecision({ manuscript, generatedAt });
+fs.writeFileSync(path.join(outputDir, "review-decision-ledger.json"), `${JSON.stringify(audit, null, 2)}\n`);
+fs.writeFileSync(path.join(outputDir, "demo.svg"), buildSvg(audit));
+
+console.log("Collaborative review decision ledger demo");
+console.log(`Document: ${audit.title}`);
+console.log(`Sections: ${audit.releaseSummary.counts.sections}`);
+console.log(`Ready to freeze: ${audit.releaseSummary.counts.readyToFreeze}`);
+console.log(`Hold publish: ${audit.releaseSummary.counts.holdPublish}`);
+console.log(`Blocking comments: ${audit.releaseSummary.counts.blockingComments}`);
+console.log(`Wrote ${path.join(outputDir, "review-decision-ledger.json")}`);
+console.log(`Wrote ${path.join(outputDir, "demo.svg")}`);
+
+function buildSvg(audit) {
+ const rows = audit.sectionDecisions.map((section, index) => {
+ const y = 196 + index * 82;
+ const color = section.decision === "ready_to_freeze" ? "#1f8a5b" : "#b42318";
+ const flags = section.flags.length === 0 ? "No blockers" : section.flags.join(" | ");
+ return `
+
+
+ ${escapeXml(section.sectionTitle)}
+ ${escapeXml(formatDecision(section.decision))} - risk ${section.riskScore}
+ ${escapeXml(flags)}
+ ${escapeXml(section.decisionDigest)}
+ `;
+ }).join("");
+
+ return ``;
+}
+
+function metricCard(x, y, label, value, color) {
+ return `
+
+ ${value}
+ ${escapeXml(label)}
+ `;
+}
+
+function formatDecision(decision) {
+ return decision.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
diff --git a/collab-review-decision-ledger/index.js b/collab-review-decision-ledger/index.js
new file mode 100644
index 0000000..0965cb3
--- /dev/null
+++ b/collab-review-decision-ledger/index.js
@@ -0,0 +1,192 @@
+const crypto = require("crypto");
+
+const FLAG_WEIGHTS = {
+ BLOCKING_COMMENT_OPEN: 4,
+ OPEN_SUGGESTION: 2,
+ NOTEBOOK_OUTPUT_STALE: 4,
+ APPROVAL_MISSING: 3,
+ SECTION_UNLOCKED: 2,
+ DECISION_RECORD_MISSING: 2,
+ RESTORE_SNAPSHOT_UNSAFE: 3,
+};
+
+function evaluateReviewDecision({ manuscript, generatedAt = new Date().toISOString() }) {
+ if (!manuscript || !Array.isArray(manuscript.sections)) {
+ throw new Error("manuscript.sections is required");
+ }
+
+ const sectionDecisions = manuscript.sections.map((section) =>
+ evaluateSection(section, manuscript.requiredApprovers || [], generatedAt)
+ );
+
+ const audit = {
+ generatedAt,
+ documentId: manuscript.documentId,
+ title: manuscript.title,
+ sectionDecisions,
+ };
+ audit.releaseSummary = buildReleaseSummary(audit);
+ return audit;
+}
+
+function evaluateSection(section, requiredApprovers, generatedAt) {
+ const flags = [];
+ const blockingComments = (section.comments || []).filter(
+ (comment) => comment.severity === "blocking" && comment.resolved !== true
+ );
+ if (blockingComments.length > 0) {
+ flags.push("BLOCKING_COMMENT_OPEN");
+ }
+
+ const openSuggestions = (section.suggestions || []).filter(
+ (suggestion) => !["accepted", "rejected"].includes(suggestion.status)
+ );
+ if (openSuggestions.length > 0) {
+ flags.push("OPEN_SUGGESTION");
+ }
+
+ const staleNotebooks = (section.notebookOutputs || []).filter(
+ (output) => output.status === "stale" || output.sourceHash !== output.currentSourceHash
+ );
+ if (staleNotebooks.length > 0) {
+ flags.push("NOTEBOOK_OUTPUT_STALE");
+ }
+
+ const approvedRoles = new Set((section.approvals || [])
+ .filter((approval) => approval.status === "approved")
+ .map((approval) => approval.role));
+ const missingApprovals = requiredApprovers.filter((role) => !approvedRoles.has(role));
+ if (missingApprovals.length > 0) {
+ flags.push("APPROVAL_MISSING");
+ }
+
+ if (!section.lock || section.lock.mode !== "freeze-window") {
+ flags.push("SECTION_UNLOCKED");
+ }
+
+ const recordedDecisions = (section.decisionRecords || []).filter(
+ (decision) => decision.status === "recorded" && String(decision.text || "").trim()
+ );
+ if (recordedDecisions.length === 0) {
+ flags.push("DECISION_RECORD_MISSING");
+ }
+
+ if (!section.snapshot || section.snapshot.restoreReady !== true) {
+ flags.push("RESTORE_SNAPSHOT_UNSAFE");
+ }
+
+ const riskScore = flags.reduce((total, flag) => total + FLAG_WEIGHTS[flag], 0);
+ const decision = riskScore === 0 ? "ready_to_freeze" : "hold_publish";
+ const sectionDecision = {
+ sectionId: section.id,
+ sectionTitle: section.title,
+ generatedAt,
+ decision,
+ flags,
+ riskScore,
+ blockingComments: blockingComments.map((comment) => comment.id),
+ openSuggestions: openSuggestions.map((suggestion) => suggestion.id),
+ staleNotebooks: staleNotebooks.map((output) => output.id),
+ missingApprovals,
+ releaseActions: buildReleaseActions({
+ decision,
+ blockingComments,
+ openSuggestions,
+ staleNotebooks,
+ missingApprovals,
+ section,
+ }),
+ };
+ sectionDecision.decisionDigest = createDecisionDigest(sectionDecision);
+ return sectionDecision;
+}
+
+function buildReleaseActions({
+ decision,
+ blockingComments,
+ openSuggestions,
+ staleNotebooks,
+ missingApprovals,
+ section,
+}) {
+ if (decision === "ready_to_freeze") {
+ return ["Keep section frozen for publish review"];
+ }
+
+ const actions = [];
+ if (blockingComments.length > 0) {
+ actions.push(`Resolve ${formatCount(blockingComments.length, "blocking comment")} in ${section.title}`);
+ }
+ if (openSuggestions.length > 0) {
+ actions.push(`Accept or reject ${formatCount(openSuggestions.length, "open suggestion")} in ${section.title}`);
+ }
+ if (staleNotebooks.length > 0) {
+ actions.push(`Refresh ${formatCount(staleNotebooks.length, "stale notebook output")} before freeze`);
+ }
+ if (missingApprovals.length > 0) {
+ actions.push(`Collect approval from ${missingApprovals.join(", ")}`);
+ }
+ if (!section.lock || section.lock.mode !== "freeze-window") {
+ actions.push(`Move ${section.title} into freeze-window lock mode`);
+ }
+ if (!section.snapshot || section.snapshot.restoreReady !== true) {
+ actions.push(`Create restore-ready snapshot for ${section.title}`);
+ }
+ return actions;
+}
+
+function buildReleaseSummary(audit) {
+ const sections = audit.sectionDecisions || [];
+ const counts = {
+ sections: sections.length,
+ readyToFreeze: sections.filter((section) => section.decision === "ready_to_freeze").length,
+ holdPublish: sections.filter((section) => section.decision === "hold_publish").length,
+ blockingComments: sum(sections, "blockingComments"),
+ staleNotebooks: sum(sections, "staleNotebooks"),
+ missingApprovals: sum(sections, "missingApprovals"),
+ };
+
+ const priorityActions = [];
+ if (counts.holdPublish > 0) {
+ priorityActions.push(`Hold publish: ${formatCount(counts.holdPublish, "section")} has unresolved release blockers.`);
+ }
+ if (counts.blockingComments > 0) {
+ priorityActions.push(`Resolve ${formatCount(counts.blockingComments, "blocking comment")} before freeze.`);
+ }
+ if (counts.staleNotebooks > 0) {
+ priorityActions.push(`Refresh ${formatCount(counts.staleNotebooks, "stale notebook output")} before release.`);
+ }
+ if (counts.missingApprovals > 0) {
+ priorityActions.push(`Collect ${formatCount(counts.missingApprovals, "missing role approval")} before publish.`);
+ }
+
+ return { counts, priorityActions };
+}
+
+function createDecisionDigest(sectionDecision) {
+ const stableFacts = {
+ sectionId: sectionDecision.sectionId,
+ decision: sectionDecision.decision,
+ flags: [...(sectionDecision.flags || [])].sort(),
+ blockingComments: [...(sectionDecision.blockingComments || [])].sort(),
+ openSuggestions: [...(sectionDecision.openSuggestions || [])].sort(),
+ staleNotebooks: [...(sectionDecision.staleNotebooks || [])].sort(),
+ missingApprovals: [...(sectionDecision.missingApprovals || [])].sort(),
+ riskScore: sectionDecision.riskScore,
+ };
+ return `crdl_${crypto.createHash("sha256").update(JSON.stringify(stableFacts)).digest("hex").slice(0, 24)}`;
+}
+
+function sum(items, key) {
+ return items.reduce((total, item) => total + ((item[key] || []).length), 0);
+}
+
+function formatCount(count, label) {
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
+}
+
+module.exports = {
+ evaluateReviewDecision,
+ buildReleaseSummary,
+ createDecisionDigest,
+};
diff --git a/collab-review-decision-ledger/requirements-map.md b/collab-review-decision-ledger/requirements-map.md
new file mode 100644
index 0000000..fed39cf
--- /dev/null
+++ b/collab-review-decision-ledger/requirements-map.md
@@ -0,0 +1,16 @@
+# Requirements Map
+
+| Issue #12 requirement | Implementation coverage |
+| --- | --- |
+| Inline comments, suggestions, and change tracking | Blocks publish when comments or suggestions remain unresolved. |
+| Locking/unlock modes for controlled sections | Requires `freeze-window` locks before publish review. |
+| Document chat or discussion sidebar per section | Requires recorded decision records for each section. |
+| Version history and autosave | Requires restore-ready snapshots before release. |
+| Jupyter notebook integration | Checks notebook output freshness and source hash drift. |
+| Multi-user collaborative workflow | Requires role approvals from author, statistician, and data-owner roles. |
+| Review and publication workflow | Emits per-section release decisions and document-level priority actions. |
+| Auditability | Emits deterministic `crdl_` decision digests from section facts. |
+
+## Non-Overlap Statement
+
+This slice focuses on final collaborative review decisions before freeze/publish. It does not duplicate broad editor models, operation replay, offline conflict rebasing, notebook collaboration, reference formatting, authorship governance, lock recovery, freeze lanes, figure/table review, discussion sidebar audit, autosave recovery, or round-trip fidelity checks.
diff --git a/collab-review-decision-ledger/sample-data.js b/collab-review-decision-ledger/sample-data.js
new file mode 100644
index 0000000..b6cef01
--- /dev/null
+++ b/collab-review-decision-ledger/sample-data.js
@@ -0,0 +1,70 @@
+const manuscript = {
+ documentId: "doc-quantum-12",
+ title: "Quantum catalyst manuscript",
+ requiredApprovers: ["lead-author", "stat-reviewer", "data-owner"],
+ sections: [
+ {
+ id: "intro",
+ title: "Introduction",
+ lock: { mode: "freeze-window", lockedAt: "2026-05-20T10:00:00.000Z" },
+ comments: [],
+ suggestions: [{ id: "sg-1", status: "accepted" }],
+ notebookOutputs: [],
+ approvals: [
+ { role: "lead-author", status: "approved" },
+ { role: "stat-reviewer", status: "approved" },
+ { role: "data-owner", status: "approved" },
+ ],
+ decisionRecords: [{ id: "dec-1", status: "recorded", text: "Intro accepted." }],
+ snapshot: { id: "snap-intro", restoreReady: true },
+ },
+ {
+ id: "results",
+ title: "Results",
+ lock: { mode: "editable", lockedAt: "2026-05-17T08:00:00.000Z" },
+ comments: [{ id: "c-7", severity: "blocking", resolved: false }],
+ suggestions: [{ id: "sg-7", status: "open" }],
+ notebookOutputs: [
+ {
+ id: "nb-7",
+ status: "stale",
+ lastExecutedAt: "2026-05-16T05:00:00.000Z",
+ sourceHash: "sha256:old",
+ currentSourceHash: "sha256:new",
+ },
+ ],
+ approvals: [
+ { role: "lead-author", status: "approved" },
+ { role: "stat-reviewer", status: "pending" },
+ { role: "data-owner", status: "approved" },
+ ],
+ decisionRecords: [{ id: "dec-7", status: "missing", text: "" }],
+ snapshot: { id: "snap-results", restoreReady: false },
+ },
+ {
+ id: "methods",
+ title: "Methods",
+ lock: { mode: "freeze-window", lockedAt: "2026-05-20T11:00:00.000Z" },
+ comments: [],
+ suggestions: [{ id: "sg-3", status: "rejected" }],
+ notebookOutputs: [
+ {
+ id: "nb-3",
+ status: "fresh",
+ lastExecutedAt: "2026-05-20T09:00:00.000Z",
+ sourceHash: "sha256:methods",
+ currentSourceHash: "sha256:methods",
+ },
+ ],
+ approvals: [
+ { role: "lead-author", status: "approved" },
+ { role: "stat-reviewer", status: "approved" },
+ { role: "data-owner", status: "approved" },
+ ],
+ decisionRecords: [{ id: "dec-3", status: "recorded", text: "Methods frozen." }],
+ snapshot: { id: "snap-methods", restoreReady: true },
+ },
+ ],
+};
+
+module.exports = { manuscript };
diff --git a/collab-review-decision-ledger/test.js b/collab-review-decision-ledger/test.js
new file mode 100644
index 0000000..c37f7b5
--- /dev/null
+++ b/collab-review-decision-ledger/test.js
@@ -0,0 +1,125 @@
+const assert = require("assert");
+
+const {
+ evaluateReviewDecision,
+ buildReleaseSummary,
+ createDecisionDigest,
+} = require("./index");
+
+const generatedAt = "2026-05-20T13:00:00.000Z";
+
+const manuscript = {
+ documentId: "doc-quantum-12",
+ title: "Quantum catalyst manuscript",
+ requiredApprovers: ["lead-author", "stat-reviewer", "data-owner"],
+ sections: [
+ {
+ id: "intro",
+ title: "Introduction",
+ lock: { mode: "freeze-window", lockedAt: "2026-05-20T10:00:00.000Z" },
+ comments: [],
+ suggestions: [{ id: "sg-1", status: "accepted" }],
+ notebookOutputs: [],
+ approvals: [
+ { role: "lead-author", status: "approved" },
+ { role: "stat-reviewer", status: "approved" },
+ { role: "data-owner", status: "approved" },
+ ],
+ decisionRecords: [{ id: "dec-1", status: "recorded", text: "Intro accepted." }],
+ snapshot: { id: "snap-intro", restoreReady: true },
+ },
+ {
+ id: "results",
+ title: "Results",
+ lock: { mode: "editable", lockedAt: "2026-05-17T08:00:00.000Z" },
+ comments: [{ id: "c-7", severity: "blocking", resolved: false }],
+ suggestions: [{ id: "sg-7", status: "open" }],
+ notebookOutputs: [
+ {
+ id: "nb-7",
+ status: "stale",
+ lastExecutedAt: "2026-05-16T05:00:00.000Z",
+ sourceHash: "sha256:old",
+ currentSourceHash: "sha256:new",
+ },
+ ],
+ approvals: [
+ { role: "lead-author", status: "approved" },
+ { role: "stat-reviewer", status: "pending" },
+ { role: "data-owner", status: "approved" },
+ ],
+ decisionRecords: [{ id: "dec-7", status: "missing", text: "" }],
+ snapshot: { id: "snap-results", restoreReady: false },
+ },
+ ],
+};
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(`ok - ${name}`);
+ } catch (error) {
+ console.error(`not ok - ${name}`);
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
+
+test("holds publish when collaborative review gates are unresolved", () => {
+ const audit = evaluateReviewDecision({ manuscript, generatedAt });
+ const results = audit.sectionDecisions.find((section) => section.sectionId === "results");
+
+ assert(results, "expected results section decision");
+ assert.strictEqual(results.decision, "hold_publish");
+ assert(results.flags.includes("BLOCKING_COMMENT_OPEN"));
+ assert(results.flags.includes("OPEN_SUGGESTION"));
+ assert(results.flags.includes("NOTEBOOK_OUTPUT_STALE"));
+ assert(results.flags.includes("APPROVAL_MISSING"));
+ assert(results.flags.includes("SECTION_UNLOCKED"));
+ assert(results.flags.includes("DECISION_RECORD_MISSING"));
+ assert(results.flags.includes("RESTORE_SNAPSHOT_UNSAFE"));
+ assert(results.releaseActions.some((action) => action.includes("Resolve 1 blocking comment")));
+ assert(results.releaseActions.some((action) => action.includes("stat-reviewer")));
+});
+
+test("marks clean sections ready to freeze", () => {
+ const audit = evaluateReviewDecision({ manuscript, generatedAt });
+ const intro = audit.sectionDecisions.find((section) => section.sectionId === "intro");
+
+ assert(intro, "expected intro section decision");
+ assert.strictEqual(intro.decision, "ready_to_freeze");
+ assert.deepStrictEqual(intro.flags, []);
+ assert.strictEqual(intro.riskScore, 0);
+ assert(intro.releaseActions.includes("Keep section frozen for publish review"));
+});
+
+test("builds document release summary", () => {
+ const audit = evaluateReviewDecision({ manuscript, generatedAt });
+ const summary = buildReleaseSummary(audit);
+
+ assert.deepStrictEqual(summary.counts, {
+ sections: 2,
+ readyToFreeze: 1,
+ holdPublish: 1,
+ blockingComments: 1,
+ staleNotebooks: 1,
+ missingApprovals: 1,
+ });
+ assert.deepStrictEqual(summary.priorityActions, [
+ "Hold publish: 1 section has unresolved release blockers.",
+ "Resolve 1 blocking comment before freeze.",
+ "Refresh 1 stale notebook output before release.",
+ "Collect 1 missing role approval before publish.",
+ ]);
+});
+
+test("creates stable decision digests from section facts", () => {
+ const audit = evaluateReviewDecision({ manuscript, generatedAt });
+ const section = audit.sectionDecisions.find((item) => item.sectionId === "results");
+
+ const first = createDecisionDigest(section);
+ const second = createDecisionDigest({ ...section, releaseActions: [...section.releaseActions] });
+
+ assert.strictEqual(first, second);
+ assert.match(first, /^crdl_[a-f0-9]{24}$/);
+});