fix(publish): fail closed when required synthesis is unavailable (#571) (#573)

* fix(publish): fail closed when required synthesis is unavailable (#571) Gate normal-mode Copilot-authored weekly publication on the presence of a valid synthesis narrative. When synthesis is missing, empty, or the Copilot CLI failed/was not verifiable, block promotion, name the specific failure mode in the workflow log, and record synthesis provenance on the publish manifest so downstream promotion cannot mark an item eligible. Workflow (.github/workflows/crawl-and-publish.yml): - Move Install Copilot CLI ahead of the synthesis step and add an explicit 'copilot --version' verification so synthesis cannot start until Copilot installation succeeds. - Emit a precise synthesis_status output (available | empty | failed | missing) with a distinct warning/error line for each failure mode. - Pass --synthesis-status / --synthesis-file into publish_manifest.py so the manifest carries the authoritative signal. publish_manifest.py: - Add --synthesis-status / --synthesis-file arguments (default 'missing' = fail closed). - synthesis_required = run_mode == 'normal' and ai_status == 'ai', so dry-run, candidate-only, restore, force-replace, and no-ai fallback paths stay isolated from the new gate. - Record a 'synthesis' block on the manifest (required, status, available, path, sha256, reasons). - Block eligibility and add a promotion reason when required synthesis is not available, and make assert-eligible raise for the same condition. Tests: - New FailClosedSynthesisTests covering missing/empty/failed blocking, available happy path with provenance recorded, and non-gating of dry-run and no-ai modes. - Update the promotion-guard helper to declare synthesis_status=available for its simulated happy-path publish. Closes #571 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: format publish manifest files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(publish): require synthesis provenance for 'available' and fail closed Address Copilot review on jmservera/SquadScope#573: - create-manifest: downgrade synthesis status 'available' to 'missing' when no readable, non-empty synthesis file is provided, clearing the file reference so the manifest never advertises unbacked provenance. - assert-eligible: reject manifests that claim synthesis is available but lack authoritative provenance (path + sha256). - workflow: emit a notice (not a misleading ::error::) when Copilot CLI is intentionally absent on the map-reduce-dry-run path. - tests: back 'available' claims with real files in shared helpers, add fail-closed downgrade/empty-file/provenance coverage, drop dead if-False branch in the path assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: jmservera <jmservera@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jul 17, 2026 at 18:08 UTC 2c15dd1b001d7397b80a28b4174ae8fd4aeda30b
4 files changed +368 -13
.github/workflows/crawl-and-publish.yml
+44 -13
@@ -494,14 +494,26 @@ jobs:
494 PY
495 echo "press_file=$PRESS_FILE" >> "$GITHUB_OUTPUT"
496
497 + - name: Install Copilot CLI
498 + if: ${{ inputs.analysis_path != 'map-reduce-dry-run' }}
499 + id: install-copilot
500 + run: |
501 + set -euo pipefail
502 + npm install -g @github/copilot
503 + # Verify the CLI actually installed and is runnable before any step
504 + # (including synthesis) is allowed to depend on it.
505 + copilot --version
506 +
507 - name: Run synthesis step (Step 1)
508 id: synthesis
509 env:
510 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
511 + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GH_TOKEN }}
512 IN_WEEK_FILE: ${{ steps.analysis-context.outputs.week_file }}
513 IN_OUTPUT_FILE: ${{ steps.analysis-context.outputs.candidate_output_file }}
514 IN_CURRENT_DATETIME: ${{ steps.analysis-context.outputs.current_datetime }}
515 IN_PRESS_FILE: ${{ steps.press-context.outputs.press_file }}
516 + IN_ANALYSIS_PATH: ${{ inputs.analysis_path }}
517 run: |
518 set -euo pipefail
519 WEEK_FILE="$IN_WEEK_FILE"
@@ -513,7 +525,11 @@ jobs:
525 SYNTHESIS_PROMPT="$DIAGNOSTICS_DIR/synthesis-prompt.md"
526 mkdir -p "$DIAGNOSTICS_DIR"
527 # Step 1: render synthesis prompt, then run via Copilot CLI.
516 - # If this fails, we fall back to the single-prompt approach (no synthesis_input).
528 + # Copilot CLI installation/verification (previous step) must have
529 + # already succeeded before this step runs. synthesis_status is a
530 + # precise, fail-closed signal (available/empty/failed/missing)
531 + # recorded on the publish manifest; only "available" is publishable
532 + # for normal-mode publication (see scripts/publish_manifest.py).
533 python3 scripts/analyze_fallback.py \
534 --raw-json "$WEEK_FILE" \
535 --output "$OUTPUT_FILE" \
@@ -521,7 +537,16 @@ jobs:
537 --press-context "$PRESS_FILE" \
538 --run-synthesis \
539 --synthesis-output "$SYNTHESIS_PROMPT"
524 - if command -v copilot >/dev/null 2>&1 && [ -f "$SYNTHESIS_PROMPT" ]; then
540 + SYNTHESIS_STATUS="missing"
541 + if [ ! -f "$SYNTHESIS_PROMPT" ]; then
542 + echo "::warning::Synthesis prompt was not generated; required synthesis is missing."
543 + elif ! command -v copilot >/dev/null 2>&1; then
544 + if [ "$IN_ANALYSIS_PATH" = "map-reduce-dry-run" ]; then
545 + echo "::notice::Copilot CLI intentionally not installed for map-reduce dry run; synthesis skipped (required synthesis is missing)."
546 + else
547 + echo "::error::Copilot CLI is unavailable after installation step; required synthesis is missing."
548 + fi
549 + else
550 set +e
551 copilot \
552 --agent weekly-synthesis \
@@ -533,15 +558,21 @@ jobs:
558 > "$DIAGNOSTICS_DIR/synthesis-copilot.log" 2>&1
559 SYNTH_STATUS=$?
560 set -e
536 - if [ "$SYNTH_STATUS" -eq 0 ] && [ -f "$SYNTHESIS_FILE" ] && [ -s "$SYNTHESIS_FILE" ]; then
537 - echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
538 - echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
561 + if [ "$SYNTH_STATUS" -ne 0 ]; then
562 + SYNTHESIS_STATUS="failed"
563 + echo "::warning::Synthesis Copilot CLI failed (exit=$SYNTH_STATUS); required synthesis is failed."
564 + elif [ ! -s "$SYNTHESIS_FILE" ]; then
565 + SYNTHESIS_STATUS="empty"
566 + echo "::warning::Synthesis Copilot CLI produced no narrative content; required synthesis is empty."
567 else
540 - echo "::warning::Synthesis Copilot CLI failed (exit=$SYNTH_STATUS); falling back to single-prompt approach."
541 - echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
568 + SYNTHESIS_STATUS="available"
569 fi
570 + fi
571 + echo "synthesis_status=$SYNTHESIS_STATUS" >> "$GITHUB_OUTPUT"
572 + if [ "$SYNTHESIS_STATUS" = "available" ]; then
573 + echo "synthesis_file=$SYNTHESIS_FILE" >> "$GITHUB_OUTPUT"
574 + echo "synthesis_available=true" >> "$GITHUB_OUTPUT"
575 else
544 - echo "::warning::Synthesis step skipped (copilot not available or prompt missing); falling back to single-prompt."
576 echo "synthesis_available=false" >> "$GITHUB_OUTPUT"
577 fi
578
@@ -613,11 +644,6 @@ jobs:
644 echo "preflight_report_json=$PREFLIGHT_JSON" >> "$GITHUB_OUTPUT"
645 echo "preflight_report_md=$PREFLIGHT_MD" >> "$GITHUB_OUTPUT"
646
616 - - name: Install Copilot CLI
617 - if: ${{ inputs.analysis_path != 'map-reduce-dry-run' }}
618 - id: install-copilot
619 - run: npm install -g @github/copilot
620 -
647 - name: Run analysis
648 if: steps.prompt-preflight.outcome == 'success'
649 id: run-analysis
@@ -910,6 +936,8 @@ jobs:
936 SOURCE_RUN_ID: ${{ inputs.source_run_id || '' }}
937 GATE_REPORT: ${{ steps.analysis-context.outputs.analysis_gate_report_file }}
938 PREFLIGHT_REPORT: ${{ steps.prompt-preflight.outputs.preflight_report_json }}
939 + SYNTHESIS_STATUS: ${{ steps.synthesis.outputs.synthesis_status }}
940 + SYNTHESIS_FILE: ${{ steps.synthesis.outputs.synthesis_file }}
941 run: |
942 set -euo pipefail
943 git fetch origin publish 2>/dev/null && git checkout origin/publish -- "$PUBLISHED_SUMMARY" 2>/dev/null || true
@@ -932,6 +960,8 @@ jobs:
960 path="${candidate#*=}"
961 [ -f "$path" ] && ARTIFACT_ARGS+=(--artifact "$candidate")
962 done
963 + SYNTHESIS_ARGS=(--synthesis-status "${SYNTHESIS_STATUS:-missing}")
964 + [ -n "${SYNTHESIS_FILE:-}" ] && [ -f "${SYNTHESIS_FILE:-}" ] && SYNTHESIS_ARGS+=(--synthesis-file "$SYNTHESIS_FILE")
965 python3 scripts/publish_manifest.py create \
966 --week "$WEEK" \
967 --run-id "$RUN_ID" \
@@ -946,6 +976,7 @@ jobs:
976 --run-mode "$RUN_MODE" \
977 --source-refresh-policy "$SOURCE_REFRESH_POLICY" \
978 --gate-report "$GATE_REPORT" \
979 + "${SYNTHESIS_ARGS[@]}" \
980 --output "$MANIFEST_FILE" \
981 "${RESTORE_ARGS[@]}" \
982 "${ARTIFACT_ARGS[@]}"
scripts/publish_manifest.py
+81
@@ -17,6 +17,7 @@ except ModuleNotFoundError: # pragma: no cover - direct script execution path
17 SCHEMA_VERSION = "publish_eligibility_v1"
18 AI_SOURCES = {"copilot-cli"}
19 RUN_MODES = {"normal", "dry-run", "restore", "force-replace", "candidate-only"}
20 +SYNTHESIS_STATUSES = {"available", "missing", "empty", "failed"}
21 SOURCE_REFRESH_POLICIES = {"reuse-same-day", "refresh-missing-stale", "force-refresh"}
22 ALLOWED_PROMOTION_MANIFEST_ROOTS = {("data", "staging"), ("data", "candidates")}
23 PROMOTION_MANIFEST_ROOT_ERROR = (
@@ -76,6 +77,22 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
77 type=Path,
78 help="Structured analysis gate report emitted by analysis_gate.py.",
79 )
80 + create.add_argument(
81 + "--synthesis-status",
82 + choices=sorted(SYNTHESIS_STATUSES),
83 + default="missing",
84 + help=(
85 + "Status of the required weekly synthesis narrative that feeds the Copilot "
86 + "analysis prompt. Defaults to 'missing' (fail closed) when not explicitly "
87 + "provided by the workflow. Only 'available' is publishable for normal-mode "
88 + "AI-authored publication."
89 + ),
90 + )
91 + create.add_argument(
92 + "--synthesis-file",
93 + type=Path,
94 + help="Path to the synthesis narrative file, when --synthesis-status is 'available'.",
95 + )
96 create.add_argument("--output", required=True, type=Path)
97 create.add_argument(
98 "--artifact", action="append", default=[], help="Additional source artifact as role=path."
@@ -655,6 +672,40 @@ def create_manifest(args: argparse.Namespace) -> int:
672 candidate_content_exists = candidate_content.exists()
673 validation_passed = args.validation_status == "passed"
674 mode_allows_promotion = args.run_mode not in {"dry-run", "candidate-only"}
675 + # Required synthesis only gates normal-mode AI-authored publication. Explicitly
676 + # documented non-normal/debug modes (dry-run, candidate-only, restore,
677 + # force-replace) are unaffected, matching their existing escape-hatch gates.
678 + synthesis_status = args.synthesis_status
679 + synthesis_required = args.run_mode == "normal" and ai_status == "ai"
680 + synthesis_reasons: list[str] = []
681 + # Fail closed: a claim of "available" is only trustworthy when it is backed by a
682 + # readable, non-empty synthesis file. If the provenance is absent or invalid we
683 + # downgrade the status to "missing" and drop the (unusable) file reference so the
684 + # manifest never advertises unbacked synthesis provenance.
685 + synthesis_file = args.synthesis_file
686 + synthesis_sha256 = sha256_file(synthesis_file) if synthesis_file else None
687 + synthesis_downgrade_note: str | None = None
688 + if synthesis_status == "available":
689 + file_ok = (
690 + synthesis_file is not None
691 + and synthesis_file.is_file()
692 + and synthesis_file.stat().st_size > 0
693 + and synthesis_sha256 is not None
694 + )
695 + if not file_ok:
696 + synthesis_status = "missing"
697 + synthesis_file = None
698 + synthesis_sha256 = None
699 + synthesis_downgrade_note = (
700 + "required synthesis claimed 'available' but no readable, non-empty "
701 + "synthesis file was provided (downgraded to missing)"
702 + )
703 + if synthesis_required and synthesis_status != "available":
704 + # Always emit the canonical, mode-naming reason for every non-available
705 + # required status, then add any downgrade context as a secondary reason.
706 + synthesis_reasons.append(f"required synthesis is {synthesis_status} for normal publication")
707 + if synthesis_downgrade_note:
708 + synthesis_reasons.append(synthesis_downgrade_note)
709 gates_passed = gate_report.get("present") is True and gate_report.get("passed") is True
710 candidate_quality = candidate_metadata.get("quality_score")
711 attempted_ai_paths = [path for path in args.attempted_ai_path if path.strip()]
@@ -735,6 +786,7 @@ def create_manifest(args: argparse.Namespace) -> int:
786 reasons.extend(artifact_reasons)
787 reasons.extend(restore_reasons)
788 reasons.extend(comparison_reasons)
789 + reasons.extend(synthesis_reasons)
790
791 eligible = (
792 candidate_exists
@@ -745,6 +797,7 @@ def create_manifest(args: argparse.Namespace) -> int:
797 and not restore_reasons
798 and not comparison_reasons
799 and not preflight_reasons
800 + and not synthesis_reasons
801 and mode_allows_promotion
802 and not reasons
803 and (
@@ -780,6 +833,14 @@ def create_manifest(args: argparse.Namespace) -> int:
833 },
834 "published": published_status,
835 "source_artifacts": source_artifacts,
836 + "synthesis": {
837 + "required": synthesis_required,
838 + "status": synthesis_status,
839 + "available": synthesis_status == "available",
840 + "path": synthesis_file.as_posix() if synthesis_file else None,
841 + "sha256": synthesis_sha256,
842 + "reasons": synthesis_reasons,
843 + },
844 "analysis": {
845 "ai_status": ai_status,
846 "source": analysis_source,
@@ -934,6 +995,26 @@ def assert_eligible(args: argparse.Namespace) -> int:
995 preflight = analysis.get("preflight")
996 if not isinstance(preflight, dict) or preflight.get("publish_eligible") is not True:
997 raise SystemExit("Manifest lacks a publish-eligible Copilot preflight report.")
998 + synthesis = payload.get("synthesis")
999 + if ai_status == "ai" and payload.get("run_mode") == "normal":
1000 + if not isinstance(synthesis, dict) or synthesis.get("required") is not True:
1001 + raise SystemExit(
1002 + "Manifest lacks required synthesis provenance for normal-mode publication."
1003 + )
1004 + if synthesis.get("status") != "available":
1005 + raise SystemExit(
1006 + "Manifest blocks promotion: required synthesis is "
1007 + f"{synthesis.get('status')!r} (missing/empty/failed), not available."
1008 + )
1009 + synthesis_path = synthesis.get("path")
1010 + synthesis_sha256 = synthesis.get("sha256")
1011 + if not (isinstance(synthesis_path, str) and synthesis_path.strip()) or not (
1012 + isinstance(synthesis_sha256, str) and synthesis_sha256.strip()
1013 + ):
1014 + raise SystemExit(
1015 + "Manifest claims synthesis is available but lacks authoritative "
1016 + "provenance (path and sha256)."
1017 + )
1018 validation = payload.get("validation")
1019 gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
1020 if (
tests/test_promotion_guard.py
+7
@@ -153,11 +153,13 @@ def create_publish_manifest(
153 manifest_path = candidate_dir / "publish-manifest.json"
154 gate_report = candidate_dir / "analysis-gate-report.json"
155 preflight_report = candidate_dir / "diagnostics" / "analysis-preflight.json"
156 + synthesis_file = candidate_dir / "diagnostics" / "synthesis-narrative.md"
157 write_file(root, summary_path.as_posix(), VALID_REPLACEMENT_SUMMARY)
158 write_publish_raw(root)
159 write_gate_report(root, gate_report, passed=gate_passed)
160 if source == "copilot-cli":
161 write_preflight(root, preflight_report)
162 + write_file(root, synthesis_file.as_posix(), "Weekly synthesis narrative.\n")
163
164 previous_cwd = Path.cwd()
165 try:
@@ -189,6 +191,11 @@ def create_publish_manifest(
191 ]
192 if source == "copilot-cli":
193 args.extend(["--preflight-report", preflight_report.as_posix()])
194 + # Simulate a successful upstream synthesis step; fail-closed
195 + # behavior for missing/empty/failed synthesis is covered by
196 + # dedicated tests in tests/test_publish_manifest.py.
197 + args.extend(["--synthesis-status", "available"])
198 + args.extend(["--synthesis-file", synthesis_file.as_posix()])
199 publish_manifest.main(args)
200 finally:
201 os.chdir(previous_cwd)
tests/test_publish_manifest.py
+236
@@ -143,6 +143,8 @@ def create_args(
143 run_mode: str = "normal",
144 source_run_id: str = "",
145 raw_store_manifest: Path | None = None,
146 + synthesis_status: str | None = "available",
147 + synthesis_file: Path | None = None,
148 ) -> list[str]:
149 args = [
150 "create",
@@ -179,6 +181,16 @@ def create_args(
181 args.extend(["--preflight-report", str(preflight_path)])
182 elif isinstance(preflight, Path):
183 args.extend(["--preflight-report", str(preflight)])
184 + if synthesis_status is not None:
185 + args.extend(["--synthesis-status", synthesis_status])
186 + # When we claim synthesis is available we must back it with real provenance
187 + # (a readable, non-empty file), matching how the workflow signals "available".
188 + if synthesis_status == "available" and synthesis_file is None:
189 + synthesis_file = manifest.parent / "diagnostics" / "synthesis-narrative.md"
190 + synthesis_file.parent.mkdir(parents=True, exist_ok=True)
191 + synthesis_file.write_text("Weekly synthesis narrative.\n", encoding="utf-8")
192 + if synthesis_file is not None:
193 + args.extend(["--synthesis-file", str(synthesis_file)])
194 if source_run_id:
195 args.extend(["--source-run-id", source_run_id])
196 if raw_store_manifest is not None:
@@ -1256,5 +1268,229 @@ class PublishManifestTests(unittest.TestCase):
1268 )
1269
1270
1271 +class FailClosedSynthesisTests(unittest.TestCase):
1272 + """Cover issue #571: required synthesis must fail closed for normal AI publication."""
1273 +
1274 + def _prepare(self, base: Path) -> tuple[Path, Path, Path, Path]:
1275 + raw = base / "data/raw/2026-W21.json"
1276 + summary = base / "data/candidates/2026-W21/123456/2026-W21-summary.md"
1277 + manifest = base / "data/candidates/2026-W21/123456/publish-manifest.json"
1278 + gate_report = base / "data/candidates/2026-W21/123456/analysis-gate-report.json"
1279 + write_raw(raw)
1280 + write_summary(summary)
1281 + write_gate_report(gate_report)
1282 + return raw, summary, manifest, gate_report
1283 +
1284 + def _assert_synthesis_blocks(self, status: str) -> None:
1285 + tests_root = Path(__file__).resolve().parent
1286 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1287 + base = Path(tmpdir)
1288 + raw, summary, manifest, gate_report = self._prepare(base)
1289 +
1290 + publish_manifest.main(
1291 + create_args(
1292 + base,
1293 + raw,
1294 + summary,
1295 + manifest,
1296 + gate_report=gate_report,
1297 + synthesis_status=status,
1298 + )
1299 + )
1300 +
1301 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1302 + self.assertFalse(payload["promotion"]["eligible"])
1303 + self.assertTrue(payload["synthesis"]["required"])
1304 + self.assertEqual(payload["synthesis"]["status"], status)
1305 + self.assertFalse(payload["synthesis"]["available"])
1306 + self.assertTrue(
1307 + any(
1308 + f"required synthesis is {status}" in reason
1309 + for reason in payload["promotion"]["reasons"]
1310 + ),
1311 + f"expected synthesis reason for status={status!r} in {payload['promotion']['reasons']}",
1312 + )
1313 + with self.assertRaises(SystemExit) as ctx:
1314 + assert_eligible_from_root(base, manifest)
1315 + self.assertIn("synthesis", str(ctx.exception).lower())
1316 + self.assertIn(status, str(ctx.exception))
1317 +
1318 + def test_missing_synthesis_blocks_normal_ai_publication(self) -> None:
1319 + self._assert_synthesis_blocks("missing")
1320 +
1321 + def test_empty_synthesis_blocks_normal_ai_publication(self) -> None:
1322 + self._assert_synthesis_blocks("empty")
1323 +
1324 + def test_failed_synthesis_blocks_normal_ai_publication(self) -> None:
1325 + self._assert_synthesis_blocks("failed")
1326 +
1327 + def test_available_synthesis_records_provenance_on_manifest(self) -> None:
1328 + tests_root = Path(__file__).resolve().parent
1329 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1330 + base = Path(tmpdir)
1331 + raw, summary, manifest, gate_report = self._prepare(base)
1332 + synthesis_file = base / "data/diagnostics/2026-W21/synthesis.md"
1333 + synthesis_file.parent.mkdir(parents=True, exist_ok=True)
1334 + synthesis_file.write_text("# Weekly synthesis narrative\n", encoding="utf-8")
1335 +
1336 + args = create_args(
1337 + base,
1338 + raw,
1339 + summary,
1340 + manifest,
1341 + gate_report=gate_report,
1342 + synthesis_status="available",
1343 + synthesis_file=synthesis_file,
1344 + )
1345 + self.assertEqual(publish_manifest.main(args), 0)
1346 +
1347 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1348 + self.assertTrue(payload["promotion"]["eligible"])
1349 + self.assertTrue(payload["synthesis"]["required"])
1350 + self.assertEqual(payload["synthesis"]["status"], "available")
1351 + self.assertTrue(payload["synthesis"]["available"])
1352 + self.assertEqual(
1353 + payload["synthesis"]["path"],
1354 + synthesis_file.as_posix(),
1355 + )
1356 + self.assertRegex(payload["synthesis"]["sha256"], r"^[0-9a-f]{64}$")
1357 + self.assertEqual(assert_eligible_from_root(base, manifest), 0)
1358 +
1359 + def test_dry_run_mode_is_not_gated_by_synthesis(self) -> None:
1360 + """Non-normal/debug modes remain isolated from the fail-closed gate."""
1361 + tests_root = Path(__file__).resolve().parent
1362 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1363 + base = Path(tmpdir)
1364 + raw, summary, manifest, gate_report = self._prepare(base)
1365 +
1366 + publish_manifest.main(
1367 + create_args(
1368 + base,
1369 + raw,
1370 + summary,
1371 + manifest,
1372 + gate_report=gate_report,
1373 + synthesis_status="missing",
1374 + run_mode="dry-run",
1375 + )
1376 + )
1377 +
1378 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1379 + self.assertFalse(payload["synthesis"]["required"])
1380 + self.assertFalse(
1381 + any("required synthesis" in reason for reason in payload["promotion"]["reasons"]),
1382 + f"dry-run must not emit synthesis reasons: {payload['promotion']['reasons']}",
1383 + )
1384 +
1385 + def test_no_ai_normal_mode_is_not_gated_by_synthesis(self) -> None:
1386 + """no-ai fallback publication is governed by its own force-replace path, not synthesis."""
1387 + tests_root = Path(__file__).resolve().parent
1388 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1389 + base = Path(tmpdir)
1390 + raw, summary, manifest, gate_report = self._prepare(base)
1391 +
1392 + publish_manifest.main(
1393 + create_args(
1394 + base,
1395 + raw,
1396 + summary,
1397 + manifest,
1398 + source="no-ai",
1399 + model="none",
1400 + gate_report=gate_report,
1401 + synthesis_status="missing",
1402 + )
1403 + )
1404 +
1405 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1406 + self.assertFalse(payload["synthesis"]["required"])
1407 + self.assertFalse(
1408 + any("required synthesis" in reason for reason in payload["promotion"]["reasons"]),
1409 + f"no-ai mode must not emit synthesis reasons: {payload['promotion']['reasons']}",
1410 + )
1411 +
1412 + def test_available_without_file_is_downgraded_and_fails_closed(self) -> None:
1413 + """Claiming 'available' without provenance downgrades to missing and blocks promotion."""
1414 + tests_root = Path(__file__).resolve().parent
1415 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1416 + base = Path(tmpdir)
1417 + raw, summary, manifest, gate_report = self._prepare(base)
1418 +
1419 + # Pass status "available" but no synthesis file / provenance.
1420 + args = create_args(
1421 + base,
1422 + raw,
1423 + summary,
1424 + manifest,
1425 + gate_report=gate_report,
1426 + synthesis_status=None,
1427 + )
1428 + args.extend(["--synthesis-status", "available"])
1429 + self.assertEqual(publish_manifest.main(args), 0)
1430 +
1431 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1432 + self.assertTrue(payload["synthesis"]["required"])
1433 + self.assertEqual(payload["synthesis"]["status"], "missing")
1434 + self.assertFalse(payload["synthesis"]["available"])
1435 + self.assertIsNone(payload["synthesis"]["path"])
1436 + self.assertIsNone(payload["synthesis"]["sha256"])
1437 + self.assertFalse(payload["promotion"]["eligible"])
1438 + self.assertTrue(
1439 + any(
1440 + "no readable, non-empty" in reason for reason in payload["synthesis"]["reasons"]
1441 + ),
1442 + payload["synthesis"]["reasons"],
1443 + )
1444 +
1445 + def test_available_with_empty_file_is_downgraded(self) -> None:
1446 + """An empty synthesis file cannot back an 'available' claim."""
1447 + tests_root = Path(__file__).resolve().parent
1448 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1449 + base = Path(tmpdir)
1450 + raw, summary, manifest, gate_report = self._prepare(base)
1451 + empty = base / "diagnostics" / "synthesis-narrative.md"
1452 + empty.parent.mkdir(parents=True, exist_ok=True)
1453 + empty.write_text("", encoding="utf-8")
1454 +
1455 + args = create_args(
1456 + base,
1457 + raw,
1458 + summary,
1459 + manifest,
1460 + gate_report=gate_report,
1461 + synthesis_status="available",
1462 + synthesis_file=empty,
1463 + )
1464 + self.assertEqual(publish_manifest.main(args), 0)
1465 +
1466 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1467 + self.assertEqual(payload["synthesis"]["status"], "missing")
1468 + self.assertIsNone(payload["synthesis"]["path"])
1469 + self.assertFalse(payload["promotion"]["eligible"])
1470 +
1471 + def test_assert_eligible_requires_synthesis_provenance(self) -> None:
1472 + """assert-eligible rejects a manifest that claims available without path/sha256."""
1473 + tests_root = Path(__file__).resolve().parent
1474 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
1475 + base = Path(tmpdir)
1476 + raw, summary, manifest, gate_report = self._prepare(base)
1477 +
1478 + self.assertEqual(
1479 + publish_manifest.main(
1480 + create_args(base, raw, summary, manifest, gate_report=gate_report)
1481 + ),
1482 + 0,
1483 + )
1484 + payload = json.loads(manifest.read_text(encoding="utf-8"))
1485 + # Tamper: strip provenance while leaving status "available".
1486 + payload["synthesis"]["path"] = None
1487 + payload["synthesis"]["sha256"] = None
1488 + manifest.write_text(json.dumps(payload), encoding="utf-8")
1489 +
1490 + with self.assertRaises(SystemExit) as ctx:
1491 + assert_eligible_from_root(base, manifest)
1492 + self.assertIn("provenance", str(ctx.exception))
1493 +
1494 +
1495 if __name__ == "__main__":
1496 unittest.main()