main
py 1,102 lines 44.2 KB
Raw
1 #!/usr/bin/env python3
2 from __future__ import annotations
3
4 import argparse
5 import hashlib
6 import json
7 import re
8 from datetime import UTC, datetime
9 from pathlib import Path
10 from typing import Any
11
12 try:
13 from scripts import publish_safety
14 except ModuleNotFoundError: # pragma: no cover - direct script execution path
15 import publish_safety # type: ignore[no-redef]
16
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 = (
24 "Publish manifest must live under data/staging/ or data/candidates/."
25 )
26 NO_AI_SOURCE = "no-ai"
27 MIN_PUBLISH_QUALITY_SCORE = 60
28 FALLBACK_MIN_QUALITY_SCORE = 70
29 NO_AI_MARKERS = (
30 "AI analysis was unavailable",
31 "without AI-powered analysis",
32 "Automated data-only summary",
33 "generated without AI assistance",
34 )
35 FRONTMATTER_PATTERN = re.compile(r"^---\n(?P<frontmatter>.*?)\n---\n(?P<body>.*)\Z", re.DOTALL)
36
37
38 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
39 parser = argparse.ArgumentParser(
40 description="Create or validate a weekly publish eligibility manifest."
41 )
42 subparsers = parser.add_subparsers(dest="command", required=True)
43
44 create = subparsers.add_parser("create", help="Create a publish eligibility manifest.")
45 create.add_argument("--week", required=True)
46 create.add_argument("--run-id", required=True)
47 create.add_argument("--root", default=".", type=Path)
48 create.add_argument("--current-datetime", required=True)
49 create.add_argument("--summary", required=True, type=Path)
50 create.add_argument(
51 "--content",
52 type=Path,
53 help="Rendered candidate content path. Defaults to --summary for legacy summary-only manifests.",
54 )
55 create.add_argument("--published-summary", required=True, type=Path)
56 create.add_argument("--raw-json", required=True, type=Path)
57 create.add_argument("--analysis-source", required=True)
58 create.add_argument("--analysis-model", default="copilot-default")
59 create.add_argument(
60 "--preflight-report",
61 type=Path,
62 help="Analysis preflight report JSON used to decide whether Copilot output is normally promotable.",
63 )
64 create.add_argument("--validation-status", choices=["passed", "failed"], required=True)
65 create.add_argument("--run-mode", choices=sorted(RUN_MODES), default="normal")
66 create.add_argument("--source-run-id", default="")
67 create.add_argument(
68 "--raw-store-manifest",
69 type=Path,
70 help="Immutable raw store manifest required for run_mode=restore.",
71 )
72 create.add_argument(
73 "--source-refresh-policy", choices=sorted(SOURCE_REFRESH_POLICIES), default="reuse-same-day"
74 )
75 create.add_argument(
76 "--gate-report",
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."
99 )
100 create.add_argument(
101 "--fallback-reason",
102 default="",
103 help="Required reason when analysis-source is no-ai; records why AI output was unavailable.",
104 )
105 create.add_argument(
106 "--attempted-ai-path",
107 action="append",
108 default=[],
109 help="AI path attempted before this candidate, e.g. provider=copilot-cli,model=copilot-default,status=failed.",
110 )
111 create.add_argument(
112 "--publish-policy",
113 choices=["default", "allow-no-ai-first-publish", "force-replace"],
114 default="default",
115 help="Explicit operator policy for publication overrides.",
116 )
117 create.add_argument(
118 "--force-reason", default="", help="Operator reason required for force-replace."
119 )
120 create.add_argument(
121 "--actor",
122 default="",
123 help="Operator or automation actor requesting an explicit publication policy.",
124 )
125
126 check = subparsers.add_parser(
127 "assert-eligible", help="Fail unless the manifest permits promotion."
128 )
129 check.add_argument("--manifest", required=True, type=Path)
130 return parser.parse_args(argv)
131
132
133 def sha256_file(path: Path) -> str | None:
134 if not path.exists() or not path.is_file():
135 return None
136 digest = hashlib.sha256()
137 with path.open("rb") as handle:
138 for chunk in iter(lambda: handle.read(1024 * 1024), b""):
139 digest.update(chunk)
140 return digest.hexdigest()
141
142
143 def parse_datetime(value: Any) -> datetime | None:
144 if not isinstance(value, str) or not value.strip():
145 return None
146 candidate = value.strip()
147 if candidate.endswith("Z"):
148 candidate = f"{candidate[:-1]}+00:00"
149 try:
150 parsed = datetime.fromisoformat(candidate)
151 except ValueError:
152 return None
153 return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
154
155
156 def week_slug(value: datetime) -> str:
157 iso_year, iso_week, _ = value.astimezone(UTC).isocalendar()
158 return f"{iso_year}-W{iso_week:02d}"
159
160
161 def load_json(path: Path) -> dict[str, Any] | None:
162 if not path.exists():
163 return None
164 try:
165 payload = json.loads(path.read_text(encoding="utf-8"))
166 except (OSError, json.JSONDecodeError):
167 return None
168 return payload if isinstance(payload, dict) else None
169
170
171 def restore_verification(args: argparse.Namespace) -> tuple[dict[str, Any], list[str]]:
172 required = args.run_mode == "restore"
173 source_run_id = args.source_run_id.strip()
174 manifest_path = args.raw_store_manifest
175 resolved_manifest_path = (
176 manifest_path
177 if manifest_path is None or manifest_path.is_absolute()
178 else args.root / manifest_path
179 )
180 result: dict[str, Any] = {
181 "required": required,
182 "verified": False,
183 "source_run_id": source_run_id or None,
184 "manifest_path": manifest_path.as_posix() if manifest_path else None,
185 "manifest_sha256": (
186 sha256_file(resolved_manifest_path) if resolved_manifest_path else None
187 ),
188 "source_artifact": None,
189 "files": [],
190 }
191 reasons: list[str] = []
192 if not required:
193 if source_run_id or manifest_path is not None:
194 reasons.append("restore provenance is only allowed with run_mode=restore")
195 return result, reasons
196 if not source_run_id:
197 reasons.append("run_mode=restore requires source_run_id")
198 if manifest_path is None:
199 reasons.append("run_mode=restore requires raw_store_manifest")
200 if reasons:
201 return result, reasons
202
203 try:
204 payload, verified = publish_safety.validate_raw_store_manifest(
205 args.root,
206 manifest_path,
207 expected_week=args.week,
208 expected_source_run_id=source_run_id,
209 )
210 except SystemExit as exc:
211 return result, [str(exc)]
212
213 verified_files: list[dict[str, Any]] = []
214 for _, restored_path, entry in verified:
215 if not restored_path.exists() or not restored_path.is_file():
216 reasons.append(f"Restored raw input is missing: {entry['original_path']}")
217 continue
218 if restored_path.stat().st_size != entry.get("size_bytes"):
219 reasons.append(f"Restored raw input size mismatch: {entry['original_path']}")
220 continue
221 if sha256_file(restored_path) != entry.get("sha256"):
222 reasons.append(f"Restored raw input checksum mismatch: {entry['original_path']}")
223 continue
224 verified_files.append(
225 {
226 "original_path": entry["original_path"],
227 "size_bytes": entry["size_bytes"],
228 "sha256": entry["sha256"],
229 }
230 )
231
232 result.update(
233 {
234 "verified": not reasons and len(verified_files) == len(verified),
235 "source_artifact": payload.get("source_artifact"),
236 "files": verified_files,
237 }
238 )
239 return result, reasons
240
241
242 def load_preflight(
243 path: Path | None, *, required: bool = False
244 ) -> tuple[dict[str, Any] | None, list[str]]:
245 if path is None:
246 if required:
247 return None, ["preflight report is required for Copilot CLI promotion"]
248 return None, []
249 payload = load_json(path)
250 if payload is None:
251 return None, [f"preflight report missing or malformed: {path}"]
252 reasons: list[str] = []
253 if payload.get("publish_eligible") is not True:
254 reasons.append("preflight report marks candidate as publish-ineligible")
255 return payload, reasons
256
257
258 def manifest_lives_under_allowed_promotion_root(
259 manifest_path: Path, root: Path | None = None
260 ) -> bool:
261 workspace = (root or Path.cwd()).resolve()
262 resolved_manifest = manifest_path if manifest_path.is_absolute() else workspace / manifest_path
263 try:
264 manifest_relative = resolved_manifest.resolve().relative_to(workspace)
265 except ValueError:
266 return False
267 return manifest_relative.parts[:2] in ALLOWED_PROMOTION_MANIFEST_ROOTS
268
269
270 def _parse_scalar(value: str) -> Any:
271 stripped = value.strip().strip("\"'")
272 if re.fullmatch(r"-?\d+", stripped):
273 return int(stripped)
274 if re.fullmatch(r"-?\d+\.\d+", stripped):
275 return float(stripped)
276 if stripped.lower() == "true":
277 return True
278 if stripped.lower() == "false":
279 return False
280 return stripped
281
282
283 def markdown_metadata(path: Path) -> dict[str, Any]:
284 if not path.exists() or not path.is_file():
285 return {
286 "exists": False,
287 "path": path.as_posix(),
288 "sha256": None,
289 "quality_score": None,
290 "week": None,
291 "ai_status": "missing",
292 "reasons": ["summary missing"],
293 }
294
295 text = path.read_text(encoding="utf-8", errors="replace")
296 match = FRONTMATTER_PATTERN.match(text)
297 frontmatter: dict[str, Any] = {}
298 reasons: list[str] = []
299 if match:
300 for line in match.group("frontmatter").splitlines():
301 if ":" not in line or line.startswith((" ", "\t")):
302 continue
303 key, value = line.split(":", 1)
304 frontmatter[key.strip()] = _parse_scalar(value)
305 else:
306 reasons.append("summary lacks YAML frontmatter")
307
308 quality = frontmatter.get("quality_score")
309 if not isinstance(quality, (int, float)):
310 quality = None
311 ai_status = "no-ai" if any(marker in text for marker in NO_AI_MARKERS) else "unknown"
312 source = str(frontmatter.get("analysis_source") or frontmatter.get("source") or "").strip()
313 if source in AI_SOURCES:
314 ai_status = "ai"
315 elif source == "no-ai":
316 ai_status = "no-ai"
317
318 return {
319 "exists": True,
320 "path": path.as_posix(),
321 "sha256": sha256_file(path),
322 "quality_score": quality,
323 "week": frontmatter.get("week"),
324 "title": frontmatter.get("title"),
325 "ai_status": ai_status,
326 "reasons": reasons,
327 }
328
329
330 def _published_manifest_paths(published_summary: Path, week: str) -> list[Path]:
331 root = published_summary
332 for parent in [published_summary, *published_summary.parents]:
333 if (parent / "data").exists():
334 root = parent
335 break
336 candidate_root = root / "data" / "candidates" / week
337 return sorted(candidate_root.glob("*/publish-manifest.json")) if candidate_root.exists() else []
338
339
340 def _ai_status_from_manifest(payload: dict[str, Any]) -> str:
341 analysis = payload.get("analysis")
342 if isinstance(analysis, dict):
343 ai_status = analysis.get("ai_status")
344 if ai_status in {"ai", "no-ai"}:
345 return ai_status
346 ai_provenance = payload.get("ai_provenance")
347 if isinstance(ai_provenance, dict):
348 source = ai_provenance.get("source")
349 if source in AI_SOURCES:
350 return "ai"
351 if source == "no-ai":
352 return "no-ai"
353 return "unknown"
354
355
356 def published_summary_status(path: Path, week: str) -> dict[str, Any]:
357 status = markdown_metadata(path)
358 if not status["exists"]:
359 status.update({"good": False, "provenance_source": "missing"})
360 return status
361
362 matching_manifest: dict[str, Any] | None = None
363 summary_sha = status.get("sha256")
364 for manifest_path in _published_manifest_paths(path, week):
365 payload = load_json(manifest_path)
366 if not payload:
367 continue
368 candidate = payload.get("candidate")
369 candidate_sha = candidate.get("summary_sha256") if isinstance(candidate, dict) else None
370 if candidate_sha == summary_sha:
371 matching_manifest = payload
372 status["provenance_manifest_path"] = manifest_path.as_posix()
373 break
374
375 if matching_manifest:
376 status["ai_status"] = _ai_status_from_manifest(matching_manifest)
377 status["provenance_source"] = "publish-manifest"
378 else:
379 status["provenance_source"] = "summary"
380
381 reasons = list(status.get("reasons", []))
382 if status.get("week") != week:
383 reasons.append(
384 f"published summary week mismatch: expected {week}, found {status.get('week')!r}"
385 )
386 quality = status.get("quality_score")
387 if quality is None:
388 reasons.append("published summary lacks quality_score")
389 elif quality < MIN_PUBLISH_QUALITY_SCORE:
390 reasons.append(
391 f"published summary quality_score below {MIN_PUBLISH_QUALITY_SCORE}: {quality}"
392 )
393 if status.get("ai_status") == "no-ai":
394 reasons.append("published summary is no-AI fallback")
395
396 status["reasons"] = reasons
397 status["good"] = not reasons and status.get("ai_status") != "no-ai"
398 return status
399
400
401 def fallback_quality_errors(summary: Path, validation_passed: bool) -> list[str]:
402 errors: list[str] = []
403 if not validation_passed:
404 errors.append("no-AI fallback cannot publish because analysis validation did not pass")
405 quality_score = markdown_metadata(summary).get("quality_score")
406 if not isinstance(quality_score, (int, float)) or quality_score < FALLBACK_MIN_QUALITY_SCORE:
407 errors.append(
408 f"no-AI fallback quality_score must be at least {FALLBACK_MIN_QUALITY_SCORE} for explicit fallback publication"
409 )
410 return errors
411
412
413 def same_day_reuse_status(payload: dict[str, Any] | None) -> dict[str, Any]:
414 metadata = payload.get("metadata", {}) if isinstance(payload, dict) else {}
415 if not isinstance(metadata, dict):
416 metadata = {}
417 explicit = metadata.get("same_day_reuse") or metadata.get("same_day_reuse_status")
418 if isinstance(explicit, dict):
419 return dict(explicit)
420 if explicit:
421 return {"status": str(explicit), "source": "artifact-metadata"}
422 return {
423 "status": "not_reused",
424 "source": "default",
425 "details": "No same-day reuse marker was present on this artifact.",
426 }
427
428
429 def freshness_for_json_artifact(
430 role: str,
431 week: str,
432 payload: dict[str, Any] | None,
433 *,
434 run_date: datetime | None = None,
435 run_mode: str = "normal",
436 ) -> dict[str, Any]:
437 if payload is None:
438 return {
439 "status": "missing" if role == "raw_github" else "not_applicable",
440 "reasons": ["artifact missing"],
441 }
442
443 reasons: list[str] = []
444 artifact_week = payload.get("week")
445 if artifact_week != week:
446 reasons.append(f"week mismatch: expected {week}, found {artifact_week!r}")
447
448 timestamp = payload.get("crawled_at") or payload.get("generated_at")
449 parsed = parse_datetime(timestamp)
450 if role in {"raw_github", "external_news", "techcrunch_news"}:
451 if parsed is None:
452 reasons.append("missing or invalid crawled_at/generated_at timestamp")
453 elif week_slug(parsed) != week:
454 reasons.append(f"timestamp week mismatch: expected {week}, found {week_slug(parsed)}")
455 elif (
456 run_date is not None
457 and run_mode not in {"restore", "force-replace"}
458 and parsed.astimezone(UTC).date() != run_date.astimezone(UTC).date()
459 ):
460 reasons.append("timestamp date is not the current UTC run date")
461
462 crawl_window = payload.get("crawl_window")
463 if role in {"external_news", "techcrunch_news"} and isinstance(crawl_window, dict):
464 until = parse_datetime(crawl_window.get("until"))
465 if until is not None and week_slug(until) != week:
466 reasons.append(
467 f"crawl_window.until week mismatch: expected {week}, found {week_slug(until)}"
468 )
469
470 return {"status": "fresh" if not reasons else "stale", "reasons": reasons}
471
472
473 def artifact_entry(
474 role: str,
475 path: Path,
476 week: str,
477 generated_at: str | None = None,
478 *,
479 run_date: datetime | None = None,
480 run_mode: str = "normal",
481 ) -> dict[str, Any]:
482 payload = load_json(path) if path.suffix == ".json" else None
483 metadata = (
484 payload.get("metadata", {})
485 if isinstance(payload, dict) and isinstance(payload.get("metadata"), dict)
486 else {}
487 )
488 if isinstance(payload, dict):
489 artifact_generated_at = (
490 payload.get("generated_at") or payload.get("crawled_at") or generated_at
491 )
492 else:
493 artifact_generated_at = generated_at
494 reuse_status = same_day_reuse_status(payload)
495 checksum = sha256_file(path)
496 entry: dict[str, Any] = {
497 "role": role,
498 "path": path.as_posix(),
499 "exists": path.exists(),
500 "size_bytes": path.stat().st_size if path.exists() else 0,
501 "sha256": checksum,
502 "artifact_checksum": metadata.get("artifact_checksum"),
503 "week": payload.get("week") if isinstance(payload, dict) else None,
504 "crawled_at": payload.get("crawled_at") if isinstance(payload, dict) else None,
505 "generated_at": artifact_generated_at,
506 "same_day_reuse": reuse_status,
507 "provenance": {
508 "path": path.as_posix(),
509 "sha256": checksum,
510 "artifact_checksum": metadata.get("artifact_checksum"),
511 "generated_at": artifact_generated_at,
512 "same_day_reuse": reuse_status,
513 },
514 "freshness": freshness_for_json_artifact(
515 role, week, payload, run_date=run_date, run_mode=run_mode
516 )
517 if path.suffix == ".json"
518 else {"status": "not_applicable", "reasons": []},
519 }
520 if "source_status" in metadata:
521 entry["source_status"] = metadata["source_status"]
522 if "source_reuse_summary" in metadata:
523 entry["source_reuse_summary"] = metadata["source_reuse_summary"]
524 if "source_artifact_provenance" in metadata:
525 entry["source_artifact_provenance"] = metadata["source_artifact_provenance"]
526 if "source_config_checksum" in metadata:
527 entry["source_config_checksum"] = metadata["source_config_checksum"]
528 if "schema_checksum" in metadata:
529 entry["schema_checksum"] = metadata["schema_checksum"]
530 if "sources_requested" in metadata:
531 entry["sources_requested"] = metadata["sources_requested"]
532 entry["sources_succeeded"] = metadata.get("sources_succeeded", [])
533 entry["sources_failed"] = metadata.get("sources_failed", [])
534 return entry
535
536
537 def parse_artifacts(values: list[str]) -> list[tuple[str, Path]]:
538 artifacts: list[tuple[str, Path]] = []
539 for value in values:
540 if "=" not in value:
541 raise SystemExit(f"Invalid --artifact value {value!r}; expected role=path")
542 role, raw_path = value.split("=", 1)
543 role = role.strip()
544 if not role:
545 raise SystemExit(f"Invalid --artifact value {value!r}; missing role")
546 artifacts.append((role, Path(raw_path)))
547 return artifacts
548
549
550 def load_gate_report(path: Path | None) -> dict[str, Any]:
551 if path is None:
552 return {
553 "path": None,
554 "present": False,
555 "passed": False,
556 "gates": {},
557 "errors": ["structured analysis gate report was not provided"],
558 }
559 payload = load_json(path)
560 if payload is None:
561 return {
562 "path": path.as_posix(),
563 "present": False,
564 "passed": False,
565 "gates": {},
566 "errors": ["structured analysis gate report is missing or malformed"],
567 }
568 gates = payload.get("gates")
569 if not isinstance(gates, dict):
570 gates = {}
571 errors = payload.get("errors_after_repair")
572 if not isinstance(errors, list):
573 errors = []
574 report = {
575 "path": path.as_posix(),
576 "present": True,
577 "passed": payload.get("passed") is True,
578 "failure_class": payload.get("failure_class"),
579 "source": payload.get("source"),
580 "model": payload.get("model"),
581 "repair_actions": payload.get("repair_actions")
582 if isinstance(payload.get("repair_actions"), list)
583 else [],
584 "errors": [str(error) for error in errors],
585 "gates": gates,
586 "sha256": sha256_file(path),
587 }
588 for gate_name in (
589 "structural_schema",
590 "ai_provenance",
591 "evidence_citation",
592 "editorial_quality",
593 ):
594 gate = gates.get(gate_name)
595 if not isinstance(gate, dict) or gate.get("passed") is not True:
596 report["passed"] = False
597 return report
598
599
600 def gate_reasons(report: dict[str, Any]) -> list[str]:
601 reasons: list[str] = []
602 if not report.get("present"):
603 return [
604 str(error)
605 for error in report.get("errors", ["structured analysis gate report missing"])
606 ]
607 gates = report.get("gates", {})
608 if not isinstance(gates, dict):
609 gates = {}
610 for required_gate in (
611 "structural_schema",
612 "ai_provenance",
613 "evidence_citation",
614 "editorial_quality",
615 ):
616 if required_gate not in gates:
617 reasons.append(f"{required_gate} gate missing from structured analysis gate report")
618 for name, gate in gates.items():
619 if isinstance(gate, dict) and gate.get("passed") is not True:
620 gate_errors = gate.get("errors") if isinstance(gate.get("errors"), list) else []
621 if gate_errors:
622 reasons.extend(f"{name}: {error}" for error in gate_errors)
623 else:
624 reasons.append(f"{name} gate did not pass")
625 for error in report.get("errors", []):
626 if not any(str(error) in reason for reason in reasons):
627 reasons.append(str(error))
628 return reasons
629
630
631 def publishable_model_status(model: str) -> str:
632 normalized = model.strip().lower()
633 if normalized in {"", "unknown", "unavailable", "none", "no-ai"}:
634 return "unavailable"
635 return "available"
636
637
638 def create_manifest(args: argparse.Namespace) -> int:
639 artifacts = [("raw_github", args.raw_json), *parse_artifacts(args.artifact)]
640 run_date = parse_datetime(args.current_datetime)
641 if run_date is None:
642 raise SystemExit(f"Invalid --current-datetime value: {args.current_datetime!r}")
643 source_artifacts = [
644 artifact_entry(
645 role, path, args.week, args.current_datetime, run_date=run_date, run_mode=args.run_mode
646 )
647 for role, path in artifacts
648 if path.exists() or role == "raw_github"
649 ]
650 artifact_reasons = [
651 f"{entry['role']}: {reason}"
652 for entry in source_artifacts
653 for reason in entry.get("freshness", {}).get("reasons", [])
654 ]
655
656 analysis_source = args.analysis_source.strip()
657 ai_status = (
658 "ai"
659 if analysis_source in AI_SOURCES
660 else "no-ai"
661 if analysis_source == NO_AI_SOURCE
662 else "unknown"
663 )
664 model_status = publishable_model_status(args.analysis_model)
665 preflight, preflight_reasons = load_preflight(args.preflight_report, required=ai_status == "ai")
666 gate_report = load_gate_report(args.gate_report)
667 restore, restore_reasons = restore_verification(args)
668 candidate_metadata = markdown_metadata(args.summary)
669 published_status = published_summary_status(args.published_summary, args.week)
670 candidate_exists = args.summary.exists()
671 candidate_content = args.content or args.summary
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()]
712 force_replacing = args.publish_policy == "force-replace"
713 comparison_reasons: list[str] = []
714 if candidate_exists:
715 if candidate_metadata.get("week") not in {None, args.week}:
716 comparison_reasons.append(
717 f"candidate summary week mismatch: expected {args.week}, found {candidate_metadata.get('week')!r}"
718 )
719 if candidate_quality is None:
720 comparison_reasons.append("candidate summary lacks quality_score")
721 elif candidate_quality < MIN_PUBLISH_QUALITY_SCORE:
722 comparison_reasons.append(
723 f"candidate quality_score below {MIN_PUBLISH_QUALITY_SCORE}: {candidate_quality}"
724 )
725 if (
726 published_status.get("good")
727 and isinstance(candidate_quality, (int, float))
728 and not force_replacing
729 ):
730 published_quality = published_status.get("quality_score")
731 if (
732 isinstance(published_quality, (int, float))
733 and candidate_quality < published_quality
734 ):
735 comparison_reasons.append(
736 f"candidate quality_score {candidate_quality} is lower than published good quality_score {published_quality}"
737 )
738
739 reasons: list[str] = []
740 fallback_errors: list[str] = []
741 if force_replacing:
742 if not args.force_reason.strip():
743 reasons.append("force-replace requires force_reason")
744 if not args.actor.strip():
745 reasons.append("force-replace requires actor")
746 if ai_status == "no-ai":
747 if not args.fallback_reason.strip():
748 reasons.append("fallback_reason is required for no-AI fallback candidates")
749 if not attempted_ai_paths:
750 reasons.append(
751 "attempted_ai_paths must record attempted AI paths for no-AI fallback candidates"
752 )
753 if args.publish_policy == "default":
754 if published_status.get("good"):
755 reasons.append(
756 "no-AI fallback is ineligible to replace an existing good AI-authored article by default"
757 )
758 else:
759 reasons.append(
760 "no-AI fallback requires explicit allow-no-ai-first-publish or force-replace policy"
761 )
762 elif args.publish_policy == "allow-no-ai-first-publish":
763 if published_status.get("good"):
764 reasons.append(
765 "allow-no-ai-first-publish cannot replace an existing good AI-authored article"
766 )
767 fallback_errors = fallback_quality_errors(args.summary, validation_passed)
768 elif args.publish_policy == "force-replace":
769 fallback_errors = fallback_quality_errors(args.summary, validation_passed)
770 reasons.extend(fallback_errors)
771
772 if not candidate_exists:
773 reasons.append(f"candidate summary missing: {args.summary}")
774 if not candidate_content_exists:
775 reasons.append(f"candidate content missing: {candidate_content}")
776 if not validation_passed:
777 reasons.append("analysis validation did not pass")
778 if ai_status not in {"ai", "no-ai"}:
779 reasons.append(f"analysis source is not AI-publishable: {analysis_source or 'unknown'}")
780 reasons.extend(preflight_reasons)
781 if not mode_allows_promotion:
782 reasons.append(f"run mode {args.run_mode} is non-publishing")
783 if ai_status == "ai" and model_status != "available":
784 reasons.append(f"analysis model is not AI-publishable: {args.analysis_model or 'unknown'}")
785 if not gates_passed:
786 reasons.extend(gate_reasons(gate_report))
787 reasons.extend(artifact_reasons)
788 reasons.extend(restore_reasons)
789 reasons.extend(comparison_reasons)
790 reasons.extend(synthesis_reasons)
791
792 eligible = (
793 candidate_exists
794 and candidate_content_exists
795 and validation_passed
796 and gates_passed
797 and not artifact_reasons
798 and not restore_reasons
799 and not comparison_reasons
800 and not preflight_reasons
801 and not synthesis_reasons
802 and mode_allows_promotion
803 and not reasons
804 and (
805 (ai_status == "ai" and model_status == "available")
806 or (
807 ai_status == "no-ai"
808 and args.publish_policy in {"allow-no-ai-first-publish", "force-replace"}
809 )
810 )
811 )
812 preserve_existing = bool(published_status.get("good") and not eligible)
813 decision = "promote" if eligible else "preserve" if preserve_existing else "block"
814
815 manifest = {
816 "schema_version": SCHEMA_VERSION,
817 "run_id": args.run_id,
818 "week": args.week,
819 "generated_at": args.current_datetime,
820 "run_mode": args.run_mode,
821 "source_refresh_policy": args.source_refresh_policy,
822 "run_started_at": args.current_datetime,
823 "restore": restore,
824 "candidate_summary_path": args.summary.as_posix(),
825 "candidate_content_path": candidate_content.as_posix(),
826 "promotion_eligible": eligible,
827 "candidate": {
828 "summary_path": args.summary.as_posix(),
829 "content_path": candidate_content.as_posix(),
830 "published_summary_path": args.published_summary.as_posix(),
831 "summary_sha256": sha256_file(args.summary),
832 "quality_score": candidate_quality,
833 "ai_status": ai_status,
834 },
835 "published": published_status,
836 "source_artifacts": source_artifacts,
837 "synthesis": {
838 "required": synthesis_required,
839 "status": synthesis_status,
840 "available": synthesis_status == "available",
841 "path": synthesis_file.as_posix() if synthesis_file else None,
842 "sha256": synthesis_sha256,
843 "reasons": synthesis_reasons,
844 },
845 "analysis": {
846 "ai_status": ai_status,
847 "source": analysis_source,
848 "model": args.analysis_model,
849 "model_status": model_status,
850 "provider": analysis_source,
851 "preflight": {
852 "path": args.preflight_report.as_posix() if args.preflight_report else None,
853 "degraded": preflight.get("degraded") if preflight else None,
854 "publish_eligible": preflight.get("publish_eligible") if preflight else None,
855 "prompt_tokens": preflight.get("prompt_tokens") if preflight else None,
856 "prompt_token_budget": preflight.get("prompt_token_budget") if preflight else None,
857 "prompt_checksum_sha256": preflight.get("prompt_checksum_sha256")
858 if preflight
859 else None,
860 "promotion_policy": preflight.get("promotion_policy") if preflight else None,
861 "degradation_reason": preflight.get("degradation_reason") if preflight else None,
862 },
863 "provenance": {
864 "run_id": args.run_id,
865 "current_datetime": args.current_datetime,
866 "authorship": "ai-authored"
867 if ai_status == "ai"
868 else "no-ai-fallback"
869 if ai_status == "no-ai"
870 else "unknown",
871 "provider": analysis_source,
872 "model": args.analysis_model,
873 "degraded": preflight.get("degraded") if preflight else None,
874 "fallback_reason": args.fallback_reason.strip() or None,
875 "attempted_ai_paths": attempted_ai_paths,
876 },
877 },
878 "ai_provenance": {
879 "source": analysis_source,
880 "model": args.analysis_model,
881 "degraded": ai_status != "ai" or model_status != "available",
882 "authorship": "ai-authored"
883 if ai_status == "ai"
884 else "no-ai-fallback"
885 if ai_status == "no-ai"
886 else "unknown",
887 "fallback_reason": args.fallback_reason.strip() or None,
888 "attempted_ai_paths": attempted_ai_paths,
889 },
890 "gate_results": {
891 name: isinstance(gate, dict) and gate.get("passed") is True
892 for name, gate in (
893 gate_report.get("gates") if isinstance(gate_report.get("gates"), dict) else {}
894 ).items()
895 },
896 "existing_article": {
897 "exists": published_status["exists"],
898 "path": published_status["path"],
899 "quality_score": published_status.get("quality_score"),
900 "provenance": (
901 "no-ai-fallback"
902 if published_status.get("ai_status") == "no-ai"
903 else "ai-authored-assumed"
904 if published_status["exists"]
905 else "none"
906 ),
907 "good_ai_authored": bool(published_status.get("good")),
908 },
909 "validation": {
910 "status": args.validation_status,
911 "gate_report": gate_report,
912 "quality_gates": [
913 {
914 "name": "analysis_gate",
915 "status": "passed" if gates_passed else "failed",
916 "source": analysis_source,
917 "report": gate_report.get("path"),
918 }
919 ],
920 },
921 "promotion": {
922 "eligible": eligible,
923 "decision": decision,
924 "mode": args.run_mode,
925 "source_refresh_policy": args.source_refresh_policy,
926 "policy": args.publish_policy,
927 "reasons": reasons,
928 },
929 "audit": {
930 "mode": args.publish_policy,
931 "actor": args.actor.strip() or None,
932 "reason": args.force_reason.strip() or None,
933 "source_artifact_count": len(source_artifacts),
934 "source_artifacts": source_artifacts,
935 },
936 "preservation": {
937 "preserve_existing": preserve_existing,
938 "preserved_summary_path": args.published_summary.as_posix()
939 if preserve_existing
940 else None,
941 "rejected_candidate_path": args.summary.as_posix()
942 if not eligible and candidate_exists
943 else None,
944 "reasons": reasons if preserve_existing else [],
945 },
946 }
947
948 args.output.parent.mkdir(parents=True, exist_ok=True)
949 args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
950 print(f"Publish manifest decision={manifest['promotion']['decision']} path={args.output}")
951 if preserve_existing:
952 print(f"Preserving published summary: {args.published_summary}")
953 if candidate_exists:
954 print(f"Rejected candidate summary: {args.summary}")
955 if reasons:
956 for reason in reasons:
957 print(f"- {reason}")
958 return 0
959
960
961 def assert_eligible(args: argparse.Namespace) -> int:
962 payload = load_json(args.manifest)
963 if payload is None:
964 raise SystemExit(f"Publish manifest is missing or malformed: {args.manifest}")
965 if not manifest_lives_under_allowed_promotion_root(args.manifest):
966 raise SystemExit(PROMOTION_MANIFEST_ROOT_ERROR)
967 if payload.get("schema_version") != SCHEMA_VERSION:
968 raise SystemExit(f"Unsupported publish manifest schema: {payload.get('schema_version')!r}")
969 if payload.get("run_mode") == "restore":
970 restore = payload.get("restore")
971 if (
972 not isinstance(restore, dict)
973 or restore.get("required") is not True
974 or restore.get("verified") is not True
975 or not restore.get("source_run_id")
976 or not restore.get("manifest_sha256")
977 or not isinstance(restore.get("source_artifact"), dict)
978 ):
979 raise SystemExit("Manifest lacks verified source-bound raw restore provenance.")
980 restored_files = restore.get("files")
981 if not isinstance(restored_files, list) or not restored_files:
982 raise SystemExit("Manifest lacks verified restored raw files.")
983 for entry in restored_files:
984 if not isinstance(entry, dict) or not isinstance(entry.get("original_path"), str):
985 raise SystemExit("Manifest restored raw file entry is malformed.")
986 _, path = publish_safety.require_raw_path(Path.cwd(), entry["original_path"])
987 if sha256_file(path) != entry.get("sha256"):
988 raise SystemExit(f"Restored raw input checksum mismatch: {path}")
989 analysis = payload.get("analysis")
990 if not isinstance(analysis, dict):
991 raise SystemExit("Manifest lacks publishable AI provenance.")
992 ai_status = analysis.get("ai_status")
993 if ai_status == "ai" and analysis.get("model_status") != "available":
994 raise SystemExit("Manifest lacks an available AI model.")
995 if ai_status == "ai":
996 preflight = analysis.get("preflight")
997 if not isinstance(preflight, dict) or preflight.get("publish_eligible") is not True:
998 raise SystemExit("Manifest lacks a publish-eligible Copilot preflight report.")
999 synthesis = payload.get("synthesis")
1000 if ai_status == "ai" and payload.get("run_mode") == "normal":
1001 if not isinstance(synthesis, dict) or synthesis.get("required") is not True:
1002 raise SystemExit(
1003 "Manifest lacks required synthesis provenance for normal-mode publication."
1004 )
1005 if synthesis.get("status") != "available":
1006 raise SystemExit(
1007 "Manifest blocks promotion: required synthesis is "
1008 f"{synthesis.get('status')!r} (missing/empty/failed), not available."
1009 )
1010 synthesis_path = synthesis.get("path")
1011 synthesis_sha256 = synthesis.get("sha256")
1012 if not (isinstance(synthesis_path, str) and synthesis_path.strip()) or not (
1013 isinstance(synthesis_sha256, str) and synthesis_sha256.strip()
1014 ):
1015 raise SystemExit(
1016 "Manifest claims synthesis is available but lacks authoritative "
1017 "provenance (path and sha256)."
1018 )
1019 validation = payload.get("validation")
1020 gate_report = validation.get("gate_report") if isinstance(validation, dict) else None
1021 if (
1022 not isinstance(gate_report, dict)
1023 or gate_report.get("present") is not True
1024 or gate_report.get("passed") is not True
1025 ):
1026 raise SystemExit("Manifest lacks a passing structured analysis gate report.")
1027 for gate_name in (
1028 "structural_schema",
1029 "ai_provenance",
1030 "evidence_citation",
1031 "editorial_quality",
1032 ):
1033 gate = (
1034 gate_report.get("gates", {}).get(gate_name)
1035 if isinstance(gate_report.get("gates"), dict)
1036 else None
1037 )
1038 if not isinstance(gate, dict) or gate.get("passed") is not True:
1039 raise SystemExit(f"Manifest analysis gate did not pass: {gate_name}")
1040 promotion_policy = (
1041 (payload.get("promotion") or {}).get("policy")
1042 if isinstance(payload.get("promotion"), dict)
1043 else None
1044 )
1045 if promotion_policy == "force-replace":
1046 audit = payload.get("audit")
1047 if not isinstance(audit, dict) or not audit.get("actor") or not audit.get("reason"):
1048 raise SystemExit("Force replacement requires actor and reason in manifest audit.")
1049 if ai_status == "no-ai":
1050 provenance = analysis.get("provenance") if isinstance(analysis, dict) else {}
1051 if not isinstance(provenance, dict) or provenance.get("authorship") != "no-ai-fallback":
1052 raise SystemExit("Manifest lacks no-AI fallback provenance.")
1053 if not provenance.get("fallback_reason"):
1054 raise SystemExit("Manifest lacks no-AI fallback reason.")
1055 if not provenance.get("attempted_ai_paths"):
1056 raise SystemExit("Manifest lacks attempted AI path audit.")
1057 elif ai_status != "ai":
1058 raise SystemExit("Manifest lacks publishable AI provenance.")
1059 promotion = payload.get("promotion")
1060 if (
1061 not isinstance(promotion, dict)
1062 or promotion.get("eligible") is not True
1063 or promotion.get("decision") != "promote"
1064 ):
1065 reasons = (
1066 promotion.get("reasons") if isinstance(promotion, dict) else ["missing promotion block"]
1067 )
1068 raise SystemExit(
1069 f"Manifest blocks promotion: {', '.join(str(reason) for reason in reasons)}"
1070 )
1071 candidate = payload.get("candidate")
1072 if not isinstance(candidate, dict) or not candidate.get("summary_sha256"):
1073 raise SystemExit("Manifest lacks candidate summary checksum.")
1074 source_artifacts = payload.get("source_artifacts")
1075 if not isinstance(source_artifacts, list) or not source_artifacts:
1076 raise SystemExit("Manifest lacks source artifact provenance.")
1077 for entry in source_artifacts:
1078 if not isinstance(entry, dict) or not entry.get("sha256"):
1079 raise SystemExit("Manifest source artifact is missing a checksum.")
1080 provenance = entry.get("provenance")
1081 if not isinstance(provenance, dict) or provenance.get("sha256") != entry.get("sha256"):
1082 raise SystemExit("Manifest source artifact is missing auditable provenance.")
1083 if not isinstance(provenance.get("same_day_reuse"), dict):
1084 raise SystemExit("Manifest source artifact reuse provenance is missing.")
1085 freshness = entry.get("freshness", {})
1086 if isinstance(freshness, dict) and freshness.get("status") == "stale":
1087 raise SystemExit(f"Manifest source artifact is stale: {entry.get('path')}")
1088 print(f"Manifest permits promotion: {args.manifest}")
1089 return 0
1090
1091
1092 def main(argv: list[str] | None = None) -> int:
1093 args = parse_args(argv)
1094 if args.command == "create":
1095 return create_manifest(args)
1096 if args.command == "assert-eligible":
1097 return assert_eligible(args)
1098 raise AssertionError(args.command)
1099
1100
1101 if __name__ == "__main__":
1102 raise SystemExit(main())