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 @@ + + + + Collaborative Review Decision Ledger + Quantum catalyst manuscript - 2026-05-20T13:00:00.000Z + + + 3 + Sections + + + + 2 + Ready + + + + 1 + Hold + + + + 1 + Comments + + + + + Introduction + Ready To Freeze - risk 0 + No blockers + crdl_bf687c130eb59eeabd2c84e3 + + + + Results + Hold Publish - risk 20 + BLOCKING_COMMENT_OPEN | OPEN_SUGGESTION | NOTEBOOK_OUTPUT_STALE | APPROVAL_MISSING | SECTION_UNLOCKED | DECISION_RECORD_MISSING | RESTORE_SNAPSHOT_UNSAFE + crdl_58fa90d9db1ded935bd59ca8 + + + + Methods + Ready To Freeze - risk 0 + No blockers + crdl_ed49826af04eabfc3f989582 + + 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. + \ 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 ` + + + Collaborative Review Decision Ledger + ${escapeXml(audit.title)} - ${escapeXml(audit.generatedAt)} + ${metricCard(64, 112, "Sections", audit.releaseSummary.counts.sections, "#0b5fff")} + ${metricCard(252, 112, "Ready", audit.releaseSummary.counts.readyToFreeze, "#1f8a5b")} + ${metricCard(440, 112, "Hold", audit.releaseSummary.counts.holdPublish, "#b42318")} + ${metricCard(628, 112, "Comments", audit.releaseSummary.counts.blockingComments, "#ad6f00")} + ${rows} + ${escapeXml(audit.releaseSummary.priorityActions.join(" "))} +`; +} + +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}$/); +});