diff --git a/scientific-bounty-award-publication-guard/README.md b/scientific-bounty-award-publication-guard/README.md
new file mode 100644
index 00000000..1037cb80
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/README.md
@@ -0,0 +1,53 @@
+# Scientific Bounty Award Publication Guard
+
+This module adds a focused award-publication readiness guard for the Scientific Bounty System bounty.
+
+It answers one operational question:
+
+> Can the platform publicly announce a winner, publish the award page, and expose result metadata without violating payout, appeal, IP, NDA, embargo, or team-consent rules?
+
+## Scope
+
+This is intentionally separate from intake, scoring, escrow funding, export-control, duplicate-submission, IP-policy, appeal-ledger, and payout-routing modules. It runs after an award decision exists and before the result is made public.
+
+The guard checks:
+
+- award decision and sponsor approval are present
+- payout is authorized or released before public winner publication
+- appeal window has closed or has an approved waiver
+- IP handoff or open-license terms are satisfied before result publication
+- embargo dates have passed
+- private/NDA challenge content is redacted before public release
+- named team announcements have solver consent
+- award splits sum to 100 percent and recipients accepted publication terms
+- reproducibility package hashes are present for public claims
+
+## Decisions
+
+- `RELEASE_PUBLICATION`: ready to publish the award and winner metadata.
+- `HOLD_FOR_REVIEW`: not ready, but remediable without changing the award.
+- `BLOCK_PUBLICATION`: publication would violate a hard trust rule.
+
+## Run
+
+```bash
+npm test
+npm run demo
+```
+
+Demo outputs are written to `artifacts/`:
+
+- `award-publication-results.json`
+- `award-publication-report.md`
+- `award-publication-summary.svg`
+- `award-publication-demo.mp4`
+
+## Boundaries
+
+- synthetic records only
+- no payment processor calls
+- no private submissions
+- no credentials
+- no external APIs
+- no legal advice
+
diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4 b/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4
new file mode 100644
index 00000000..a710e06d
Binary files /dev/null and b/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4 differ
diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md b/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md
new file mode 100644
index 00000000..5e6386f6
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md
@@ -0,0 +1,70 @@
+# Award Publication Readiness Report
+
+Generated: 2026-06-12T00:00:00Z
+
+## Summary
+
+- Blocking findings: 5
+- Review findings: 3
+- Passing findings: 20
+
+## Challenge Decisions
+
+### biomarker-single-cell-2026
+
+- Decision: RELEASE_PUBLICATION
+- Award: award-ready-001
+- Payout state: released
+- Public mode: public_challenge_public_award
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. |
+| pass | payout_ready | Payout state allows public award publication. | No remediation required. |
+| pass | appeal_window_closed | Appeal window is closed. | No remediation required. |
+| pass | ip_terms_ready | IP or license terms allow publication. | No remediation required. |
+| pass | embargo_clear | No active embargo blocks publication. | No remediation required. |
+| pass | privacy_release_ready | Publication privacy mode is satisfied. | No remediation required. |
+| pass | named_consent_ready | All named recipients consented to public announcement. | No remediation required. |
+| pass | award_splits_ready | Award splits and recipient publication terms are complete. | No remediation required. |
+| pass | reproducibility_manifest_ready | Reproducibility manifest has public claim evidence. | No remediation required. |
+
+### climate-forecast-region-pack
+
+- Decision: HOLD_FOR_REVIEW
+- Award: award-hold-002
+- Payout state: authorized
+- Public mode: public_challenge_public_award
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. |
+| pass | payout_ready | Payout state allows public award publication. | No remediation required. |
+| pass | appeal_window_closed | Appeal window is closed. | No remediation required. |
+| pass | ip_terms_ready | IP or license terms allow publication. | No remediation required. |
+| review | embargo_active | Publication embargo remains active until 2026-06-20T00:00:00.000Z. | Hold public publication until the embargo expires. |
+| pass | privacy_release_ready | Publication privacy mode is satisfied. | No remediation required. |
+| pass | named_consent_ready | All named recipients consented to public announcement. | No remediation required. |
+| pass | award_splits_ready | Award splits and recipient publication terms are complete. | No remediation required. |
+| pass | reproducibility_manifest_ready | Reproducibility manifest has public claim evidence. | No remediation required. |
+
+### private-quantum-noise-reduction
+
+- Decision: BLOCK_PUBLICATION
+- Award: award-block-003
+- Payout state: pending
+- Public mode: private_challenge_public_award
+
+| Severity | Code | Message | Remediation |
+| --- | --- | --- | --- |
+| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. |
+| block | payout_not_authorized | Public winner publication requires an authorized, released, or paid payout state. | Move payout to authorized, released, or paid before publishing named winners. |
+| pass | appeal_waived | Appeal window was explicitly waived. | No remediation required. |
+| block | ip_transfer_incomplete | IP transfer publication requires signed handoff and released or paid payout. | Complete signed IP handoff and release or pay the payout before publication. |
+| pass | embargo_clear | No active embargo blocks publication. | No remediation required. |
+| block | private_content_unredacted | Private or NDA challenge content needs approved public redaction before award publication. | Approve a public redaction packet for private challenge content. |
+| review | named_consent_missing | Named publication consent missing for solver-epsilon. | Collect named publication consent or publish anonymized winners. |
+| block | award_splits_invalid | Award split percentages must sum to 100; received 90. | Correct award splits so recipient percentages total exactly 100. |
+| block | recipient_terms_missing | Publication terms missing for solver-epsilon. | Collect publication terms from each payout recipient. |
+| review | reproducibility_manifest_incomplete | Public claims should include artifact hash and result digest. | Attach artifact hash and result digest before publishing technical claims. |
+
diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json b/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json
new file mode 100644
index 00000000..23497a6d
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json
@@ -0,0 +1,258 @@
+[
+ {
+ "challengeId": "biomarker-single-cell-2026",
+ "awardId": "award-ready-001",
+ "decision": "RELEASE_PUBLICATION",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 0,
+ "reviews": 0,
+ "passes": 9
+ },
+ "findings": [
+ {
+ "code": "award_decision_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Award decision and sponsor approval are present.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "payout_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Payout state allows public award publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "appeal_window_closed",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Appeal window is closed.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "ip_terms_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "IP or license terms allow publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "embargo_clear",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "No active embargo blocks publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "privacy_release_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Publication privacy mode is satisfied.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "named_consent_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "All named recipients consented to public announcement.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "award_splits_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Award splits and recipient publication terms are complete.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "reproducibility_manifest_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Reproducibility manifest has public claim evidence.",
+ "remediation": "No remediation required."
+ }
+ ],
+ "audit": {
+ "publicMode": "public_challenge_public_award",
+ "payoutState": "released",
+ "ipPolicy": "transfer_on_payout",
+ "embargoUntil": "2026-06-05T00:00:00Z",
+ "recipientCount": 2
+ }
+ },
+ {
+ "challengeId": "climate-forecast-region-pack",
+ "awardId": "award-hold-002",
+ "decision": "HOLD_FOR_REVIEW",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 0,
+ "reviews": 1,
+ "passes": 8
+ },
+ "findings": [
+ {
+ "code": "award_decision_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Award decision and sponsor approval are present.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "payout_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Payout state allows public award publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "appeal_window_closed",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Appeal window is closed.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "ip_terms_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "IP or license terms allow publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "embargo_active",
+ "severity": "review",
+ "hardBlock": false,
+ "message": "Publication embargo remains active until 2026-06-20T00:00:00.000Z.",
+ "remediation": "Hold public publication until the embargo expires."
+ },
+ {
+ "code": "privacy_release_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Publication privacy mode is satisfied.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "named_consent_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "All named recipients consented to public announcement.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "award_splits_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Award splits and recipient publication terms are complete.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "reproducibility_manifest_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Reproducibility manifest has public claim evidence.",
+ "remediation": "No remediation required."
+ }
+ ],
+ "audit": {
+ "publicMode": "public_challenge_public_award",
+ "payoutState": "authorized",
+ "ipPolicy": "open_license",
+ "embargoUntil": "2026-06-20T00:00:00Z",
+ "recipientCount": 1
+ }
+ },
+ {
+ "challengeId": "private-quantum-noise-reduction",
+ "awardId": "award-block-003",
+ "decision": "BLOCK_PUBLICATION",
+ "checkedAt": "2026-06-12T00:00:00.000Z",
+ "summary": {
+ "blocks": 5,
+ "reviews": 2,
+ "passes": 3
+ },
+ "findings": [
+ {
+ "code": "award_decision_ready",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Award decision and sponsor approval are present.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "payout_not_authorized",
+ "severity": "block",
+ "hardBlock": true,
+ "message": "Public winner publication requires an authorized, released, or paid payout state.",
+ "remediation": "Move payout to authorized, released, or paid before publishing named winners."
+ },
+ {
+ "code": "appeal_waived",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "Appeal window was explicitly waived.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "ip_transfer_incomplete",
+ "severity": "block",
+ "hardBlock": true,
+ "message": "IP transfer publication requires signed handoff and released or paid payout.",
+ "remediation": "Complete signed IP handoff and release or pay the payout before publication."
+ },
+ {
+ "code": "embargo_clear",
+ "severity": "pass",
+ "hardBlock": false,
+ "message": "No active embargo blocks publication.",
+ "remediation": "No remediation required."
+ },
+ {
+ "code": "private_content_unredacted",
+ "severity": "block",
+ "hardBlock": true,
+ "message": "Private or NDA challenge content needs approved public redaction before award publication.",
+ "remediation": "Approve a public redaction packet for private challenge content."
+ },
+ {
+ "code": "named_consent_missing",
+ "severity": "review",
+ "hardBlock": false,
+ "message": "Named publication consent missing for solver-epsilon.",
+ "remediation": "Collect named publication consent or publish anonymized winners."
+ },
+ {
+ "code": "award_splits_invalid",
+ "severity": "block",
+ "hardBlock": true,
+ "message": "Award split percentages must sum to 100; received 90.",
+ "remediation": "Correct award splits so recipient percentages total exactly 100."
+ },
+ {
+ "code": "recipient_terms_missing",
+ "severity": "block",
+ "hardBlock": true,
+ "message": "Publication terms missing for solver-epsilon.",
+ "remediation": "Collect publication terms from each payout recipient."
+ },
+ {
+ "code": "reproducibility_manifest_incomplete",
+ "severity": "review",
+ "hardBlock": false,
+ "message": "Public claims should include artifact hash and result digest.",
+ "remediation": "Attach artifact hash and result digest before publishing technical claims."
+ }
+ ],
+ "audit": {
+ "publicMode": "private_challenge_public_award",
+ "payoutState": "pending",
+ "ipPolicy": "transfer_on_payout",
+ "embargoUntil": null,
+ "recipientCount": 2
+ }
+ }
+]
diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg b/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg
new file mode 100644
index 00000000..d5d1cc6a
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/scientific-bounty-award-publication-guard/examples/award-publication-packets.json b/scientific-bounty-award-publication-guard/examples/award-publication-packets.json
new file mode 100644
index 00000000..94befa9a
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/examples/award-publication-packets.json
@@ -0,0 +1,119 @@
+[
+ {
+ "challengeId": "biomarker-single-cell-2026",
+ "award": {
+ "id": "award-ready-001",
+ "state": "awarded",
+ "sponsorApproved": true,
+ "splits": [
+ {
+ "recipientId": "lab-alpha",
+ "percent": 60,
+ "namedConsent": true,
+ "acceptedPublicationTerms": true
+ },
+ {
+ "recipientId": "researcher-beta",
+ "percent": 40,
+ "namedConsent": true,
+ "acceptedPublicationTerms": true
+ }
+ ]
+ },
+ "payout": {
+ "state": "released"
+ },
+ "appeal": {
+ "closesAt": "2026-06-01T00:00:00Z"
+ },
+ "ip": {
+ "policy": "transfer_on_payout",
+ "transferSigned": true
+ },
+ "publication": {
+ "mode": "public_challenge_public_award",
+ "namedWinners": true,
+ "embargoUntil": "2026-06-05T00:00:00Z"
+ },
+ "reproducibilityManifest": {
+ "artifactHash": "sha256:11c6db0f84b4f7f8f7fa6b8da6c89bda61b24f71d111bd3d31ec2d557ec9b9a0",
+ "resultDigest": "ranked model package reproduced with held-out validation report"
+ }
+ },
+ {
+ "challengeId": "climate-forecast-region-pack",
+ "award": {
+ "id": "award-hold-002",
+ "state": "awarded",
+ "sponsorApproved": true,
+ "splits": [
+ {
+ "recipientId": "team-gamma",
+ "percent": 100,
+ "namedConsent": true,
+ "acceptedPublicationTerms": true
+ }
+ ]
+ },
+ "payout": {
+ "state": "authorized"
+ },
+ "appeal": {
+ "closesAt": "2026-06-10T00:00:00Z"
+ },
+ "ip": {
+ "policy": "open_license",
+ "licenseId": "CC-BY-4.0"
+ },
+ "publication": {
+ "mode": "public_challenge_public_award",
+ "namedWinners": true,
+ "embargoUntil": "2026-06-20T00:00:00Z"
+ },
+ "reproducibilityManifest": {
+ "artifactHash": "sha256:6b1b9d9487c8be834df51d1bc67b0df4c475a4329a077ad10b7cf8a198cb9212",
+ "resultDigest": "forecast benchmark notebook reproduced with station holdout"
+ }
+ },
+ {
+ "challengeId": "private-quantum-noise-reduction",
+ "award": {
+ "id": "award-block-003",
+ "state": "awarded",
+ "sponsorApproved": true,
+ "splits": [
+ {
+ "recipientId": "solver-delta",
+ "percent": 70,
+ "namedConsent": true,
+ "acceptedPublicationTerms": true
+ },
+ {
+ "recipientId": "solver-epsilon",
+ "percent": 20,
+ "namedConsent": false,
+ "acceptedPublicationTerms": false
+ }
+ ]
+ },
+ "payout": {
+ "state": "pending"
+ },
+ "appeal": {
+ "waived": true
+ },
+ "ip": {
+ "policy": "transfer_on_payout",
+ "transferSigned": false
+ },
+ "publication": {
+ "mode": "private_challenge_public_award",
+ "namedWinners": true,
+ "redactionApproved": false
+ },
+ "reproducibilityManifest": {
+ "artifactHash": "",
+ "resultDigest": ""
+ }
+ }
+]
diff --git a/scientific-bounty-award-publication-guard/package.json b/scientific-bounty-award-publication-guard/package.json
new file mode 100644
index 00000000..3527c092
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "scientific-bounty-award-publication-guard",
+ "version": "1.0.0",
+ "description": "Deterministic publication readiness guard for scientific bounty award announcements.",
+ "type": "module",
+ "scripts": {
+ "test": "node test/awardPublicationGuard.test.js",
+ "demo": "node scripts/demo.js"
+ },
+ "license": "MIT"
+}
diff --git a/scientific-bounty-award-publication-guard/scripts/demo.js b/scientific-bounty-award-publication-guard/scripts/demo.js
new file mode 100644
index 00000000..3620af3b
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/scripts/demo.js
@@ -0,0 +1,147 @@
+import { mkdir, writeFile } from "node:fs/promises"
+import { spawnSync } from "node:child_process"
+import { join } from "node:path"
+import { tmpdir } from "node:os"
+import packets from "../examples/award-publication-packets.json" with { type: "json" }
+import { evaluateAwardPublicationBatch, summarize } from "../src/index.js"
+
+const ARTIFACT_DIR = new URL("../artifacts/", import.meta.url)
+const now = "2026-06-12T00:00:00Z"
+const results = evaluateAwardPublicationBatch(packets, { now })
+const summary = summarize(results)
+
+await mkdir(ARTIFACT_DIR, { recursive: true })
+await writeFile(new URL("award-publication-results.json", ARTIFACT_DIR), `${JSON.stringify(results, null, 2)}\n`)
+await writeFile(new URL("award-publication-report.md", ARTIFACT_DIR), renderReport(results, summary))
+await writeFile(new URL("award-publication-summary.svg", ARTIFACT_DIR), renderSvg(results, summary))
+await renderMp4()
+
+console.log("Award publication guard demo generated")
+console.log(`- decisions: ${results.map((result) => `${result.challengeId}:${result.decision}`).join(", ")}`)
+console.log(`- summary: ${JSON.stringify(summary)}`)
+
+function renderReport(results, summary) {
+ const lines = [
+ "# Award Publication Readiness Report",
+ "",
+ `Generated: ${now}`,
+ "",
+ "## Summary",
+ "",
+ `- Blocking findings: ${summary.blocks}`,
+ `- Review findings: ${summary.reviews}`,
+ `- Passing findings: ${summary.passes}`,
+ "",
+ "## Challenge Decisions",
+ ""
+ ]
+
+ for (const result of results) {
+ lines.push(`### ${result.challengeId}`)
+ lines.push("")
+ lines.push(`- Decision: ${result.decision}`)
+ lines.push(`- Award: ${result.awardId}`)
+ lines.push(`- Payout state: ${result.audit.payoutState}`)
+ lines.push(`- Public mode: ${result.audit.publicMode}`)
+ lines.push("")
+ lines.push("| Severity | Code | Message | Remediation |")
+ lines.push("| --- | --- | --- | --- |")
+ for (const finding of result.findings) {
+ lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`)
+ }
+ lines.push("")
+ }
+
+ return `${lines.join("\n")}\n`
+}
+
+function renderSvg(results, summary) {
+ const rows = results.map((result, index) => {
+ const y = 128 + index * 64
+ const color = result.decision === "RELEASE_PUBLICATION" ? "#15803d" : result.decision === "HOLD_FOR_REVIEW" ? "#b45309" : "#b91c1c"
+ return `
+ ${escapeXml(result.challengeId)}
+
+ ${result.decision}`
+ }).join("\n")
+
+ return `
+`
+}
+
+async function renderMp4() {
+ const ppmPath = join(tmpdir(), `award-publication-demo-frame-${Date.now()}.ppm`)
+ const mp4Path = new URL("award-publication-demo.mp4", ARTIFACT_DIR)
+ await writeFile(ppmPath, renderPpmFrame())
+
+ const ffmpeg = spawnSync("ffmpeg", [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-loop",
+ "1",
+ "-framerate",
+ "1",
+ "-i",
+ ppmPath,
+ "-t",
+ "5",
+ "-pix_fmt",
+ "yuv420p",
+ mp4Path.pathname
+ ], { encoding: "utf8" })
+
+ if (ffmpeg.status !== 0) {
+ await writeFile(new URL("award-publication-demo-fallback.txt", ARTIFACT_DIR), ffmpeg.stderr || "ffmpeg failed")
+ return
+ }
+}
+
+function renderPpmFrame() {
+ const width = 900
+ const height = 360
+ const pixels = Buffer.alloc(width * height * 3, 248)
+
+ fillRect(pixels, width, 0, 0, width, height, [248, 250, 252])
+ fillRect(pixels, width, 40, 50, 820, 34, [15, 23, 42])
+ fillRect(pixels, width, 40, 112, 250, 72, [21, 128, 61])
+ fillRect(pixels, width, 325, 112, 250, 72, [180, 83, 9])
+ fillRect(pixels, width, 610, 112, 250, 72, [185, 28, 28])
+ fillRect(pixels, width, 40, 224, 820, 56, [226, 232, 240])
+ fillRect(pixels, width, 40, 304, 260, 18, [21, 128, 61])
+ fillRect(pixels, width, 320, 304, 180, 18, [180, 83, 9])
+ fillRect(pixels, width, 520, 304, 300, 18, [185, 28, 28])
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii")
+ return Buffer.concat([header, pixels])
+}
+
+function fillRect(pixels, imageWidth, x, y, width, height, rgb) {
+ for (let row = y; row < y + height; row += 1) {
+ for (let col = x; col < x + width; col += 1) {
+ const offset = (row * imageWidth + col) * 3
+ pixels[offset] = rgb[0]
+ pixels[offset + 1] = rgb[1]
+ pixels[offset + 2] = rgb[2]
+ }
+ }
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+}
diff --git a/scientific-bounty-award-publication-guard/src/index.js b/scientific-bounty-award-publication-guard/src/index.js
new file mode 100644
index 00000000..43fe3574
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/src/index.js
@@ -0,0 +1,223 @@
+const HARD_BLOCKS = new Set([
+ "missing_award_decision",
+ "payout_not_authorized",
+ "ip_transfer_incomplete",
+ "private_content_unredacted",
+ "award_splits_invalid",
+ "recipient_terms_missing"
+])
+
+export function evaluateAwardPublication(packet, options = {}) {
+ const now = normalizeDate(options.now ?? new Date())
+ const findings = []
+
+ checkAwardDecision(packet, findings)
+ checkPayout(packet, findings)
+ checkAppealWindow(packet, findings, now)
+ checkIpAndLicense(packet, findings)
+ checkEmbargo(packet, findings, now)
+ checkPrivacy(packet, findings)
+ checkTeamConsent(packet, findings)
+ checkAwardSplits(packet, findings)
+ checkReproducibility(packet, findings)
+
+ const hardBlocks = findings.filter((finding) => finding.severity === "block")
+ const reviewItems = findings.filter((finding) => finding.severity === "review")
+ const decision = hardBlocks.length
+ ? "BLOCK_PUBLICATION"
+ : reviewItems.length
+ ? "HOLD_FOR_REVIEW"
+ : "RELEASE_PUBLICATION"
+
+ return {
+ challengeId: packet.challengeId ?? "unknown",
+ awardId: packet.award?.id ?? "unknown",
+ decision,
+ checkedAt: now.toISOString(),
+ summary: summarize(findings),
+ findings,
+ audit: {
+ publicMode: packet.publication?.mode ?? "unknown",
+ payoutState: packet.payout?.state ?? "unknown",
+ ipPolicy: packet.ip?.policy ?? "unknown",
+ embargoUntil: packet.publication?.embargoUntil ?? null,
+ recipientCount: packet.award?.splits?.length ?? 0
+ }
+ }
+}
+
+export function evaluateAwardPublicationBatch(packets, options = {}) {
+ return packets.map((packet) => evaluateAwardPublication(packet, options))
+}
+
+export function summarize(resultsOrFindings) {
+ const findings = Array.isArray(resultsOrFindings)
+ ? resultsOrFindings.flatMap((item) => item.findings ?? item)
+ : []
+
+ return {
+ blocks: findings.filter((finding) => finding.severity === "block").length,
+ reviews: findings.filter((finding) => finding.severity === "review").length,
+ passes: findings.filter((finding) => finding.severity === "pass").length
+ }
+}
+
+function checkAwardDecision(packet, findings) {
+ if (!packet.award?.id || packet.award?.state !== "awarded") {
+ findings.push(issue("missing_award_decision", "block", "Award decision is missing or not finalized."))
+ return
+ }
+ if (!packet.award?.sponsorApproved) {
+ findings.push(issue("sponsor_approval_missing", "review", "Sponsor has not approved the award publication."))
+ return
+ }
+ findings.push(issue("award_decision_ready", "pass", "Award decision and sponsor approval are present."))
+}
+
+function checkPayout(packet, findings) {
+ const allowed = new Set(["authorized", "released", "paid"])
+ if (!allowed.has(packet.payout?.state)) {
+ findings.push(issue("payout_not_authorized", "block", "Public winner publication requires an authorized, released, or paid payout state."))
+ return
+ }
+ findings.push(issue("payout_ready", "pass", "Payout state allows public award publication."))
+}
+
+function checkAppealWindow(packet, findings, now) {
+ const appeal = packet.appeal ?? {}
+ if (appeal.waived === true) {
+ findings.push(issue("appeal_waived", "pass", "Appeal window was explicitly waived."))
+ return
+ }
+ const closesAt = parseOptionalDate(appeal.closesAt)
+ if (!closesAt) {
+ findings.push(issue("appeal_window_missing", "review", "Appeal close timestamp is missing."))
+ return
+ }
+ if (closesAt > now) {
+ findings.push(issue("appeal_window_open", "review", `Appeal window is still open until ${closesAt.toISOString()}.`))
+ return
+ }
+ findings.push(issue("appeal_window_closed", "pass", "Appeal window is closed."))
+}
+
+function checkIpAndLicense(packet, findings) {
+ const ip = packet.ip ?? {}
+ if (ip.policy === "transfer_on_payout") {
+ if (ip.transferSigned !== true || !["released", "paid"].includes(packet.payout?.state)) {
+ findings.push(issue("ip_transfer_incomplete", "block", "IP transfer publication requires signed handoff and released or paid payout."))
+ return
+ }
+ }
+
+ if (ip.policy === "open_license" && !ip.licenseId) {
+ findings.push(issue("open_license_missing", "review", "Open publication requires a license identifier."))
+ return
+ }
+
+ findings.push(issue("ip_terms_ready", "pass", "IP or license terms allow publication."))
+}
+
+function checkEmbargo(packet, findings, now) {
+ const embargoUntil = parseOptionalDate(packet.publication?.embargoUntil)
+ if (embargoUntil && embargoUntil > now) {
+ findings.push(issue("embargo_active", "review", `Publication embargo remains active until ${embargoUntil.toISOString()}.`))
+ return
+ }
+ findings.push(issue("embargo_clear", "pass", "No active embargo blocks publication."))
+}
+
+function checkPrivacy(packet, findings) {
+ const publication = packet.publication ?? {}
+ if (publication.mode === "private_challenge_public_award" && publication.redactionApproved !== true) {
+ findings.push(issue("private_content_unredacted", "block", "Private or NDA challenge content needs approved public redaction before award publication."))
+ return
+ }
+ findings.push(issue("privacy_release_ready", "pass", "Publication privacy mode is satisfied."))
+}
+
+function checkTeamConsent(packet, findings) {
+ const publication = packet.publication ?? {}
+ if (publication.namedWinners !== true) {
+ findings.push(issue("anonymous_publication_ok", "pass", "Winner publication does not expose named solvers."))
+ return
+ }
+
+ const missing = (packet.award?.splits ?? []).filter((split) => split.namedConsent !== true)
+ if (missing.length) {
+ findings.push(issue("named_consent_missing", "review", `Named publication consent missing for ${missing.map((item) => item.recipientId).join(", ")}.`))
+ return
+ }
+ findings.push(issue("named_consent_ready", "pass", "All named recipients consented to public announcement."))
+}
+
+function checkAwardSplits(packet, findings) {
+ const splits = packet.award?.splits ?? []
+ const total = splits.reduce((sum, split) => sum + Number(split.percent ?? 0), 0)
+ let hasProblem = false
+ if (splits.length === 0 || Math.abs(total - 100) > 0.001) {
+ findings.push(issue("award_splits_invalid", "block", `Award split percentages must sum to 100; received ${total}.`))
+ hasProblem = true
+ }
+
+ const missingTerms = splits.filter((split) => split.acceptedPublicationTerms !== true)
+ if (missingTerms.length) {
+ findings.push(issue("recipient_terms_missing", "block", `Publication terms missing for ${missingTerms.map((item) => item.recipientId).join(", ")}.`))
+ hasProblem = true
+ }
+
+ if (hasProblem) {
+ return
+ }
+ findings.push(issue("award_splits_ready", "pass", "Award splits and recipient publication terms are complete."))
+}
+
+function checkReproducibility(packet, findings) {
+ const manifest = packet.reproducibilityManifest ?? {}
+ if (!manifest.artifactHash || !manifest.resultDigest) {
+ findings.push(issue("reproducibility_manifest_incomplete", "review", "Public claims should include artifact hash and result digest."))
+ return
+ }
+ findings.push(issue("reproducibility_manifest_ready", "pass", "Reproducibility manifest has public claim evidence."))
+}
+
+function issue(code, severity, message) {
+ return {
+ code,
+ severity,
+ hardBlock: HARD_BLOCKS.has(code),
+ message,
+ remediation: remediationFor(code)
+ }
+}
+
+function remediationFor(code) {
+ const remediations = {
+ missing_award_decision: "Finalize the award record and sponsor approval before publication.",
+ payout_not_authorized: "Move payout to authorized, released, or paid before publishing named winners.",
+ appeal_window_missing: "Record an appeal close timestamp or explicit waiver.",
+ appeal_window_open: "Wait until the appeal window closes or record an approved waiver.",
+ ip_transfer_incomplete: "Complete signed IP handoff and release or pay the payout before publication.",
+ open_license_missing: "Attach the open license identifier to the award record.",
+ embargo_active: "Hold public publication until the embargo expires.",
+ private_content_unredacted: "Approve a public redaction packet for private challenge content.",
+ named_consent_missing: "Collect named publication consent or publish anonymized winners.",
+ award_splits_invalid: "Correct award splits so recipient percentages total exactly 100.",
+ recipient_terms_missing: "Collect publication terms from each payout recipient.",
+ reproducibility_manifest_incomplete: "Attach artifact hash and result digest before publishing technical claims."
+ }
+ return remediations[code] ?? "No remediation required."
+}
+
+function normalizeDate(value) {
+ const date = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ throw new Error(`Invalid date: ${value}`)
+ }
+ return date
+}
+
+function parseOptionalDate(value) {
+ if (!value) return null
+ return normalizeDate(value)
+}
diff --git a/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js b/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js
new file mode 100644
index 00000000..0b89c5cf
--- /dev/null
+++ b/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict"
+import packets from "../examples/award-publication-packets.json" with { type: "json" }
+import { evaluateAwardPublication, evaluateAwardPublicationBatch, summarize } from "../src/index.js"
+
+const now = "2026-06-12T00:00:00Z"
+
+const [ready, embargoHold, blocked] = evaluateAwardPublicationBatch(packets, { now })
+
+assert.equal(ready.decision, "RELEASE_PUBLICATION")
+assert.equal(ready.summary.blocks, 0)
+assert.equal(ready.summary.reviews, 0)
+
+assert.equal(embargoHold.decision, "HOLD_FOR_REVIEW")
+assert.ok(embargoHold.findings.some((finding) => finding.code === "embargo_active"))
+
+assert.equal(blocked.decision, "BLOCK_PUBLICATION")
+assert.ok(blocked.findings.some((finding) => finding.code === "payout_not_authorized"))
+assert.ok(blocked.findings.some((finding) => finding.code === "ip_transfer_incomplete"))
+assert.ok(blocked.findings.some((finding) => finding.code === "award_splits_invalid"))
+assert.ok(blocked.findings.some((finding) => finding.code === "recipient_terms_missing"))
+
+const anonymousPacket = structuredClone(packets[0])
+anonymousPacket.publication.namedWinners = false
+anonymousPacket.award.splits[0].namedConsent = false
+anonymousPacket.award.splits[1].namedConsent = false
+const anonymousResult = evaluateAwardPublication(anonymousPacket, { now })
+assert.equal(anonymousResult.decision, "RELEASE_PUBLICATION")
+assert.ok(anonymousResult.findings.some((finding) => finding.code === "anonymous_publication_ok"))
+
+const openLicensePacket = structuredClone(packets[0])
+openLicensePacket.ip = { policy: "open_license" }
+const openLicenseResult = evaluateAwardPublication(openLicensePacket, { now })
+assert.equal(openLicenseResult.decision, "HOLD_FOR_REVIEW")
+assert.ok(openLicenseResult.findings.some((finding) => finding.code === "open_license_missing"))
+
+const batchSummary = summarize([ready, embargoHold, blocked])
+assert.equal(batchSummary.blocks, blocked.summary.blocks)
+assert.equal(batchSummary.reviews, embargoHold.summary.reviews + blocked.summary.reviews)
+
+console.log("award publication guard tests passed")