@hunter-harness/workflow-harness 0.2.27 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  4. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
  18. package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  26. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  27. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  30. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
  40. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
  48. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
  49. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  52. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
  60. package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
  62. package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
  63. package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
  64. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
  65. package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
  66. package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
  70. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
  71. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  74. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
  83. package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
  84. package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
  85. package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
  86. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
  87. package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
  88. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  93. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  94. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  97. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
  106. package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
  108. package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
  110. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
  111. package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
  112. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
  113. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  114. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  116. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  117. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  118. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  121. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
  130. package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
  132. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
  133. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
  134. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
  135. package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
  136. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
  137. package/harness/bundles/java/codex/.harness-build.json +1 -1
  138. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
  141. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
  142. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  145. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
  154. package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
  155. package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
  156. package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
  157. package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
  158. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
  159. package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
  160. package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
  161. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  162. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  164. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
  165. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
  166. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  169. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
  178. package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
  179. package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
  180. package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
  181. package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
  182. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
  183. package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
  184. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
  185. package/harness/manifests/general/claude-code.json +26 -22
  186. package/harness/manifests/general/codebuddy.json +26 -22
  187. package/harness/manifests/general/codex.json +26 -22
  188. package/harness/manifests/general/cursor.json +26 -22
  189. package/harness/manifests/java/claude-code.json +28 -24
  190. package/harness/manifests/java/codebuddy.json +28 -24
  191. package/harness/manifests/java/codex.json +28 -24
  192. package/harness/manifests/java/cursor.json +28 -24
  193. package/hunter-workflow-family.json +2 -2
  194. package/package.json +1 -1
@@ -16,12 +16,14 @@ from __future__ import annotations
16
16
  import argparse
17
17
  import datetime as dt
18
18
  import hashlib
19
+ import io
19
20
  import json
20
21
  import os
21
22
  import re
22
23
  import shutil
23
24
  import subprocess
24
25
  import sys
26
+ import tarfile
25
27
  import tempfile
26
28
  import uuid
27
29
  from pathlib import Path
@@ -68,8 +70,10 @@ if str(SCRIPTS_DIR) not in sys.path:
68
70
  sys.path.insert(0, str(SCRIPTS_DIR))
69
71
 
70
72
  import harness_events as he # noqa: E402
73
+ import harness_gate as hgate # noqa: E402
71
74
  import harness_ledger as hl # noqa: E402
72
75
  import harness_paths as hp # noqa: E402
76
+ import harness_phase as hphase # noqa: E402
73
77
  import harness_review as hr # noqa: E402
74
78
 
75
79
 
@@ -331,6 +335,80 @@ def load_ci_metrics(change_dir: Path) -> tuple[dict[str, Any] | None, str | None
331
335
  return None, None
332
336
 
333
337
 
338
+ def build_remote_cost_summary(metrics: dict[str, Any] | None) -> dict[str, Any]:
339
+ """Aggregate typed remote runner/storage costs by candidate identity."""
340
+ value = metrics if isinstance(metrics, dict) else {}
341
+ raw_runs = value.get("runs")
342
+ if isinstance(raw_runs, list):
343
+ runs = [item for item in raw_runs if isinstance(item, dict)]
344
+ elif any(
345
+ field in value
346
+ for field in (
347
+ "runnerMinutes",
348
+ "queueWaitMs",
349
+ "artifactBytes",
350
+ "duplicateRunReason",
351
+ )
352
+ ):
353
+ runs = [value]
354
+ else:
355
+ runs = []
356
+
357
+ candidates: dict[str, dict[str, Any]] = {}
358
+ for run in runs:
359
+ subject = run.get("subject")
360
+ subject = subject if isinstance(subject, dict) else {}
361
+ candidate_id = str(
362
+ run.get("candidateId")
363
+ or subject.get("candidateId")
364
+ or subject.get("productCommit")
365
+ or "unattributed"
366
+ ).strip()
367
+ row = candidates.setdefault(
368
+ candidate_id,
369
+ {
370
+ "candidateId": candidate_id,
371
+ "runCount": 0,
372
+ "runnerMinutes": 0.0,
373
+ "queueWaitMs": 0,
374
+ "artifactBytes": 0,
375
+ "duplicateRunCount": 0,
376
+ "duplicateRunReasons": [],
377
+ },
378
+ )
379
+ row["runCount"] += 1
380
+ row["runnerMinutes"] += max(0.0, float(run.get("runnerMinutes") or 0))
381
+ row["queueWaitMs"] += max(0, int(run.get("queueWaitMs") or 0))
382
+ row["artifactBytes"] += max(0, int(run.get("artifactBytes") or 0))
383
+ reason = str(run.get("duplicateRunReason") or "").strip()
384
+ if reason:
385
+ row["duplicateRunCount"] += 1
386
+ if reason not in row["duplicateRunReasons"]:
387
+ row["duplicateRunReasons"].append(reason)
388
+
389
+ items = sorted(candidates.values(), key=lambda item: item["candidateId"])
390
+ for item in items:
391
+ item["runnerMinutes"] = round(float(item["runnerMinutes"]), 3)
392
+ totals = {
393
+ "runCount": sum(int(item["runCount"]) for item in items),
394
+ "runnerMinutes": round(
395
+ sum(float(item["runnerMinutes"]) for item in items),
396
+ 3,
397
+ ),
398
+ "queueWaitMs": sum(int(item["queueWaitMs"]) for item in items),
399
+ "artifactBytes": sum(int(item["artifactBytes"]) for item in items),
400
+ "duplicateRunCount": sum(
401
+ int(item["duplicateRunCount"]) for item in items
402
+ ),
403
+ }
404
+ return {
405
+ "schemaVersion": 1,
406
+ "available": bool(runs),
407
+ "candidates": items,
408
+ "totals": totals,
409
+ }
410
+
411
+
334
412
  def _legacy_candidate_path(change_dir: Path) -> Path | None:
335
413
  for relative in (
336
414
  "evidence/product-candidate-ci.json",
@@ -401,7 +479,14 @@ def load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
401
479
 
402
480
 
403
481
  def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
404
- """Evaluate product-candidate evidence independently of CI availability."""
482
+ """Evaluate whether candidate evidence is release-capable.
483
+
484
+ Evidence validity and release authority are deliberately separate. A
485
+ ``remote-claimed`` receipt remains useful for audit/migration, but can
486
+ never authorize release. Local evidence requires an explicit project
487
+ opt-in; remote-required projects require a subject/environment-bound,
488
+ provider-verified immutable run.
489
+ """
405
490
  evidence = load_product_candidate_ci(change_dir)
406
491
  if evidence is None:
407
492
  return {
@@ -412,6 +497,8 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
412
497
  "(need evidence/product-candidate-ci.json with conclusion=success, "
413
498
  "runUrl, commit)"
414
499
  ),
500
+ "releaseCapable": False,
501
+ "evidenceValid": False,
415
502
  "evidence": None,
416
503
  }
417
504
 
@@ -425,6 +512,7 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
425
512
  verification = verification if isinstance(verification, dict) else {}
426
513
  product_commit = str(subject.get("productCommit") or "").strip()
427
514
  product_tree_hash = str(subject.get("productTreeHash") or "").strip()
515
+ environment_hash = str(subject.get("environmentHash") or "").strip()
428
516
  command_set_hash = str(verification.get("commandSetHash") or "").strip()
429
517
  ledger_hash = str(verification.get("ledgerHash") or "").strip()
430
518
  common_required_fields = {
@@ -433,11 +521,6 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
433
521
  "subject.productCommit": product_commit,
434
522
  "subject.productTreeHash": product_tree_hash,
435
523
  }
436
- allowed_assurance = {
437
- "local-reproducible": 1,
438
- "remote-claimed": 2,
439
- "remote-attested": 3,
440
- }
441
524
  policy = load_gate_policy(change_dir) or {}
442
525
  candidate_policy = policy.get("candidateVerification")
443
526
  candidate_policy = (
@@ -446,8 +529,16 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
446
529
  minimum_assurance = str(
447
530
  candidate_policy.get("minimumAssurance") or "local-reproducible"
448
531
  ).strip()
449
- if minimum_assurance not in allowed_assurance:
450
- minimum_assurance = "local-reproducible"
532
+ remote_required = bool(candidate_policy.get("remoteRequired")) or (
533
+ minimum_assurance == "remote-attested"
534
+ )
535
+ allow_local_release = bool(candidate_policy.get("allowLocalRelease"))
536
+ declared_capabilities = {
537
+ str(item).strip()
538
+ for item in (evidence.get("capabilities") or [])
539
+ if isinstance(item, str) and item.strip()
540
+ }
541
+ capabilities = set(declared_capabilities)
451
542
  if (
452
543
  provider == "local-harness"
453
544
  and _remote_ci_history_present(change_dir)
@@ -461,37 +552,11 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
461
552
  "candidateVerification.allowLocalFallback=true"
462
553
  ),
463
554
  "assurance": assurance,
555
+ "releaseCapable": False,
556
+ "evidenceValid": False,
464
557
  "evidence": evidence,
465
558
  }
466
- if provider == "local-harness":
467
- local_required = {
468
- "verification.commandSetHash": command_set_hash,
469
- "verification.ledgerHash": ledger_hash,
470
- "verification.toolchainHashes": verification.get("toolchainHashes"),
471
- "verification.environmentHashes": verification.get("environmentHashes"),
472
- "verification.dependencyHashes": verification.get("dependencyHashes"),
473
- "verification.logHashes": verification.get("logHashes"),
474
- }
475
- required_fields = {**common_required_fields, **local_required}
476
- elif provider == "remote-ci":
477
- attestation = evidence.get("attestation")
478
- attestation = attestation if isinstance(attestation, dict) else {}
479
- if assurance == "remote-attested":
480
- remote_identity_name = "verification.attestationDigest"
481
- remote_identity = verification.get("attestationDigest")
482
- else:
483
- remote_identity_name = "verification.remoteIdentity"
484
- remote_identity = (
485
- verification.get("legacyEvidenceHash") or ledger_hash
486
- )
487
- required_fields = {
488
- **common_required_fields,
489
- "attestation.url": attestation.get("url"),
490
- remote_identity_name: remote_identity,
491
- }
492
- else:
493
- required_fields = common_required_fields
494
- missing = [name for name, value in required_fields.items() if not value]
559
+
495
560
  if conclusion not in {
496
561
  "success",
497
562
  "successful",
@@ -508,36 +573,225 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
508
573
  f"provider={provider or 'unknown'}"
509
574
  ),
510
575
  "assurance": assurance,
576
+ "releaseCapable": False,
577
+ "evidenceValid": False,
511
578
  "evidence": evidence,
512
579
  }
513
- if missing or assurance not in allowed_assurance:
514
- detail = ", ".join(missing) if missing else f"assurance={assurance}"
580
+
581
+ if assurance == "remote-claimed":
582
+ attestation = evidence.get("attestation")
583
+ attestation = attestation if isinstance(attestation, dict) else {}
584
+ required_fields = {
585
+ **common_required_fields,
586
+ "attestation.url": attestation.get("url"),
587
+ "verification.legacyEvidenceHash": (
588
+ verification.get("legacyEvidenceHash") or ledger_hash
589
+ ),
590
+ }
591
+ missing = [
592
+ name for name, value in required_fields.items() if not value
593
+ ]
594
+ if missing:
595
+ return {
596
+ "ok": False,
597
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
598
+ "message": (
599
+ "remote claim is incomplete: " + ", ".join(missing)
600
+ ),
601
+ "assurance": assurance,
602
+ "releaseCapable": False,
603
+ "evidenceValid": False,
604
+ "capabilities": sorted(capabilities),
605
+ "evidence": evidence,
606
+ }
515
607
  return {
516
608
  "ok": False,
517
- "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
518
- "message": f"candidate evidence missing or invalid: {detail}",
609
+ "code": "PRODUCT_CANDIDATE_RECORD_ONLY",
610
+ "message": (
611
+ "remote-claimed evidence is audit-only and cannot authorize release"
612
+ ),
613
+ "assurance": assurance,
614
+ "releaseCapable": False,
615
+ "evidenceValid": True,
616
+ "recordOnly": True,
617
+ "capabilities": sorted(capabilities),
618
+ "evidence": evidence,
619
+ }
620
+
621
+ if assurance == "local-reproducible" and provider == "local-harness":
622
+ local_required = {
623
+ "verification.commandSetHash": command_set_hash,
624
+ "verification.ledgerHash": ledger_hash,
625
+ "verification.toolchainHashes": verification.get("toolchainHashes"),
626
+ "verification.environmentHashes": verification.get("environmentHashes"),
627
+ "verification.dependencyHashes": verification.get("dependencyHashes"),
628
+ "verification.logHashes": verification.get("logHashes"),
629
+ }
630
+ required_fields = {**common_required_fields, **local_required}
631
+ missing = [
632
+ name for name, value in required_fields.items() if not value
633
+ ]
634
+ if missing:
635
+ return {
636
+ "ok": False,
637
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
638
+ "message": (
639
+ "candidate evidence missing or invalid: "
640
+ + ", ".join(missing)
641
+ ),
642
+ "assurance": assurance,
643
+ "releaseCapable": False,
644
+ "evidenceValid": False,
645
+ "evidence": evidence,
646
+ }
647
+ capabilities.update(
648
+ {
649
+ "subject-bound",
650
+ "locally-reproducible",
651
+ "environment-bound",
652
+ }
653
+ )
654
+ if remote_required:
655
+ return {
656
+ "ok": False,
657
+ "code": "PROJECT_RELEASE_POLICY_BLOCKED",
658
+ "message": (
659
+ "project requires remote-attested candidate evidence"
660
+ ),
661
+ "assurance": assurance,
662
+ "releaseCapable": False,
663
+ "evidenceValid": True,
664
+ "capabilities": sorted(capabilities),
665
+ "evidence": evidence,
666
+ }
667
+ if not allow_local_release:
668
+ return {
669
+ "ok": False,
670
+ "code": "PROJECT_RELEASE_POLICY_BLOCKED",
671
+ "message": (
672
+ "local-reproducible evidence requires explicit "
673
+ "candidateVerification.allowLocalRelease=true"
674
+ ),
675
+ "assurance": assurance,
676
+ "releaseCapable": False,
677
+ "evidenceValid": True,
678
+ "capabilities": sorted(capabilities),
679
+ "evidence": evidence,
680
+ }
681
+ return {
682
+ "ok": True,
683
+ "code": "PRODUCT_CANDIDATE_VERIFIED",
684
+ "message": (
685
+ "local reproducible candidate explicitly authorized by project policy "
686
+ f"commit={product_commit}"
687
+ ),
519
688
  "assurance": assurance,
689
+ "releaseCapable": True,
690
+ "evidenceValid": True,
691
+ "capabilities": sorted(capabilities),
520
692
  "evidence": evidence,
521
693
  }
522
- if allowed_assurance[assurance] < allowed_assurance[minimum_assurance]:
694
+
695
+ if assurance == "remote-attested" and provider == "remote-ci":
696
+ attestation = evidence.get("attestation")
697
+ attestation = attestation if isinstance(attestation, dict) else {}
698
+ required_fields = {
699
+ **common_required_fields,
700
+ "subject.environmentHash": environment_hash,
701
+ "attestation.url": attestation.get("url"),
702
+ "attestation.providerRunDigest": attestation.get(
703
+ "providerRunDigest"
704
+ ),
705
+ "verification.attestationDigest": verification.get(
706
+ "attestationDigest"
707
+ ),
708
+ }
709
+ missing = [
710
+ name for name, value in required_fields.items() if not value
711
+ ]
712
+ if missing:
713
+ return {
714
+ "ok": False,
715
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
716
+ "message": (
717
+ "candidate evidence missing or invalid: "
718
+ + ", ".join(missing)
719
+ ),
720
+ "assurance": assurance,
721
+ "releaseCapable": False,
722
+ "evidenceValid": False,
723
+ "capabilities": sorted(capabilities),
724
+ "evidence": evidence,
725
+ }
726
+ capabilities.update(
727
+ {
728
+ "subject-bound",
729
+ "provider-verified",
730
+ "immutable-run",
731
+ "environment-bound",
732
+ }
733
+ )
734
+ required_capabilities = candidate_policy.get(
735
+ "requiredCapabilities"
736
+ )
737
+ if not isinstance(required_capabilities, list):
738
+ required_capabilities = (
739
+ [
740
+ "subject-bound",
741
+ "provider-verified",
742
+ "immutable-run",
743
+ "environment-bound",
744
+ ]
745
+ if remote_required
746
+ else []
747
+ )
748
+ missing_capabilities = sorted(
749
+ {
750
+ str(item).strip()
751
+ for item in required_capabilities
752
+ if isinstance(item, str) and item.strip()
753
+ }
754
+ - capabilities
755
+ )
756
+ if missing_capabilities:
757
+ return {
758
+ "ok": False,
759
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
760
+ "message": (
761
+ "candidate capabilities missing: "
762
+ + ", ".join(missing_capabilities)
763
+ ),
764
+ "assurance": assurance,
765
+ "releaseCapable": False,
766
+ "evidenceValid": False,
767
+ "capabilities": sorted(capabilities),
768
+ "evidence": evidence,
769
+ }
523
770
  return {
524
- "ok": False,
525
- "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
771
+ "ok": True,
772
+ "code": "PRODUCT_CANDIDATE_VERIFIED",
526
773
  "message": (
527
- f"candidate assurance={assurance} is below required "
528
- f"minimumAssurance={minimum_assurance}"
774
+ "remote candidate attested "
775
+ f"provider={provider} commit={product_commit}"
529
776
  ),
530
777
  "assurance": assurance,
778
+ "releaseCapable": True,
779
+ "evidenceValid": True,
780
+ "capabilities": sorted(capabilities),
531
781
  "evidence": evidence,
532
782
  }
783
+
533
784
  return {
534
- "ok": True,
535
- "code": "PRODUCT_CANDIDATE_VERIFIED",
785
+ "ok": False,
786
+ "code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
536
787
  "message": (
537
- f"product candidate verified provider={provider} "
538
- f"assurance={assurance} commit={product_commit}"
788
+ "candidate provider/assurance combination is invalid: "
789
+ f"provider={provider or 'unknown'} assurance={assurance or 'unknown'}"
539
790
  ),
540
791
  "assurance": assurance,
792
+ "releaseCapable": False,
793
+ "evidenceValid": False,
794
+ "capabilities": sorted(capabilities),
541
795
  "evidence": evidence,
542
796
  }
543
797
 
@@ -556,12 +810,21 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
556
810
  "product candidate CI conclusion is success but missing "
557
811
  f"runUrl={run_url or 'empty'} commit={commit or 'empty'}"
558
812
  ),
813
+ "releaseCapable": False,
814
+ "evidenceValid": False,
559
815
  "evidence": evidence,
560
816
  }
561
817
  return {
562
- "ok": True,
563
- "code": "PRODUCT_CI_GREEN",
564
- "message": f"product candidate CI green commit={commit} url={run_url}",
818
+ "ok": False,
819
+ "code": "PRODUCT_CANDIDATE_RECORD_ONLY",
820
+ "message": (
821
+ "legacy CI success is a remote claim only; migrate/attest before release "
822
+ f"commit={commit} url={run_url}"
823
+ ),
824
+ "releaseCapable": False,
825
+ "evidenceValid": True,
826
+ "recordOnly": True,
827
+ "assurance": "remote-claimed",
565
828
  "evidence": evidence,
566
829
  }
567
830
  return {
@@ -571,6 +834,8 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
571
834
  f"product candidate CI conclusion={conclusion or 'unknown'} "
572
835
  f"commit={commit or 'unknown'} runUrl={run_url or 'unknown'}"
573
836
  ),
837
+ "releaseCapable": False,
838
+ "evidenceValid": False,
574
839
  "evidence": evidence,
575
840
  }
576
841
 
@@ -764,7 +1029,9 @@ def certify_local_candidate(
764
1029
  if not product_commit:
765
1030
  raise ValueError("product commit unavailable")
766
1031
 
767
- detail = compute_product_tree_hash_detail(project_root)
1032
+ detail = compute_product_tree_hash_for_commit(
1033
+ project_root, product_commit
1034
+ )
768
1035
  if detail.get("truncated"):
769
1036
  raise ValueError(
770
1037
  "product tree hash truncated; narrow the project or raise the explicit limit"
@@ -817,16 +1084,8 @@ def certify_local_candidate(
817
1084
 
818
1085
 
819
1086
  PRODUCT_TREE_HASH_FILE_LIMIT = 20_000
820
-
821
-
822
- def compute_product_tree_hash_detail(
823
- project: Path,
824
- *,
825
- file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
826
- ) -> dict[str, Any]:
827
- """Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
828
- project = project.resolve()
829
- skip_dirs = {
1087
+ PRODUCT_TREE_EXCLUDED_PARTS = frozenset(
1088
+ {
830
1089
  ".harness",
831
1090
  ".git",
832
1091
  ".worktrees",
@@ -838,17 +1097,36 @@ def compute_product_tree_hash_detail(
838
1097
  ".venv",
839
1098
  "venv",
840
1099
  }
1100
+ )
1101
+
1102
+
1103
+ def _product_path_included(relative_path: str) -> bool:
1104
+ parts = {part for part in relative_path.replace("\\", "/").split("/") if part}
1105
+ return not bool(parts & PRODUCT_TREE_EXCLUDED_PARTS)
1106
+
1107
+
1108
+ def compute_product_tree_hash_detail(
1109
+ project: Path,
1110
+ *,
1111
+ file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
1112
+ ) -> dict[str, Any]:
1113
+ """Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
1114
+ project = project.resolve()
841
1115
  limit = max(1, int(file_limit))
842
1116
  lines: list[str] = []
843
1117
  truncated = False
844
1118
  for root, dirs, files in os.walk(project, onerror=lambda _exc: None):
845
- dirs[:] = [name for name in dirs if name not in skip_dirs]
1119
+ dirs[:] = sorted(
1120
+ name for name in dirs if name not in PRODUCT_TREE_EXCLUDED_PARTS
1121
+ )
846
1122
  for name in sorted(files):
847
1123
  path = Path(root) / name
848
1124
  try:
849
1125
  rel = path.relative_to(project).as_posix()
850
1126
  except ValueError:
851
1127
  continue
1128
+ if not _product_path_included(rel):
1129
+ continue
852
1130
  try:
853
1131
  digest = sha256_file(path)
854
1132
  except OSError:
@@ -859,12 +1137,15 @@ def compute_product_tree_hash_detail(
859
1137
  break
860
1138
  if truncated:
861
1139
  break
1140
+ lines.sort()
862
1141
  digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
863
1142
  return {
864
1143
  "hash": digest,
865
1144
  "truncated": truncated,
866
1145
  "fileCount": len(lines),
867
1146
  "limit": limit,
1147
+ "source": "working-tree",
1148
+ "algorithm": "sha256-path-content-v1",
868
1149
  }
869
1150
 
870
1151
 
@@ -877,6 +1158,82 @@ def compute_product_tree_hash(
877
1158
  return str(compute_product_tree_hash_detail(project, file_limit=file_limit)["hash"])
878
1159
 
879
1160
 
1161
+ def compute_product_tree_hash_for_commit(
1162
+ project: Path,
1163
+ product_commit: str,
1164
+ *,
1165
+ file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
1166
+ ) -> dict[str, Any]:
1167
+ """Hash immutable Git commit contents using the product exclusion rules."""
1168
+ project = project.resolve()
1169
+ commit = str(product_commit or "").strip()
1170
+ if not commit:
1171
+ raise ValueError("product commit is required")
1172
+ exists, resolved_commit, error = git_run(
1173
+ project, "rev-parse", "--verify", f"{commit}^{{commit}}"
1174
+ )
1175
+ if exists != 0 or not resolved_commit:
1176
+ raise ValueError(
1177
+ f"product commit not found: {commit} ({error or 'git rev-parse failed'})"
1178
+ )
1179
+ try:
1180
+ archive = subprocess.run(
1181
+ [
1182
+ "git",
1183
+ "-C",
1184
+ str(project),
1185
+ "archive",
1186
+ "--format=tar",
1187
+ resolved_commit,
1188
+ ],
1189
+ capture_output=True,
1190
+ timeout=120,
1191
+ check=False,
1192
+ )
1193
+ except (OSError, subprocess.TimeoutExpired) as exc:
1194
+ raise ValueError(f"could not read product commit tree: {exc}") from exc
1195
+ if archive.returncode != 0:
1196
+ detail = archive.stderr.decode("utf-8", errors="replace").strip()
1197
+ raise ValueError(
1198
+ f"could not read product commit tree {resolved_commit}: {detail}"
1199
+ )
1200
+
1201
+ limit = max(1, int(file_limit))
1202
+ lines: list[str] = []
1203
+ truncated = False
1204
+ with tarfile.open(fileobj=io.BytesIO(archive.stdout), mode="r:") as stream:
1205
+ members = sorted(stream.getmembers(), key=lambda item: item.name)
1206
+ for member in members:
1207
+ relative = member.name.rstrip("/")
1208
+ if not relative or not _product_path_included(relative):
1209
+ continue
1210
+ if member.isfile():
1211
+ extracted = stream.extractfile(member)
1212
+ if extracted is None:
1213
+ continue
1214
+ content = extracted.read()
1215
+ elif member.issym() or member.islnk():
1216
+ content = member.linkname.encode("utf-8")
1217
+ else:
1218
+ continue
1219
+ lines.append(
1220
+ f"{relative}:{hashlib.sha256(content).hexdigest()}"
1221
+ )
1222
+ if len(lines) >= limit:
1223
+ truncated = True
1224
+ break
1225
+ digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
1226
+ return {
1227
+ "hash": digest,
1228
+ "truncated": truncated,
1229
+ "fileCount": len(lines),
1230
+ "limit": limit,
1231
+ "source": "git-commit-tree",
1232
+ "algorithm": "sha256-path-content-v1",
1233
+ "productCommit": resolved_commit,
1234
+ }
1235
+
1236
+
880
1237
  def resolve_product_archive_identity(
881
1238
  change_dir: Path,
882
1239
  *,
@@ -918,7 +1275,16 @@ def resolve_product_archive_identity(
918
1275
  or release_tip
919
1276
  or product
920
1277
  )
921
- tree = str(ledger.get("productTreeHash") or "")
1278
+ tree = str(
1279
+ ledger.get("productTreeHash")
1280
+ or candidate_subject.get("productTreeHash")
1281
+ or ""
1282
+ )
1283
+ environment_hash = str(
1284
+ candidate_subject.get("environmentHash")
1285
+ or ledger.get("environmentHash")
1286
+ or ""
1287
+ ).strip()
922
1288
  under_harness = False
923
1289
  try:
924
1290
  change_dir.relative_to(project_root / ".harness")
@@ -933,9 +1299,11 @@ def resolve_product_archive_identity(
933
1299
  if not tree:
934
1300
  if under_harness:
935
1301
  try:
936
- tree_meta = compute_product_tree_hash_detail(project_root)
937
- tree = str(tree_meta["hash"])
938
- except OSError:
1302
+ tree_meta = compute_product_tree_hash_for_commit(
1303
+ project_root, str(product)
1304
+ )
1305
+ tree = f"sha256:{tree_meta['hash']}"
1306
+ except (OSError, ValueError):
939
1307
  tree = hashlib.sha256(b"").hexdigest()
940
1308
  else:
941
1309
  # Bare temp fixtures are not real project roots — avoid scanning up-tree.
@@ -946,24 +1314,16 @@ def resolve_product_archive_identity(
946
1314
  "releaseTipHash": release_tip,
947
1315
  "productTreeHash": str(tree),
948
1316
  "archiveCommit": str(archive),
1317
+ "environmentHash": environment_hash,
949
1318
  "productTreeHashTruncated": bool(tree_meta.get("truncated")),
950
1319
  "productTreeHashFileCount": int(tree_meta.get("fileCount") or 0),
951
1320
  }
952
- expected_tree = tree
953
- if under_harness:
954
- try:
955
- expected_meta = compute_product_tree_hash_detail(project_root)
956
- expected_tree = str(expected_meta["hash"])
957
- identity["productTreeHashTruncated"] = bool(expected_meta.get("truncated"))
958
- identity["productTreeHashFileCount"] = int(expected_meta.get("fileCount") or 0)
959
- except OSError:
960
- expected_tree = tree
961
1321
  identity["validation"] = validate_product_identity(
962
1322
  product_commit=identity["productCommit"],
963
1323
  product_tree_hash=identity["productTreeHash"],
964
1324
  archive_commit=identity["archiveCommit"] or "unknown",
965
1325
  project=project_root if under_harness else None,
966
- expected_tree_hash=expected_tree if under_harness else tree,
1326
+ expected_tree_hash=tree if not under_harness else None,
967
1327
  )
968
1328
  return identity
969
1329
 
@@ -984,9 +1344,50 @@ def validate_product_identity(
984
1344
  "message": "productCommit, productTreeHash, and archiveCommit are required",
985
1345
  }
986
1346
  expected = expected_tree_hash
987
- if expected is None and project is not None:
988
- expected = compute_product_tree_hash(project)
989
- if expected is not None and str(product_tree_hash) != str(expected):
1347
+ source = "provided-expected"
1348
+ resolved_product_commit = product_commit
1349
+ if project is not None and expected_tree_hash is None:
1350
+ code, resolved, error = git_run(
1351
+ project, "rev-parse", "--verify", f"{product_commit}^{{commit}}"
1352
+ )
1353
+ if code != 0 or not resolved:
1354
+ return {
1355
+ "ok": False,
1356
+ "code": "PRODUCT_COMMIT_NOT_FOUND",
1357
+ "message": (
1358
+ f"productCommit={product_commit} is not a reachable Git commit: "
1359
+ f"{error or 'not found'}"
1360
+ ),
1361
+ "productCommit": product_commit,
1362
+ }
1363
+ resolved_product_commit = resolved
1364
+ try:
1365
+ detail = compute_product_tree_hash_for_commit(
1366
+ project, resolved_product_commit
1367
+ )
1368
+ except ValueError as exc:
1369
+ return {
1370
+ "ok": False,
1371
+ "code": "PRODUCT_TREE_UNAVAILABLE",
1372
+ "message": str(exc),
1373
+ "productCommit": resolved_product_commit,
1374
+ }
1375
+ if detail.get("truncated"):
1376
+ return {
1377
+ "ok": False,
1378
+ "code": "PRODUCT_TREE_HASH_TRUNCATED",
1379
+ "message": (
1380
+ f"product tree exceeds limit={detail.get('limit')} "
1381
+ f"for productCommit={resolved_product_commit}"
1382
+ ),
1383
+ }
1384
+ expected = f"sha256:{detail['hash']}"
1385
+ source = "git-commit-tree"
1386
+
1387
+ normalize_hash = lambda value: str(value or "").strip().removeprefix("sha256:")
1388
+ if expected is not None and normalize_hash(product_tree_hash) != normalize_hash(
1389
+ expected
1390
+ ):
990
1391
  return {
991
1392
  "ok": False,
992
1393
  "code": "PRODUCT_TREE_HASH_MISMATCH",
@@ -997,12 +1398,48 @@ def validate_product_identity(
997
1398
  "expected": expected,
998
1399
  "actual": product_tree_hash,
999
1400
  }
1401
+
1402
+ if project is not None:
1403
+ code, resolved_archive, error = git_run(
1404
+ project, "rev-parse", "--verify", f"{archive_commit}^{{commit}}"
1405
+ )
1406
+ if code != 0 or not resolved_archive:
1407
+ return {
1408
+ "ok": False,
1409
+ "code": "ARCHIVE_COMMIT_NOT_FOUND",
1410
+ "message": (
1411
+ f"archiveCommit={archive_commit} is not a Git commit: "
1412
+ f"{error or 'not found'}"
1413
+ ),
1414
+ "archiveCommit": archive_commit,
1415
+ }
1416
+ ancestor, _out, ancestry_error = git_run(
1417
+ project,
1418
+ "merge-base",
1419
+ "--is-ancestor",
1420
+ resolved_product_commit,
1421
+ resolved_archive,
1422
+ )
1423
+ if ancestor != 0:
1424
+ return {
1425
+ "ok": False,
1426
+ "code": "ARCHIVE_COMMIT_UNRELATED",
1427
+ "message": (
1428
+ f"archiveCommit={resolved_archive} does not descend from "
1429
+ f"productCommit={resolved_product_commit}: "
1430
+ f"{ancestry_error or 'unrelated history'}"
1431
+ ),
1432
+ "productCommit": resolved_product_commit,
1433
+ "archiveCommit": resolved_archive,
1434
+ }
1435
+ archive_commit = resolved_archive
1000
1436
  return {
1001
1437
  "ok": True,
1002
1438
  "code": "PRODUCT_IDENTITY_OK",
1003
- "productCommit": product_commit,
1439
+ "productCommit": resolved_product_commit,
1004
1440
  "productTreeHash": product_tree_hash,
1005
1441
  "archiveCommit": archive_commit,
1442
+ "source": source,
1006
1443
  }
1007
1444
 
1008
1445
 
@@ -1042,48 +1479,204 @@ def build_workflow_timing(
1042
1479
  *,
1043
1480
  report_cutoff_at: str | None = None,
1044
1481
  ) -> dict[str, Any]:
1045
- """Formal multi-semantic timing object (IA-2 / IA-6)."""
1046
- stamps = [e.get("timestamp") for e in events if e.get("timestamp")]
1047
- started = str(stamps[0]) if stamps else None
1048
- cutoff = report_cutoff_at or (str(stamps[-1]) if stamps else None)
1482
+ """Build typed attempts, sessions, and a wall-clock-conserving breakdown."""
1483
+ parsed_stamps = [
1484
+ he.parse_timestamp(event.get("timestamp"))
1485
+ for event in events
1486
+ if event.get("timestamp")
1487
+ ]
1488
+ parsed_stamps = [stamp for stamp in parsed_stamps if stamp is not None]
1489
+ started_dt = min(parsed_stamps) if parsed_stamps else None
1490
+ cutoff_text = report_cutoff_at or (
1491
+ max(parsed_stamps).isoformat() if parsed_stamps else None
1492
+ )
1493
+ cutoff_dt = he.parse_timestamp(cutoff_text) if cutoff_text else None
1494
+ started = started_dt.isoformat() if started_dt else None
1495
+ cutoff = cutoff_dt.isoformat() if cutoff_dt else cutoff_text
1496
+
1497
+ def clipped_interval(
1498
+ start_value: Any,
1499
+ end_value: Any,
1500
+ ) -> tuple[Any, Any] | None:
1501
+ start = (
1502
+ start_value
1503
+ if hasattr(start_value, "tzinfo")
1504
+ else he.parse_timestamp(start_value)
1505
+ )
1506
+ end = (
1507
+ end_value
1508
+ if hasattr(end_value, "tzinfo")
1509
+ else he.parse_timestamp(end_value)
1510
+ )
1511
+ if start is None or end is None or end <= start:
1512
+ return None
1513
+ if started_dt is not None:
1514
+ start = max(start, started_dt)
1515
+ if cutoff_dt is not None:
1516
+ end = min(end, cutoff_dt)
1517
+ return (start, end) if end > start else None
1518
+
1519
+ def union_ms(intervals: list[tuple[Any, Any]]) -> int:
1520
+ merged: list[list[Any]] = []
1521
+ for interval_start, interval_end in sorted(intervals):
1522
+ if not merged or interval_start > merged[-1][1]:
1523
+ merged.append([interval_start, interval_end])
1524
+ else:
1525
+ merged[-1][1] = max(merged[-1][1], interval_end)
1526
+ return sum(
1527
+ max(
1528
+ 0,
1529
+ int((interval_end - interval_start).total_seconds() * 1000),
1530
+ )
1531
+ for interval_start, interval_end in merged
1532
+ )
1533
+
1534
+ active_intervals: list[tuple[Any, Any]] = []
1535
+ attempts: list[dict[str, Any]] = []
1049
1536
  stage_active = 0
1050
- stage_intervals: list[tuple[Any, Any]] = []
1051
1537
  unclosed = 0
1052
- for _phase, phase_events in he.group_events_by_phase(events):
1538
+ for phase, phase_events in he.group_events_by_phase(events):
1539
+ if not any(
1540
+ event.get("type") in {"phase.start", "phase.end"}
1541
+ for event in phase_events
1542
+ ):
1543
+ continue
1053
1544
  timing = he.canonical_phase_timing(phase_events, cutoff_ts=cutoff)
1054
1545
  stage_active += int(timing.get("activeExecutionMs") or 0)
1055
1546
  unclosed += int(timing.get("unclosedAttemptCount") or 0)
1056
- phase_stamps = [
1057
- he.parse_timestamp(event.get("timestamp"))
1058
- for event in phase_events
1059
- if event.get("timestamp")
1060
- ]
1061
- phase_stamps = [stamp for stamp in phase_stamps if stamp is not None]
1062
- if phase_stamps:
1063
- stage_intervals.append((min(phase_stamps), max(phase_stamps)))
1064
- merged_intervals: list[list[Any]] = []
1065
- for interval_start, interval_end in sorted(stage_intervals):
1066
- if not merged_intervals or interval_start > merged_intervals[-1][1]:
1067
- merged_intervals.append([interval_start, interval_end])
1547
+ for invocation in timing.get("attempts") or []:
1548
+ item = {"phase": phase, **invocation}
1549
+ attempts.append(item)
1550
+ if invocation.get("activeEligible"):
1551
+ interval = clipped_interval(
1552
+ invocation.get("startedAt"),
1553
+ invocation.get("endedAt"),
1554
+ )
1555
+ if interval is not None:
1556
+ active_intervals.append(interval)
1557
+
1558
+ external_intervals: list[tuple[Any, Any]] = []
1559
+ pause_intervals: list[tuple[Any, Any]] = []
1560
+ pause_start = None
1561
+ for event in sorted(
1562
+ events,
1563
+ key=lambda item: str(item.get("timestamp") or ""),
1564
+ ):
1565
+ event_type = str(event.get("type") or "").lower()
1566
+ timestamp = he.parse_timestamp(event.get("timestamp"))
1567
+ if event_type in {
1568
+ "external.wait",
1569
+ "ci.wait",
1570
+ "environment.wait",
1571
+ "env.wait",
1572
+ }:
1573
+ ended = he.parse_timestamp(event.get("endedAt"))
1574
+ duration = event.get("duration_ms", event.get("durationMs"))
1575
+ if ended is None and timestamp is not None and isinstance(duration, int):
1576
+ ended = timestamp + dt.timedelta(milliseconds=max(0, duration))
1577
+ interval = clipped_interval(timestamp, ended)
1578
+ if interval is not None:
1579
+ external_intervals.append(interval)
1580
+ if event_type in {"workflow.pause", "session.pause", "pause"}:
1581
+ pause_start = timestamp or pause_start
1582
+ elif event_type in {
1583
+ "workflow.resume",
1584
+ "session.resume",
1585
+ "resume",
1586
+ } and pause_start is not None:
1587
+ interval = clipped_interval(pause_start, timestamp)
1588
+ if interval is not None:
1589
+ pause_intervals.append(interval)
1590
+ pause_start = None
1591
+ if pause_start is not None and cutoff_dt is not None:
1592
+ interval = clipped_interval(pause_start, cutoff_dt)
1593
+ if interval is not None:
1594
+ pause_intervals.append(interval)
1595
+
1596
+ boundaries = sorted(
1597
+ {
1598
+ point
1599
+ for interval in (
1600
+ active_intervals + external_intervals + pause_intervals
1601
+ )
1602
+ for point in interval
1603
+ }
1604
+ | (
1605
+ {started_dt, cutoff_dt}
1606
+ if started_dt is not None and cutoff_dt is not None
1607
+ else set()
1608
+ )
1609
+ )
1610
+ categories = {
1611
+ "attributed": 0,
1612
+ "external": 0,
1613
+ "paused": 0,
1614
+ "unattributed": 0,
1615
+ }
1616
+
1617
+ def covered(
1618
+ interval_start: Any,
1619
+ interval_end: Any,
1620
+ intervals: list[tuple[Any, Any]],
1621
+ ) -> bool:
1622
+ return any(
1623
+ start <= interval_start and end >= interval_end
1624
+ for start, end in intervals
1625
+ )
1626
+
1627
+ for segment_start, segment_end in zip(boundaries, boundaries[1:]):
1628
+ milliseconds = max(
1629
+ 0,
1630
+ int((segment_end - segment_start).total_seconds() * 1000),
1631
+ )
1632
+ if covered(segment_start, segment_end, active_intervals):
1633
+ categories["attributed"] += milliseconds
1634
+ elif covered(segment_start, segment_end, external_intervals):
1635
+ categories["external"] += milliseconds
1636
+ elif covered(segment_start, segment_end, pause_intervals):
1637
+ categories["paused"] += milliseconds
1068
1638
  else:
1069
- merged_intervals[-1][1] = max(merged_intervals[-1][1], interval_end)
1070
- stage_wall = sum(
1071
- max(0, int((interval_end - interval_start).total_seconds() * 1000))
1072
- for interval_start, interval_end in merged_intervals
1639
+ categories["unattributed"] += milliseconds
1640
+
1641
+ workflow_wall = (
1642
+ max(0, int((cutoff_dt - started_dt).total_seconds() * 1000))
1643
+ if started_dt is not None and cutoff_dt is not None
1644
+ else 0
1073
1645
  )
1074
- workflow_wall = he.duration_ms_between(started, cutoff) if started and cutoff else 0
1075
- external = 0
1076
- for event in events:
1077
- etype = str(event.get("type") or "").lower()
1078
- if etype in {"external.wait", "ci.wait", "environment.wait", "env.wait"}:
1079
- dur = event.get("duration_ms") or event.get("durationMs")
1080
- if isinstance(dur, int):
1081
- external += max(0, dur)
1082
- elif event.get("timestamp") and event.get("endedAt"):
1083
- gap = he.duration_ms_between(event.get("timestamp"), event.get("endedAt"))
1084
- if gap:
1085
- external += gap
1086
- unattributed = max(0, int(workflow_wall or 0) - stage_active - external)
1646
+ stage_wall = union_ms(active_intervals)
1647
+ sessions: list[dict[str, Any]] = []
1648
+ session_start = started_dt
1649
+ for pause_start_dt, pause_end_dt in sorted(pause_intervals):
1650
+ if session_start is not None and pause_start_dt > session_start:
1651
+ sessions.append(
1652
+ {
1653
+ "session": len(sessions) + 1,
1654
+ "startedAt": session_start.isoformat(),
1655
+ "endedAt": pause_start_dt.isoformat(),
1656
+ "wallClockMs": int(
1657
+ (pause_start_dt - session_start).total_seconds() * 1000
1658
+ ),
1659
+ "terminalStatus": "PAUSED",
1660
+ }
1661
+ )
1662
+ session_start = pause_end_dt
1663
+ if (
1664
+ session_start is not None
1665
+ and cutoff_dt is not None
1666
+ and cutoff_dt > session_start
1667
+ ):
1668
+ sessions.append(
1669
+ {
1670
+ "session": len(sessions) + 1,
1671
+ "startedAt": session_start.isoformat(),
1672
+ "endedAt": cutoff_dt.isoformat(),
1673
+ "wallClockMs": int(
1674
+ (cutoff_dt - session_start).total_seconds() * 1000
1675
+ ),
1676
+ "terminalStatus": "CUTOFF",
1677
+ }
1678
+ )
1679
+ conservation_total = sum(categories.values())
1087
1680
  post_archive_excluded = 0
1088
1681
  if cutoff:
1089
1682
  for event in events:
@@ -1102,11 +1695,19 @@ def build_workflow_timing(
1102
1695
  "workflowWallClockMs": int(workflow_wall or 0),
1103
1696
  "stageActiveExecutionMs": int(stage_active),
1104
1697
  "stageWallClockSpanMs": int(stage_wall),
1105
- "externalWaitMs": int(external),
1106
- "agentOrToolUnattributedMs": int(unattributed),
1698
+ "attributedStageUnionMs": int(categories["attributed"]),
1699
+ "externalWaitMs": int(categories["external"]),
1700
+ "pausedMs": int(categories["paused"]),
1701
+ "agentOrToolUnattributedMs": int(categories["unattributed"]),
1702
+ "conservationDeltaMs": int(workflow_wall - conservation_total),
1107
1703
  "unclosedAttemptCount": int(unclosed),
1704
+ "attempts": attempts,
1705
+ "sessions": sessions,
1706
+ "sessionWallClockMs": sum(
1707
+ int(item["wallClockMs"]) for item in sessions
1708
+ ),
1108
1709
  "postArchiveEventsExcluded": int(post_archive_excluded),
1109
- "totalMinutesSemantics": "active-only",
1710
+ "totalMinutesSemantics": "workflow-wall-primary; active-is-separate",
1110
1711
  }
1111
1712
 
1112
1713
 
@@ -1630,17 +2231,60 @@ def check_status(
1630
2231
  candidate_codes = {
1631
2232
  "PRODUCT_CI_NOT_GREEN",
1632
2233
  "PRODUCT_CANDIDATE_NOT_VERIFIED",
2234
+ "PRODUCT_CANDIDATE_RECORD_ONLY",
2235
+ "PROJECT_RELEASE_POLICY_BLOCKED",
2236
+ "REMOTE_CI_DOWNGRADE_REFUSED",
1633
2237
  }
1634
2238
  archive_integrity_ok = not any(
1635
2239
  item.get("code") not in candidate_codes for item in blockers
1636
2240
  )
2241
+ archive_integrity = {
2242
+ "ok": archive_integrity_ok,
2243
+ "code": (
2244
+ "ARCHIVE_INTEGRITY_OK"
2245
+ if archive_integrity_ok
2246
+ else "ARCHIVE_INTEGRITY_FAILED"
2247
+ ),
2248
+ "message": (
2249
+ "archive preconditions are complete"
2250
+ if archive_integrity_ok
2251
+ else "archive preconditions contain non-candidate blockers"
2252
+ ),
2253
+ }
2254
+ try:
2255
+ release_summary = collect_summary_data(
2256
+ change_dir, write=False, for_replay=False
2257
+ )
2258
+ release_summary["archiveIntent"] = archive_intent
2259
+ report_adequacy = validate_report_adequacy(release_summary)
2260
+ release_decision = evaluate_release_eligibility(
2261
+ change_dir,
2262
+ release_summary,
2263
+ archive_integrity=archive_integrity,
2264
+ report_adequacy=report_adequacy,
2265
+ )
2266
+ except Exception as exc: # noqa: BLE001 — status must remain read-only
2267
+ release_decision = compose_release_decision(
2268
+ {
2269
+ "archiveIntegrity": archive_integrity,
2270
+ "reportAdequacy": {
2271
+ "ok": False,
2272
+ "code": "REPORT_ADEQUACY_UNAVAILABLE",
2273
+ "message": str(exc),
2274
+ },
2275
+ "candidateVerification": ci_gate,
2276
+ }
2277
+ )
1637
2278
  return {
1638
2279
  "ok": True,
1639
2280
  "archivable": archivable,
1640
2281
  "archiveIntent": archive_intent,
1641
- "archiveIntegrity": {"ok": archive_integrity_ok},
1642
- "candidateVerification": ci_gate,
1643
- "releaseEligible": bool(ci_gate.get("ok")) and archivable,
2282
+ "archiveIntegrity": archive_integrity,
2283
+ "candidateVerification": release_decision["checks"][
2284
+ "candidateVerification"
2285
+ ],
2286
+ "releaseDecision": release_decision,
2287
+ "releaseEligible": release_decision["releaseEligible"],
1644
2288
  "change_dir": str(change_dir),
1645
2289
  "change_name": infer_change_name(change_dir),
1646
2290
  "blockers": blockers,
@@ -2888,6 +3532,7 @@ def collect_summary_data(
2888
3532
  ci_metrics, ci_metrics_source = load_ci_metrics(change_dir)
2889
3533
  if ci_metrics_source:
2890
3534
  sources.append(ci_metrics_source)
3535
+ remote_cost = build_remote_cost_summary(ci_metrics)
2891
3536
 
2892
3537
  log_text = load_execution_log(change_dir)
2893
3538
  if log_text:
@@ -2908,6 +3553,7 @@ def collect_summary_data(
2908
3553
  "diffStat",
2909
3554
  "stageStatus",
2910
3555
  "durations",
3556
+ "timing",
2911
3557
  "skillCalls",
2912
3558
  "verification",
2913
3559
  "timeline",
@@ -2922,10 +3568,24 @@ def collect_summary_data(
2922
3568
  "archiveIntent",
2923
3569
  "archiveIntegrity",
2924
3570
  "candidateVerification",
3571
+ "releaseDecision",
2925
3572
  "releaseEligible",
3573
+ "productCommit",
3574
+ "featureMergeHash",
3575
+ "releaseTipHash",
3576
+ "productTreeHash",
3577
+ "archiveCommit",
3578
+ "environmentHash",
3579
+ "changeIdentity",
3580
+ "artifactStorage",
3581
+ "retention",
3582
+ "remoteCost",
3583
+ "projection",
2926
3584
  ):
2927
3585
  if key in existing:
2928
3586
  data[key] = _deepcopy_json(existing[key])
3587
+ if not for_replay or not isinstance(data.get("remoteCost"), dict):
3588
+ data["remoteCost"] = remote_cost
2929
3589
 
2930
3590
  event_summary = he.build_summary(change_dir, events) if events else {
2931
3591
  "ok": True,
@@ -3037,7 +3697,6 @@ def collect_summary_data(
3037
3697
  )
3038
3698
  data["archiveIntegrity"] = {"ok": True}
3039
3699
  data["candidateVerification"] = candidate_gate
3040
- data["releaseEligible"] = bool(candidate_gate.get("ok"))
3041
3700
 
3042
3701
  # durations / skillCalls
3043
3702
  if events:
@@ -3098,6 +3757,7 @@ def collect_summary_data(
3098
3757
  data["releaseTipHash"] = identity.get("releaseTipHash") or data.get("archiveCommit")
3099
3758
  data["productTreeHash"] = identity.get("productTreeHash")
3100
3759
  data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
3760
+ data["environmentHash"] = identity.get("environmentHash") or NOT_AVAILABLE
3101
3761
  data["changeIdentity"] = identity
3102
3762
 
3103
3763
  # diffStat / changedFiles
@@ -3188,6 +3848,37 @@ def collect_summary_data(
3188
3848
  data["artifacts"] = _artifacts_from_events(
3189
3849
  projected_events, change_dir=change_dir
3190
3850
  )
3851
+ storage_path = change_dir / "evidence" / "artifact-storage.json"
3852
+ if storage_path.is_file():
3853
+ storage = read_json(storage_path)
3854
+ data["artifactStorage"] = (
3855
+ storage if isinstance(storage, dict) else {}
3856
+ )
3857
+ sources.append("evidence/artifact-storage.json")
3858
+ else:
3859
+ data["artifactStorage"] = {
3860
+ "schemaVersion": 1,
3861
+ "artifactCount": 0,
3862
+ "bytesAdded": 0,
3863
+ "bytesReused": 0,
3864
+ "bytesPruned": 0,
3865
+ "largestItems": [],
3866
+ }
3867
+ retention = build_retention_audit(change_dir)
3868
+ data["retention"] = retention
3869
+ if write:
3870
+ write_json(
3871
+ change_dir / "evidence" / "retention-audit.json",
3872
+ retention,
3873
+ )
3874
+ data["projection"] = hgate.evaluate_projection_gate(
3875
+ project,
3876
+ "archive",
3877
+ )
3878
+ else:
3879
+ data.setdefault("artifactStorage", {})
3880
+ data.setdefault("retention", {})
3881
+ data.setdefault("projection", {})
3191
3882
 
3192
3883
  data["reviewSummary"] = _review_summary(
3193
3884
  change_dir,
@@ -3287,6 +3978,35 @@ def collect_summary_data(
3287
3978
  "validationIssues": [],
3288
3979
  }
3289
3980
 
3981
+ report_adequacy = validate_report_adequacy(data)
3982
+ checksum_status = str(
3983
+ (data.get("archiveManifest") or {}).get("checksumStatus") or ""
3984
+ )
3985
+ archive_integrity = {
3986
+ "ok": checksum_status in {"OK", "OK_WITH_EXCLUSIONS"},
3987
+ "code": (
3988
+ "ARCHIVE_INTEGRITY_OK"
3989
+ if checksum_status in {"OK", "OK_WITH_EXCLUSIONS"}
3990
+ else "ARCHIVE_INTEGRITY_FAILED"
3991
+ ),
3992
+ "message": f"checksumStatus={checksum_status or 'missing'}",
3993
+ "checksumStatus": checksum_status,
3994
+ }
3995
+ release_decision = evaluate_release_eligibility(
3996
+ change_dir,
3997
+ data,
3998
+ archive_integrity=archive_integrity,
3999
+ report_adequacy=report_adequacy,
4000
+ )
4001
+ data["archiveIntegrity"] = archive_integrity
4002
+ data["candidateVerification"] = release_decision["checks"][
4003
+ "candidateVerification"
4004
+ ]
4005
+ data["releaseDecision"] = release_decision
4006
+ data["releaseEligible"] = release_decision["releaseEligible"]
4007
+ data["reportPipeline"]["reportAdequacy"] = report_adequacy
4008
+ data["reportPipeline"]["releaseDecision"] = release_decision
4009
+
3290
4010
  # Fill any remaining template placeholders that look like instructions
3291
4011
  for key in list(data.keys()):
3292
4012
  val = data[key]
@@ -3345,6 +4065,54 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
3345
4065
  parts.append("</ul></div>")
3346
4066
  if summary.get("riskTier"):
3347
4067
  parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
4068
+ parts.append("<h3>Current Outcome</h3>")
4069
+ parts.append(
4070
+ f"<p>finalStatus={esc(summary.get('finalStatus'))}</p>"
4071
+ )
4072
+
4073
+ candidate = (
4074
+ summary.get("candidateVerification")
4075
+ if isinstance(summary.get("candidateVerification"), dict)
4076
+ else {}
4077
+ )
4078
+ parts.append("<h3>Candidate Claim / Attestation</h3>")
4079
+ parts.append(
4080
+ f"<p>assurance={esc(candidate.get('assurance'))} "
4081
+ f"code={esc(candidate.get('code'))} "
4082
+ f"provider={esc(candidate.get('provider'))}</p>"
4083
+ )
4084
+
4085
+ integrity = (
4086
+ summary.get("archiveIntegrity")
4087
+ if isinstance(summary.get("archiveIntegrity"), dict)
4088
+ else {}
4089
+ )
4090
+ parts.append("<h3>Archive Integrity</h3>")
4091
+ parts.append(
4092
+ f"<p>ok={esc(integrity.get('ok'))} "
4093
+ f"code={esc(integrity.get('code'))} "
4094
+ f"checksumStatus={esc(integrity.get('checksumStatus'))}</p>"
4095
+ )
4096
+
4097
+ decision = (
4098
+ summary.get("releaseDecision")
4099
+ if isinstance(summary.get("releaseDecision"), dict)
4100
+ else {}
4101
+ )
4102
+ parts.append("<h3>Release Eligibility</h3>")
4103
+ parts.append(
4104
+ f"<p>releaseEligible={esc(decision.get('releaseEligible'))} "
4105
+ f"code={esc(decision.get('code'))}</p><ul>"
4106
+ )
4107
+ checks = decision.get("checks")
4108
+ checks = checks if isinstance(checks, dict) else {}
4109
+ for name, check in checks.items():
4110
+ check = check if isinstance(check, dict) else {}
4111
+ parts.append(
4112
+ f"<li>{esc(name)}: ok={esc(check.get('ok'))} "
4113
+ f"code={esc(check.get('code'))}</li>"
4114
+ )
4115
+ parts.append("</ul>")
3348
4116
 
3349
4117
  timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
3350
4118
  if timing:
@@ -3356,6 +4124,16 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
3356
4124
  f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
3357
4125
  "</p>"
3358
4126
  )
4127
+ parts.append(
4128
+ "<p id=\"timingConservation\">"
4129
+ f"attributedStageUnionMs={esc(timing.get('attributedStageUnionMs'))} · "
4130
+ f"externalWaitMs={esc(timing.get('externalWaitMs'))} · "
4131
+ f"pausedMs={esc(timing.get('pausedMs'))} · "
4132
+ "agentOrToolUnattributedMs="
4133
+ f"{esc(timing.get('agentOrToolUnattributedMs'))} · "
4134
+ f"conservationDeltaMs={esc(timing.get('conservationDeltaMs'))}"
4135
+ "</p>"
4136
+ )
3359
4137
  parts.append(
3360
4138
  f"<p><strong>reportCutoffAt</strong>: "
3361
4139
  f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
@@ -3364,6 +4142,59 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
3364
4142
  "<p><small>durations.totalMinutes is active-only; "
3365
4143
  "do not treat it as workflow wall clock.</small></p>"
3366
4144
  )
4145
+ parts.append("<h3>History Quality</h3><ul>")
4146
+ attempts = timing.get("attempts")
4147
+ attempts = attempts if isinstance(attempts, list) else []
4148
+ for attempt in attempts:
4149
+ attempt = attempt if isinstance(attempt, dict) else {}
4150
+ parts.append(
4151
+ f"<li>{esc(attempt.get('phase'))}: "
4152
+ f"{esc(attempt.get('terminalStatus') or attempt.get('status'))} "
4153
+ f"durationMs={esc(attempt.get('durationMs'))}</li>"
4154
+ )
4155
+ if not attempts:
4156
+ parts.append("<li>no typed attempts recorded</li>")
4157
+ parts.append("</ul>")
4158
+
4159
+ remote_cost = (
4160
+ summary.get("remoteCost")
4161
+ if isinstance(summary.get("remoteCost"), dict)
4162
+ else {}
4163
+ )
4164
+ remote_totals = remote_cost.get("totals")
4165
+ remote_totals = remote_totals if isinstance(remote_totals, dict) else {}
4166
+ parts.append("<h3>Remote Cost</h3>")
4167
+ parts.append(
4168
+ f"<p>runnerMinutes={esc(remote_totals.get('runnerMinutes'))} "
4169
+ f"queueWaitMs={esc(remote_totals.get('queueWaitMs'))} "
4170
+ f"artifactBytes={esc(remote_totals.get('artifactBytes'))} "
4171
+ f"duplicateRunCount={esc(remote_totals.get('duplicateRunCount'))}</p>"
4172
+ )
4173
+
4174
+ storage = (
4175
+ summary.get("artifactStorage")
4176
+ if isinstance(summary.get("artifactStorage"), dict)
4177
+ else {}
4178
+ )
4179
+ parts.append("<h3>Artifact Storage</h3>")
4180
+ parts.append(
4181
+ f"<p>artifactCount={esc(storage.get('artifactCount'))} "
4182
+ f"bytesAdded={esc(storage.get('bytesAdded'))} "
4183
+ f"bytesReused={esc(storage.get('bytesReused'))} "
4184
+ f"bytesPruned={esc(storage.get('bytesPruned'))}</p>"
4185
+ )
4186
+
4187
+ projection = (
4188
+ summary.get("projection")
4189
+ if isinstance(summary.get("projection"), dict)
4190
+ else {}
4191
+ )
4192
+ parts.append("<h3>Projection / Fallback</h3>")
4193
+ parts.append(
4194
+ f"<p>mode={esc(projection.get('mode'))} "
4195
+ f"code={esc(projection.get('code'))} "
4196
+ f"remediation={esc(projection.get('remediation'))}</p>"
4197
+ )
3367
4198
 
3368
4199
  if summary.get("productCommit") or summary.get("productTreeHash"):
3369
4200
  parts.append("<h3>Identity</h3>")
@@ -3611,7 +4442,7 @@ def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
3611
4442
 
3612
4443
  def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
3613
4444
  """Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
3614
- issues: list[dict[str, str]] = []
4445
+ issues: list[dict[str, Any]] = []
3615
4446
  seen: dict[str, str] = {}
3616
4447
  for item in entries:
3617
4448
  if not isinstance(item, dict):
@@ -4055,67 +4886,740 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
4055
4886
  except (TypeError, ValueError):
4056
4887
  files_changed = 0
4057
4888
 
4058
- base_missing = not base or base == NOT_AVAILABLE
4059
- final_present = bool(
4060
- (final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
4889
+ base_missing = not base or base == NOT_AVAILABLE
4890
+ final_present = bool(
4891
+ (final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
4892
+ )
4893
+ if final_present and base_missing:
4894
+ issues.append({
4895
+ "code": "IDENTITY_BASE_MISSING",
4896
+ "severity": "error",
4897
+ "message": (
4898
+ "final/merge identity present but baseCommit is empty; "
4899
+ "refuse CONDITIONAL_OK+ without a resolved base"
4900
+ ),
4901
+ })
4902
+
4903
+ if (
4904
+ base
4905
+ and final
4906
+ and base != NOT_AVAILABLE
4907
+ and final != NOT_AVAILABLE
4908
+ and base != final
4909
+ and files_changed == 0
4910
+ ):
4911
+ issues.append({
4912
+ "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
4913
+ "severity": "error",
4914
+ "message": f"final commit {final} differs from base {base} but filesChanged=0",
4915
+ })
4916
+
4917
+ # Typed metrics missing despite test report artifacts present.
4918
+ verification = summary.get("verification") or {}
4919
+ if isinstance(verification, dict):
4920
+ unit = verification.get("unitTests") or {}
4921
+ api = verification.get("apiTests") or {}
4922
+ artifacts = summary.get("artifacts") or []
4923
+ has_test_report = any(
4924
+ isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
4925
+ for a in artifacts
4926
+ ) if isinstance(artifacts, list) else False
4927
+ unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
4928
+ api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
4929
+ if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
4930
+ issues.append({
4931
+ "code": "TYPED_METRICS_MISSING",
4932
+ "severity": "error",
4933
+ "message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
4934
+ })
4935
+
4936
+ # stageStatus contradicts event reducer.
4937
+ stage = summary.get("stageStatus") or {}
4938
+ stage_from_events = summary.get("stageStatusFromEvents") or {}
4939
+ if isinstance(stage, dict) and isinstance(stage_from_events, dict):
4940
+ for phase, status in stage.items():
4941
+ event_status = stage_from_events.get(phase)
4942
+ if event_status is not None and str(status) != str(event_status):
4943
+ issues.append({
4944
+ "code": "STAGE_STATUS_CONTRADICTION",
4945
+ "severity": "error",
4946
+ "message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
4947
+ })
4948
+
4949
+ # Candidate receipt and summary must describe the same immutable subject.
4950
+ candidate_gate = summary.get("candidateVerification")
4951
+ candidate_gate = candidate_gate if isinstance(candidate_gate, dict) else {}
4952
+ candidate = candidate_gate.get("evidence")
4953
+ candidate = candidate if isinstance(candidate, dict) else {}
4954
+ subject = candidate.get("subject")
4955
+ subject = subject if isinstance(subject, dict) else {}
4956
+
4957
+ summary_commit = str(summary.get("productCommit") or "").strip()
4958
+ candidate_commit = str(subject.get("productCommit") or "").strip()
4959
+ if (
4960
+ summary_commit
4961
+ and candidate_commit
4962
+ and summary_commit != candidate_commit
4963
+ ):
4964
+ issues.append(
4965
+ {
4966
+ "code": "CANDIDATE_SUMMARY_COMMIT_MISMATCH",
4967
+ "severity": "error",
4968
+ "message": (
4969
+ "candidate productCommit does not match summary productCommit"
4970
+ ),
4971
+ "expected": summary_commit,
4972
+ "actual": candidate_commit,
4973
+ }
4974
+ )
4975
+
4976
+ normalize_hash = lambda value: str(value or "").strip().removeprefix("sha256:")
4977
+ summary_tree = str(summary.get("productTreeHash") or "").strip()
4978
+ candidate_tree = str(subject.get("productTreeHash") or "").strip()
4979
+ if (
4980
+ summary_tree
4981
+ and candidate_tree
4982
+ and normalize_hash(summary_tree) != normalize_hash(candidate_tree)
4983
+ ):
4984
+ issues.append(
4985
+ {
4986
+ "code": "CANDIDATE_SUMMARY_TREE_MISMATCH",
4987
+ "severity": "error",
4988
+ "message": (
4989
+ "candidate productTreeHash does not match summary productTreeHash"
4990
+ ),
4991
+ "expected": summary_tree,
4992
+ "actual": candidate_tree,
4993
+ }
4994
+ )
4995
+
4996
+ summary_environment = str(summary.get("environmentHash") or "").strip()
4997
+ candidate_environment = str(subject.get("environmentHash") or "").strip()
4998
+ if (
4999
+ summary_environment
5000
+ and summary_environment != NOT_AVAILABLE
5001
+ and candidate_environment
5002
+ and normalize_hash(summary_environment)
5003
+ != normalize_hash(candidate_environment)
5004
+ ):
5005
+ issues.append(
5006
+ {
5007
+ "code": "CANDIDATE_SUMMARY_ENVIRONMENT_MISMATCH",
5008
+ "severity": "error",
5009
+ "message": (
5010
+ "candidate environmentHash does not match summary environmentHash"
5011
+ ),
5012
+ "expected": summary_environment,
5013
+ "actual": candidate_environment,
5014
+ }
5015
+ )
5016
+
5017
+ return {"ok": not issues, "issues": issues}
5018
+
5019
+
5020
+ RELEASE_CHECKS = (
5021
+ "archiveIntegrity",
5022
+ "reportAdequacy",
5023
+ "candidateVerification",
5024
+ "candidateIdentity",
5025
+ "projectReleasePolicy",
5026
+ "terminalAttempts",
5027
+ "finalStatus",
5028
+ )
5029
+
5030
+
5031
+ def validate_parallel_isolation_contract(
5032
+ child_change_ids: list[str],
5033
+ isolation: Any,
5034
+ ) -> dict[str, Any]:
5035
+ """Require unique worktree, port, DB, temp root, and writer lease per child."""
5036
+ required = (
5037
+ "worktreeRoot",
5038
+ "port",
5039
+ "database",
5040
+ "tempRoot",
5041
+ "writerLease",
5042
+ )
5043
+ mapping = isolation if isinstance(isolation, dict) else {}
5044
+ issues: list[dict[str, Any]] = []
5045
+ expected = set(child_change_ids)
5046
+ actual = {str(key) for key in mapping}
5047
+ if expected != actual:
5048
+ issues.append(
5049
+ {
5050
+ "code": "PARALLEL_ISOLATION_MEMBERSHIP_MISMATCH",
5051
+ "message": "childIsolation must describe every covered child exactly once",
5052
+ "missing": sorted(expected - actual),
5053
+ "unexpected": sorted(actual - expected),
5054
+ }
5055
+ )
5056
+
5057
+ def _identity(field: str, value: Any) -> str:
5058
+ text = str(value).strip()
5059
+ if field in {"worktreeRoot", "tempRoot"}:
5060
+ return text.replace("\\", "/").rstrip("/").casefold()
5061
+ return text.casefold()
5062
+
5063
+ for field in required:
5064
+ owners: dict[str, list[str]] = {}
5065
+ for child_id in sorted(expected):
5066
+ row = mapping.get(child_id)
5067
+ row = row if isinstance(row, dict) else {}
5068
+ value = row.get(field)
5069
+ identity = _identity(field, value)
5070
+ if not identity:
5071
+ issues.append(
5072
+ {
5073
+ "code": "PARALLEL_ISOLATION_FIELD_MISSING",
5074
+ "message": f"{child_id} is missing isolation field {field}",
5075
+ "childChangeId": child_id,
5076
+ "field": field,
5077
+ }
5078
+ )
5079
+ continue
5080
+ owners.setdefault(identity, []).append(child_id)
5081
+ for value, children in owners.items():
5082
+ if len(children) > 1:
5083
+ issues.append(
5084
+ {
5085
+ "code": "PARALLEL_ISOLATION_COLLISION",
5086
+ "message": (
5087
+ f"parallel children share {field}; cross-write risk detected"
5088
+ ),
5089
+ "field": field,
5090
+ "value": value,
5091
+ "children": children,
5092
+ }
5093
+ )
5094
+ return {
5095
+ "ok": not issues,
5096
+ "code": (
5097
+ "PARALLEL_ISOLATION_OK"
5098
+ if not issues
5099
+ else "PARALLEL_ISOLATION_INVALID"
5100
+ ),
5101
+ "issues": issues,
5102
+ "children": sorted(expected),
5103
+ }
5104
+
5105
+
5106
+ def validate_aggregate_candidate_contract(
5107
+ contract: dict[str, Any],
5108
+ *,
5109
+ child_change_id: str | None = None,
5110
+ candidate_subject: dict[str, Any] | None = None,
5111
+ ) -> dict[str, Any]:
5112
+ """Validate aggregate/child/integration membership as one identity contract."""
5113
+ issues: list[dict[str, Any]] = []
5114
+ if contract.get("schemaVersion") != 1:
5115
+ issues.append(
5116
+ {
5117
+ "code": "AGGREGATE_SCHEMA_INVALID",
5118
+ "message": "aggregate candidate schemaVersion must be 1",
5119
+ }
5120
+ )
5121
+ if contract.get("candidateScope") != "aggregate":
5122
+ issues.append(
5123
+ {
5124
+ "code": "AGGREGATE_SCOPE_INVALID",
5125
+ "message": "candidateScope must be aggregate",
5126
+ }
5127
+ )
5128
+ aggregate_change_id = str(contract.get("aggregateChangeId") or "").strip()
5129
+ if not aggregate_change_id:
5130
+ issues.append(
5131
+ {
5132
+ "code": "AGGREGATE_CHANGE_ID_MISSING",
5133
+ "message": "aggregateChangeId is required",
5134
+ }
5135
+ )
5136
+
5137
+ raw_children = contract.get("coveredChildChanges")
5138
+ children = (
5139
+ [str(item).strip() for item in raw_children]
5140
+ if isinstance(raw_children, list)
5141
+ else []
5142
+ )
5143
+ if (
5144
+ not children
5145
+ or any(not item for item in children)
5146
+ or len(set(children)) != len(children)
5147
+ ):
5148
+ issues.append(
5149
+ {
5150
+ "code": "AGGREGATE_CHILDREN_INVALID",
5151
+ "message": "coveredChildChanges must be non-empty and unique",
5152
+ }
5153
+ )
5154
+ commits = contract.get("childProductCommits")
5155
+ commits = commits if isinstance(commits, dict) else {}
5156
+ child_set = set(children)
5157
+ commit_set = {str(key) for key in commits}
5158
+ if child_set != commit_set or any(
5159
+ not isinstance(value, str) or not value.strip()
5160
+ for value in commits.values()
5161
+ ):
5162
+ issues.append(
5163
+ {
5164
+ "code": "AGGREGATE_CHILD_MEMBERSHIP_MISMATCH",
5165
+ "message": (
5166
+ "childProductCommits must map every covered child exactly once"
5167
+ ),
5168
+ "missing": sorted(child_set - commit_set),
5169
+ "unexpected": sorted(commit_set - child_set),
5170
+ }
5171
+ )
5172
+ if child_change_id is not None and child_change_id not in child_set:
5173
+ issues.append(
5174
+ {
5175
+ "code": "AGGREGATE_CHILD_NOT_COVERED",
5176
+ "message": f"child change is not covered: {child_change_id}",
5177
+ }
5178
+ )
5179
+
5180
+ integration_commit = str(
5181
+ contract.get("integrationProductCommit") or ""
5182
+ ).strip()
5183
+ subject_commit = str(
5184
+ (candidate_subject or {}).get("productCommit") or ""
5185
+ ).strip()
5186
+ if not integration_commit:
5187
+ issues.append(
5188
+ {
5189
+ "code": "AGGREGATE_INTEGRATION_COMMIT_MISSING",
5190
+ "message": "integrationProductCommit is required",
5191
+ }
5192
+ )
5193
+ elif subject_commit and integration_commit != subject_commit:
5194
+ issues.append(
5195
+ {
5196
+ "code": "AGGREGATE_INTEGRATION_COMMIT_MISMATCH",
5197
+ "message": (
5198
+ "integrationProductCommit does not match candidate subject"
5199
+ ),
5200
+ "expected": subject_commit,
5201
+ "actual": integration_commit,
5202
+ }
5203
+ )
5204
+
5205
+ coverage = contract.get("coverageProof")
5206
+ coverage = coverage if isinstance(coverage, dict) else {}
5207
+ digest = str(coverage.get("digest") or "")
5208
+ if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
5209
+ issues.append(
5210
+ {
5211
+ "code": "AGGREGATE_COVERAGE_PROOF_INVALID",
5212
+ "message": "coverageProof.digest must be a sha256 digest",
5213
+ }
5214
+ )
5215
+ if not str(coverage.get("source") or "").strip():
5216
+ issues.append(
5217
+ {
5218
+ "code": "AGGREGATE_COVERAGE_SOURCE_MISSING",
5219
+ "message": "coverageProof.source is required",
5220
+ }
5221
+ )
5222
+ parallel_isolation = None
5223
+ if (
5224
+ contract.get("concurrencyMode") == "isolated-multi-active"
5225
+ or "childIsolation" in contract
5226
+ ):
5227
+ parallel_isolation = validate_parallel_isolation_contract(
5228
+ children,
5229
+ contract.get("childIsolation"),
5230
+ )
5231
+ issues.extend(parallel_isolation.get("issues") or [])
5232
+
5233
+ primary_code = (
5234
+ "AGGREGATE_CANDIDATE_OK"
5235
+ if not issues
5236
+ else (
5237
+ "AGGREGATE_CHILD_MEMBERSHIP_MISMATCH"
5238
+ if any(
5239
+ issue["code"] == "AGGREGATE_CHILD_MEMBERSHIP_MISMATCH"
5240
+ for issue in issues
5241
+ )
5242
+ else "AGGREGATE_CANDIDATE_INVALID"
5243
+ )
5244
+ )
5245
+ return {
5246
+ "ok": not issues,
5247
+ "code": primary_code,
5248
+ "message": (
5249
+ "aggregate candidate covers every child and matches integration identity"
5250
+ if not issues
5251
+ else f"aggregate candidate has {len(issues)} issue(s)"
5252
+ ),
5253
+ "aggregateChangeId": aggregate_change_id,
5254
+ "coveredChildChanges": children,
5255
+ "parallelIsolation": parallel_isolation,
5256
+ "issues": issues,
5257
+ }
5258
+
5259
+
5260
+ def compose_release_decision(
5261
+ checks: dict[str, Any],
5262
+ ) -> dict[str, Any]:
5263
+ """Compose the only release-eligibility formula used by the archive."""
5264
+ normalized: dict[str, dict[str, Any]] = {}
5265
+ issues: list[dict[str, Any]] = []
5266
+ for name in RELEASE_CHECKS:
5267
+ value = checks.get(name)
5268
+ item = dict(value) if isinstance(value, dict) else {
5269
+ "ok": False,
5270
+ "code": "RELEASE_CHECK_MISSING",
5271
+ "message": f"required release check missing: {name}",
5272
+ }
5273
+ item["ok"] = bool(item.get("ok"))
5274
+ normalized[name] = item
5275
+ if not item["ok"]:
5276
+ issues.append(
5277
+ {
5278
+ "check": name,
5279
+ "code": str(
5280
+ item.get("code")
5281
+ or f"{name.upper()}_BLOCKED"
5282
+ ),
5283
+ "message": str(
5284
+ item.get("message")
5285
+ or f"release check failed: {name}"
5286
+ ),
5287
+ }
5288
+ )
5289
+ eligible = all(normalized[name]["ok"] for name in RELEASE_CHECKS)
5290
+ return {
5291
+ "ok": eligible,
5292
+ "releaseEligible": eligible,
5293
+ "code": (
5294
+ "RELEASE_ELIGIBLE"
5295
+ if eligible
5296
+ else "RELEASE_NOT_ELIGIBLE"
5297
+ ),
5298
+ "checks": normalized,
5299
+ "issues": issues,
5300
+ }
5301
+
5302
+
5303
+ def _candidate_identity_gate(
5304
+ change_dir: Path,
5305
+ summary: dict[str, Any],
5306
+ candidate_gate: dict[str, Any],
5307
+ ) -> dict[str, Any]:
5308
+ evidence = candidate_gate.get("evidence")
5309
+ evidence = evidence if isinstance(evidence, dict) else {}
5310
+ subject = evidence.get("subject")
5311
+ subject = subject if isinstance(subject, dict) else {}
5312
+ issues: list[dict[str, Any]] = []
5313
+
5314
+ pairs = (
5315
+ (
5316
+ "productCommit",
5317
+ str(summary.get("productCommit") or "").strip(),
5318
+ str(subject.get("productCommit") or "").strip(),
5319
+ False,
5320
+ ),
5321
+ (
5322
+ "productTreeHash",
5323
+ str(summary.get("productTreeHash") or "").strip(),
5324
+ str(subject.get("productTreeHash") or "").strip(),
5325
+ True,
5326
+ ),
5327
+ (
5328
+ "environmentHash",
5329
+ str(summary.get("environmentHash") or "").strip(),
5330
+ str(subject.get("environmentHash") or "").strip(),
5331
+ True,
5332
+ ),
5333
+ )
5334
+ for field, expected, actual, is_hash in pairs:
5335
+ if not expected or expected == NOT_AVAILABLE or not actual:
5336
+ issues.append(
5337
+ {
5338
+ "code": f"CANDIDATE_{field.upper()}_MISSING",
5339
+ "message": (
5340
+ f"candidate/summary identity field missing: {field}"
5341
+ ),
5342
+ "expected": expected,
5343
+ "actual": actual,
5344
+ }
5345
+ )
5346
+ continue
5347
+ left = expected.removeprefix("sha256:") if is_hash else expected
5348
+ right = actual.removeprefix("sha256:") if is_hash else actual
5349
+ if left != right:
5350
+ issues.append(
5351
+ {
5352
+ "code": f"CANDIDATE_{field.upper()}_MISMATCH",
5353
+ "message": (
5354
+ f"candidate {field} does not match summary {field}"
5355
+ ),
5356
+ "expected": expected,
5357
+ "actual": actual,
5358
+ }
5359
+ )
5360
+
5361
+ product_identity = validate_product_identity(
5362
+ product_commit=str(summary.get("productCommit") or ""),
5363
+ product_tree_hash=str(summary.get("productTreeHash") or ""),
5364
+ archive_commit=str(summary.get("archiveCommit") or ""),
5365
+ project=find_project_root(change_dir),
5366
+ )
5367
+ if not product_identity.get("ok"):
5368
+ issues.append(
5369
+ {
5370
+ "code": str(
5371
+ product_identity.get("code")
5372
+ or "PRODUCT_IDENTITY_INVALID"
5373
+ ),
5374
+ "message": str(
5375
+ product_identity.get("message")
5376
+ or "product/archive Git identity is invalid"
5377
+ ),
5378
+ "detail": product_identity,
5379
+ }
5380
+ )
5381
+ aggregate_contract = evidence.get("aggregateCandidate")
5382
+ if not isinstance(aggregate_contract, dict):
5383
+ aggregate_contract = evidence.get("aggregate")
5384
+ if not isinstance(aggregate_contract, dict) and (
5385
+ evidence.get("candidateScope") == "aggregate"
5386
+ ):
5387
+ aggregate_contract = evidence
5388
+ aggregate_identity = None
5389
+ if isinstance(aggregate_contract, dict):
5390
+ aggregate_identity = validate_aggregate_candidate_contract(
5391
+ aggregate_contract,
5392
+ candidate_subject=subject,
5393
+ )
5394
+ if not aggregate_identity.get("ok"):
5395
+ issues.append(
5396
+ {
5397
+ "code": str(
5398
+ aggregate_identity.get("code")
5399
+ or "AGGREGATE_CANDIDATE_INVALID"
5400
+ ),
5401
+ "message": str(
5402
+ aggregate_identity.get("message")
5403
+ or "aggregate candidate identity is invalid"
5404
+ ),
5405
+ "detail": aggregate_identity,
5406
+ }
5407
+ )
5408
+ return {
5409
+ "ok": not issues,
5410
+ "code": (
5411
+ "CANDIDATE_IDENTITY_OK"
5412
+ if not issues
5413
+ else "CANDIDATE_IDENTITY_INVALID"
5414
+ ),
5415
+ "message": (
5416
+ "candidate, summary, environment, and Git identities agree"
5417
+ if not issues
5418
+ else f"candidate identity has {len(issues)} issue(s)"
5419
+ ),
5420
+ "issues": issues,
5421
+ "productIdentity": product_identity,
5422
+ "aggregateIdentity": aggregate_identity,
5423
+ }
5424
+
5425
+
5426
+ def _project_release_policy_gate(
5427
+ change_dir: Path,
5428
+ summary: dict[str, Any],
5429
+ candidate_gate: dict[str, Any],
5430
+ ) -> dict[str, Any]:
5431
+ policy = load_gate_policy(change_dir) or {}
5432
+ candidate_policy = policy.get("candidateVerification")
5433
+ candidate_policy = (
5434
+ candidate_policy if isinstance(candidate_policy, dict) else {}
4061
5435
  )
4062
- if final_present and base_missing:
4063
- issues.append({
4064
- "code": "IDENTITY_BASE_MISSING",
4065
- "severity": "error",
5436
+ release_policy = policy.get("releasePolicy")
5437
+ release_policy = (
5438
+ release_policy if isinstance(release_policy, dict) else {}
5439
+ )
5440
+ projection = hgate.evaluate_projection_gate(
5441
+ find_project_root(change_dir),
5442
+ "archive",
5443
+ )
5444
+ if not projection.get("ok"):
5445
+ return {
5446
+ "ok": False,
5447
+ "code": str(
5448
+ projection.get("code") or "PROJECTION_DEGRADED_BLOCKED"
5449
+ ),
5450
+ "message": str(
5451
+ projection.get("message")
5452
+ or "projection receipt blocks release"
5453
+ ),
5454
+ "projection": projection,
5455
+ }
5456
+ if str(summary.get("archiveIntent") or "") == "record-only":
5457
+ return {
5458
+ "ok": False,
5459
+ "code": "RECORD_ONLY_ARCHIVE",
5460
+ "message": "record-only archive cannot authorize release",
5461
+ "remoteRequired": bool(candidate_policy.get("remoteRequired")),
5462
+ }
5463
+ minimum = str(
5464
+ candidate_policy.get("minimumAssurance")
5465
+ or "local-reproducible"
5466
+ ).strip()
5467
+ remote_required = bool(candidate_policy.get("remoteRequired")) or (
5468
+ minimum == "remote-attested"
5469
+ )
5470
+ assurance = str(candidate_gate.get("assurance") or "").strip()
5471
+ if remote_required and assurance != "remote-attested":
5472
+ return {
5473
+ "ok": False,
5474
+ "code": "REMOTE_ATTESTATION_REQUIRED",
4066
5475
  "message": (
4067
- "final/merge identity present but baseCommit is empty; "
4068
- "refuse CONDITIONAL_OK+ without a resolved base"
5476
+ f"project requires remote-attested evidence; got {assurance or 'none'}"
4069
5477
  ),
4070
- })
4071
-
4072
- if (
4073
- base
4074
- and final
4075
- and base != NOT_AVAILABLE
4076
- and final != NOT_AVAILABLE
4077
- and base != final
4078
- and files_changed == 0
5478
+ "remoteRequired": True,
5479
+ }
5480
+ if assurance == "local-reproducible" and not bool(
5481
+ candidate_policy.get("allowLocalRelease")
4079
5482
  ):
4080
- issues.append({
4081
- "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
4082
- "severity": "error",
4083
- "message": f"final commit {final} differs from base {base} but filesChanged=0",
4084
- })
5483
+ return {
5484
+ "ok": False,
5485
+ "code": "LOCAL_RELEASE_NOT_AUTHORIZED",
5486
+ "message": (
5487
+ "local release requires "
5488
+ "candidateVerification.allowLocalRelease=true"
5489
+ ),
5490
+ "remoteRequired": remote_required,
5491
+ }
5492
+ evidence = candidate_gate.get("evidence")
5493
+ evidence = evidence if isinstance(evidence, dict) else {}
5494
+ subject = evidence.get("subject")
5495
+ subject = subject if isinstance(subject, dict) else {}
5496
+ final_sequence = hphase.evaluate_final_sequence(
5497
+ change_dir,
5498
+ {
5499
+ "productCommit": str(subject.get("productCommit") or ""),
5500
+ "productTreeHash": str(subject.get("productTreeHash") or ""),
5501
+ "environmentHash": str(subject.get("environmentHash") or ""),
5502
+ },
5503
+ exclude_nodes={"sequence:archive"},
5504
+ )
5505
+ if not final_sequence.get("ok"):
5506
+ return {
5507
+ "ok": False,
5508
+ "code": "FINAL_SEQUENCE_INCOMPLETE",
5509
+ "message": str(
5510
+ final_sequence.get("message")
5511
+ or "required final sequence is incomplete"
5512
+ ),
5513
+ "remoteRequired": remote_required,
5514
+ "finalSequence": final_sequence,
5515
+ }
5516
+ return {
5517
+ "ok": True,
5518
+ "code": "PROJECT_RELEASE_POLICY_OK",
5519
+ "message": "candidate evidence satisfies project release policy",
5520
+ "remoteRequired": remote_required,
5521
+ "minimumAssurance": minimum,
5522
+ "allowedFinalStatuses": release_policy.get(
5523
+ "allowedFinalStatuses", ["OK"]
5524
+ ),
5525
+ "finalSequence": final_sequence,
5526
+ "projection": projection,
5527
+ }
4085
5528
 
4086
- # Typed metrics missing despite test report artifacts present.
4087
- verification = summary.get("verification") or {}
4088
- if isinstance(verification, dict):
4089
- unit = verification.get("unitTests") or {}
4090
- api = verification.get("apiTests") or {}
4091
- artifacts = summary.get("artifacts") or []
4092
- has_test_report = any(
4093
- isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
4094
- for a in artifacts
4095
- ) if isinstance(artifacts, list) else False
4096
- unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
4097
- api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
4098
- if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
4099
- issues.append({
4100
- "code": "TYPED_METRICS_MISSING",
4101
- "severity": "error",
4102
- "message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
4103
- })
4104
5529
 
4105
- # stageStatus contradicts event reducer.
4106
- stage = summary.get("stageStatus") or {}
4107
- stage_from_events = summary.get("stageStatusFromEvents") or {}
4108
- if isinstance(stage, dict) and isinstance(stage_from_events, dict):
4109
- for phase, status in stage.items():
4110
- event_status = stage_from_events.get(phase)
4111
- if event_status is not None and str(status) != str(event_status):
4112
- issues.append({
4113
- "code": "STAGE_STATUS_CONTRADICTION",
4114
- "severity": "error",
4115
- "message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
4116
- })
5530
+ def _terminal_attempts_gate(summary: dict[str, Any]) -> dict[str, Any]:
5531
+ timing = summary.get("timing")
5532
+ timing = timing if isinstance(timing, dict) else {}
5533
+ unclosed = int(timing.get("unclosedAttemptCount") or 0)
5534
+ attempts = timing.get("attempts")
5535
+ attempts = attempts if isinstance(attempts, list) else []
5536
+ blocking_statuses = {
5537
+ "INCOMPLETE",
5538
+ "INCOMPLETE_AT_CUTOFF",
5539
+ "ORPHANED",
5540
+ "INTERRUPTED",
5541
+ }
5542
+ blocking = [
5543
+ item
5544
+ for item in attempts
5545
+ if isinstance(item, dict)
5546
+ and str(item.get("terminalStatus") or item.get("status") or "").upper()
5547
+ in blocking_statuses
5548
+ ]
5549
+ ok = unclosed == 0 and not blocking
5550
+ return {
5551
+ "ok": ok,
5552
+ "code": (
5553
+ "TERMINAL_ATTEMPTS_OK"
5554
+ if ok
5555
+ else "TERMINAL_ATTEMPTS_INCOMPLETE"
5556
+ ),
5557
+ "message": (
5558
+ "all release-critical attempts are terminal"
5559
+ if ok
5560
+ else (
5561
+ f"unclosedAttemptCount={unclosed} "
5562
+ f"blockingAttempts={len(blocking)}"
5563
+ )
5564
+ ),
5565
+ "unclosedAttemptCount": unclosed,
5566
+ "blockingAttempts": blocking,
5567
+ }
4117
5568
 
4118
- return {"ok": not issues, "issues": issues}
5569
+
5570
+ def evaluate_release_eligibility(
5571
+ change_dir: Path,
5572
+ summary: dict[str, Any],
5573
+ *,
5574
+ archive_integrity: dict[str, Any],
5575
+ report_adequacy: dict[str, Any],
5576
+ ) -> dict[str, Any]:
5577
+ """Evaluate the complete fail-closed release decision."""
5578
+ change_dir = change_dir.resolve()
5579
+ candidate_gate = evaluate_product_ci_gate(change_dir)
5580
+ candidate_identity = _candidate_identity_gate(
5581
+ change_dir, summary, candidate_gate
5582
+ )
5583
+ project_policy = _project_release_policy_gate(
5584
+ change_dir, summary, candidate_gate
5585
+ )
5586
+ gate_policy = load_gate_policy(change_dir) or {}
5587
+ release_policy = gate_policy.get("releasePolicy")
5588
+ release_policy = (
5589
+ release_policy if isinstance(release_policy, dict) else {}
5590
+ )
5591
+ allowed_statuses = release_policy.get("allowedFinalStatuses")
5592
+ if not isinstance(allowed_statuses, list) or not allowed_statuses:
5593
+ allowed_statuses = ["OK"]
5594
+ allowed_statuses = [
5595
+ str(item).upper() for item in allowed_statuses if str(item).strip()
5596
+ ]
5597
+ final_status = str(summary.get("finalStatus") or "").upper()
5598
+ final_status_gate = {
5599
+ "ok": final_status in allowed_statuses,
5600
+ "code": (
5601
+ "FINAL_STATUS_ALLOWED"
5602
+ if final_status in allowed_statuses
5603
+ else "FINAL_STATUS_BLOCKED"
5604
+ ),
5605
+ "message": (
5606
+ f"finalStatus={final_status or 'missing'} "
5607
+ f"allowed={allowed_statuses}"
5608
+ ),
5609
+ "actual": final_status,
5610
+ "allowed": allowed_statuses,
5611
+ }
5612
+ return compose_release_decision(
5613
+ {
5614
+ "archiveIntegrity": archive_integrity,
5615
+ "reportAdequacy": report_adequacy,
5616
+ "candidateVerification": candidate_gate,
5617
+ "candidateIdentity": candidate_identity,
5618
+ "projectReleasePolicy": project_policy,
5619
+ "terminalAttempts": _terminal_attempts_gate(summary),
5620
+ "finalStatus": final_status_gate,
5621
+ }
5622
+ )
4119
5623
 
4120
5624
 
4121
5625
  def artifact_preflight(change_dir: Path) -> dict[str, Any]:
@@ -4210,12 +5714,202 @@ def artifact_preflight(change_dir: Path) -> dict[str, Any]:
4210
5714
  return {"ok": not blocking, "items": items, "blocking": blocking}
4211
5715
 
4212
5716
 
5717
+ def _artifact_policy(project_root: Path) -> dict[str, Any]:
5718
+ path = (
5719
+ project_root
5720
+ / ".harness"
5721
+ / "config"
5722
+ / "artifact-policy.json"
5723
+ )
5724
+ defaults = {
5725
+ "schemaVersion": 1,
5726
+ "maxTotalBytes": 512 * 1024 * 1024,
5727
+ "maxFileBytes": 128 * 1024 * 1024,
5728
+ "contentAddressedMinBytes": 0,
5729
+ "runtimeTtlDays": 7,
5730
+ "stagingTtlDays": 2,
5731
+ }
5732
+ if not path.is_file():
5733
+ return {**defaults, "source": "defaults", "path": str(path)}
5734
+ value = read_json(path)
5735
+ if value.get("schemaVersion") != 1:
5736
+ raise ValueError("artifact policy schemaVersion must be 1")
5737
+ result = {**defaults, **value, "source": "project", "path": str(path)}
5738
+ for field in (
5739
+ "maxTotalBytes",
5740
+ "maxFileBytes",
5741
+ "contentAddressedMinBytes",
5742
+ "runtimeTtlDays",
5743
+ "stagingTtlDays",
5744
+ ):
5745
+ if not isinstance(result.get(field), int) or int(result[field]) < 0:
5746
+ raise ValueError(f"artifact policy {field} must be non-negative")
5747
+ return result
5748
+
5749
+
5750
+ def build_retention_audit(change_dir: Path) -> dict[str, Any]:
5751
+ """Inventory expirable runtime/staging trees without deleting user data."""
5752
+ change_dir = change_dir.resolve()
5753
+ project_root = find_project_root(change_dir)
5754
+ policy = _artifact_policy(project_root)
5755
+ now = dt.datetime.now(dt.timezone.utc)
5756
+ roots = (
5757
+ (
5758
+ "runtime",
5759
+ change_dir / "runtime",
5760
+ int(policy["runtimeTtlDays"]),
5761
+ ),
5762
+ (
5763
+ "archive-operation-staging",
5764
+ project_root / ".harness" / "archive-operations" / "staging",
5765
+ int(policy["stagingTtlDays"]),
5766
+ ),
5767
+ )
5768
+ items: list[dict[str, Any]] = []
5769
+ for category, root, ttl_days in roots:
5770
+ if not root.is_dir():
5771
+ continue
5772
+ for path in sorted(root.iterdir(), key=lambda item: item.name):
5773
+ try:
5774
+ modified = dt.datetime.fromtimestamp(
5775
+ path.stat().st_mtime,
5776
+ tz=dt.timezone.utc,
5777
+ )
5778
+ bytes_on_disk = sum(
5779
+ item.stat().st_size
5780
+ for item in ([path] if path.is_file() else path.rglob("*"))
5781
+ if item.is_file()
5782
+ )
5783
+ except OSError:
5784
+ continue
5785
+ age_seconds = max(0.0, (now - modified).total_seconds())
5786
+ expired = age_seconds >= ttl_days * 24 * 60 * 60
5787
+ try:
5788
+ relative = path.relative_to(project_root).as_posix()
5789
+ except ValueError:
5790
+ relative = str(path)
5791
+ items.append(
5792
+ {
5793
+ "category": category,
5794
+ "path": relative,
5795
+ "modifiedAt": modified.isoformat().replace("+00:00", "Z"),
5796
+ "ageDays": round(age_seconds / (24 * 60 * 60), 3),
5797
+ "ttlDays": ttl_days,
5798
+ "bytes": bytes_on_disk,
5799
+ "status": "EXPIRED" if expired else "RETAINED",
5800
+ }
5801
+ )
5802
+ expired_items = [item for item in items if item["status"] == "EXPIRED"]
5803
+ return {
5804
+ "schemaVersion": 1,
5805
+ "generatedAt": now.isoformat().replace("+00:00", "Z"),
5806
+ "mode": "audit-only",
5807
+ "policy": {
5808
+ "runtimeTtlDays": int(policy["runtimeTtlDays"]),
5809
+ "stagingTtlDays": int(policy["stagingTtlDays"]),
5810
+ "source": policy["source"],
5811
+ },
5812
+ "items": items,
5813
+ "expiredCount": len(expired_items),
5814
+ "bytesPrunable": sum(int(item["bytes"]) for item in expired_items),
5815
+ "remediation": (
5816
+ "remove only expired entries after confirming no active capsule, "
5817
+ "lease, worktree, or archive operation references them"
5818
+ ),
5819
+ }
5820
+
5821
+
5822
+ def evaluate_artifact_budget(change_dir: Path) -> dict[str, Any]:
5823
+ """Fail closed on archive size before the staging copy is created."""
5824
+ change_dir = change_dir.resolve()
5825
+ project_root = find_project_root(change_dir)
5826
+ try:
5827
+ policy = _artifact_policy(project_root)
5828
+ preflight = artifact_preflight(change_dir)
5829
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
5830
+ return {
5831
+ "ok": False,
5832
+ "code": "ARTIFACT_POLICY_INVALID",
5833
+ "message": str(exc),
5834
+ }
5835
+ excluded_parts = {
5836
+ "node_modules",
5837
+ ".venv",
5838
+ "venv",
5839
+ "__pycache__",
5840
+ ".cache",
5841
+ "integration-temp",
5842
+ }
5843
+ files: dict[str, Path] = {}
5844
+ for path in sorted(change_dir.rglob("*")):
5845
+ if (
5846
+ not path.is_file()
5847
+ or any(part in excluded_parts for part in path.parts)
5848
+ or path.name.endswith(".lock")
5849
+ ):
5850
+ continue
5851
+ files[path.relative_to(change_dir).as_posix()] = path
5852
+ for item in preflight.get("items") or []:
5853
+ if item.get("category") != "repository-file":
5854
+ continue
5855
+ source = (project_root / str(item["path"])).resolve()
5856
+ if source.is_file() and source.is_relative_to(project_root):
5857
+ files[f"artifacts/product/{item['path']}"] = source
5858
+ entries = [
5859
+ {
5860
+ "path": relative,
5861
+ "bytes": path.stat().st_size,
5862
+ }
5863
+ for relative, path in files.items()
5864
+ ]
5865
+ entries.sort(key=lambda item: (-int(item["bytes"]), str(item["path"])))
5866
+ total = sum(int(item["bytes"]) for item in entries)
5867
+ max_total = int(policy["maxTotalBytes"])
5868
+ max_file = int(policy["maxFileBytes"])
5869
+ oversized = [
5870
+ item for item in entries if int(item["bytes"]) > max_file
5871
+ ]
5872
+ ok = total <= max_total and not oversized and bool(preflight.get("ok"))
5873
+ return {
5874
+ "ok": ok,
5875
+ "code": (
5876
+ "ARTIFACT_BUDGET_OK"
5877
+ if ok
5878
+ else "ARTIFACT_BUDGET_EXCEEDED"
5879
+ ),
5880
+ "message": (
5881
+ "archive artifacts fit the configured budget"
5882
+ if ok
5883
+ else "archive artifact budget exceeded before staging"
5884
+ ),
5885
+ "artifactCount": len(entries),
5886
+ "totalBytes": total,
5887
+ "maxTotalBytes": max_total,
5888
+ "maxFileBytes": max_file,
5889
+ "oversized": oversized,
5890
+ "largestItems": entries[:10],
5891
+ "policy": policy,
5892
+ "preflightBlocking": preflight.get("blocking") or [],
5893
+ }
5894
+
5895
+
4213
5896
  def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
4214
- """Copy project-root business artifacts into the immutable archive namespace."""
5897
+ """Materialize repository artifacts through a content-addressed object store."""
4215
5898
  change_dir = change_dir.resolve()
4216
5899
  project_root = find_project_root(change_dir)
4217
5900
  preflight = artifact_preflight(change_dir)
4218
5901
  copied: list[str] = []
5902
+ objects: list[dict[str, Any]] = []
5903
+ bytes_added = 0
5904
+ bytes_reused = 0
5905
+ object_root = (
5906
+ project_root
5907
+ / ".harness"
5908
+ / "cache"
5909
+ / "artifacts"
5910
+ / "objects"
5911
+ / "sha256"
5912
+ )
4219
5913
  for item in preflight.get("items") or []:
4220
5914
  if item.get("category") != "repository-file":
4221
5915
  continue
@@ -4223,10 +5917,72 @@ def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
4223
5917
  target = (change_dir / str(item["archivePath"])).resolve()
4224
5918
  if not source.is_relative_to(project_root) or not target.is_relative_to(change_dir):
4225
5919
  raise ValueError(f"artifact materialization escaped boundary: {item['path']}")
5920
+ digest = sha256_file(source)
5921
+ size = source.stat().st_size
5922
+ blob = object_root / digest[:2] / digest
5923
+ reused = blob.is_file()
5924
+ if reused:
5925
+ if blob.stat().st_size != size or sha256_file(blob) != digest:
5926
+ raise ValueError(f"content-addressed blob corrupted: {blob}")
5927
+ bytes_reused += size
5928
+ else:
5929
+ blob.parent.mkdir(parents=True, exist_ok=True)
5930
+ temporary = blob.with_name(
5931
+ f".{blob.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
5932
+ )
5933
+ try:
5934
+ shutil.copy2(source, temporary)
5935
+ if sha256_file(temporary) != digest:
5936
+ raise ValueError(
5937
+ f"content-addressed copy hash mismatch: {source}"
5938
+ )
5939
+ os.replace(temporary, blob)
5940
+ finally:
5941
+ temporary.unlink(missing_ok=True)
5942
+ bytes_added += size
4226
5943
  target.parent.mkdir(parents=True, exist_ok=True)
4227
- shutil.copy2(source, target)
5944
+ target_preexisting = target.exists()
5945
+ materialization = "existing"
5946
+ if target_preexisting:
5947
+ if target.stat().st_size != size or sha256_file(target) != digest:
5948
+ raise ValueError(f"artifact target conflicts: {target}")
5949
+ else:
5950
+ try:
5951
+ os.link(blob, target)
5952
+ materialization = "hardlink"
5953
+ except OSError:
5954
+ shutil.copy2(blob, target)
5955
+ materialization = "copy"
4228
5956
  copied.append(target.relative_to(change_dir).as_posix())
4229
- return {"ok": True, "copied": sorted(copied)}
5957
+ objects.append(
5958
+ {
5959
+ "path": target.relative_to(change_dir).as_posix(),
5960
+ "sha256": "sha256:" + digest,
5961
+ "bytes": size,
5962
+ "blob": blob.relative_to(project_root).as_posix(),
5963
+ "reused": reused,
5964
+ "materialization": materialization,
5965
+ }
5966
+ )
5967
+ result = {
5968
+ "ok": True,
5969
+ "artifactCount": len(objects),
5970
+ "bytesAdded": bytes_added,
5971
+ "bytesReused": bytes_reused,
5972
+ "bytesPruned": 0,
5973
+ "largestItems": sorted(
5974
+ objects,
5975
+ key=lambda entry: (-int(entry["bytes"]), str(entry["path"])),
5976
+ )[:10],
5977
+ "objects": objects,
5978
+ "copied": sorted(copied),
5979
+ }
5980
+ if objects:
5981
+ write_json(
5982
+ change_dir / "evidence" / "artifact-storage.json",
5983
+ {"schemaVersion": 1, **result, "recordedAt": now_iso()},
5984
+ )
5985
+ return result
4230
5986
 
4231
5987
 
4232
5988
  def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
@@ -4383,7 +6139,7 @@ def cmd_finalize(
4383
6139
  )
4384
6140
  operation_id = f"a-{uuid.uuid4().hex[:12]}"
4385
6141
  operation_root = project_root / ".harness" / "archive-operations"
4386
- operation_temp_dir = operation_root / "staging" / operation_id / "c"
6142
+ operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
4387
6143
  operation_record = operation_root / f"{operation_id}.json"
4388
6144
 
4389
6145
  def _restore_finalize_failure() -> None:
@@ -4442,6 +6198,34 @@ def cmd_finalize(
4442
6198
  if not exact_byte["ok"]:
4443
6199
  warnings.append(str(exact_byte["remediation"]))
4444
6200
 
6201
+ artifact_budget = evaluate_artifact_budget(original_change_dir)
6202
+ payload["steps"]["artifact_budget"] = artifact_budget
6203
+ if not artifact_budget.get("ok"):
6204
+ payload["error"] = "artifact budget exceeded before staging"
6205
+ payload["issues"] = [
6206
+ {
6207
+ "code": str(
6208
+ artifact_budget.get("code")
6209
+ or "ARTIFACT_BUDGET_EXCEEDED"
6210
+ ),
6211
+ "severity": "error",
6212
+ "message": str(
6213
+ artifact_budget.get("message")
6214
+ or "artifact budget exceeded"
6215
+ ),
6216
+ }
6217
+ ]
6218
+ payload["original_preserved"] = original_change_dir.is_dir()
6219
+ payload["finalStatus"] = "FAIL"
6220
+ _append_finalize_failure_terminal(
6221
+ split_state_dir
6222
+ if split_state_dir is not None and split_state_dir.is_dir()
6223
+ else original_change_dir,
6224
+ payload["error"],
6225
+ operation_id=operation_id,
6226
+ )
6227
+ return 1, payload
6228
+
4445
6229
  payload["steps"]["service_stop_before_staging"] = run_service_stop(
4446
6230
  original_change_dir
4447
6231
  )
@@ -4635,7 +6419,7 @@ def cmd_finalize(
4635
6419
  "message": ci_gate.get("message"),
4636
6420
  }
4637
6421
  payload["candidateVerification"] = ci_gate
4638
- payload["releaseEligible"] = bool(ci_gate.get("ok"))
6422
+ payload["releaseEligible"] = False
4639
6423
  if not ci_gate.get("ok") and archive_intent == "release-candidate":
4640
6424
  payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
4641
6425
  payload["issues"] = [
@@ -4660,7 +6444,8 @@ def cmd_finalize(
4660
6444
  "schemaVersion": 1,
4661
6445
  "intent": archive_intent,
4662
6446
  "candidateVerificationCode": ci_gate.get("code"),
4663
- "releaseEligible": bool(ci_gate.get("ok")),
6447
+ "releaseEligible": False,
6448
+ "releaseDecisionCode": "PENDING_IDENTITY_AND_REPORT_GATES",
4664
6449
  "recordedAt": now_iso(),
4665
6450
  },
4666
6451
  )
@@ -4741,6 +6526,28 @@ def cmd_finalize(
4741
6526
  write_json(summary_path, summary)
4742
6527
  except OSError as exc:
4743
6528
  warnings.append(f"could not write reportAdequacy: {exc}")
6529
+ release_decision = evaluate_release_eligibility(
6530
+ work_dir,
6531
+ summary,
6532
+ archive_integrity={
6533
+ "ok": True,
6534
+ "code": "ARCHIVE_INTEGRITY_PENDING_MANIFEST",
6535
+ "message": "pre-copy archive checks passed; checksum pending",
6536
+ },
6537
+ report_adequacy=adequacy_result,
6538
+ )
6539
+ summary["releaseDecision"] = release_decision
6540
+ summary["candidateVerification"] = release_decision["checks"][
6541
+ "candidateVerification"
6542
+ ]
6543
+ summary["releaseEligible"] = release_decision["releaseEligible"]
6544
+ payload["releaseDecision"] = release_decision
6545
+ payload["candidateVerification"] = summary["candidateVerification"]
6546
+ payload["releaseEligible"] = summary["releaseEligible"]
6547
+ try:
6548
+ write_json(summary_path, summary)
6549
+ except OSError as exc:
6550
+ warnings.append(f"could not write releaseDecision: {exc}")
4744
6551
  if not adequacy_result.get("ok"):
4745
6552
  payload["issues"] = adequacy_result.get("issues") or []
4746
6553
  payload["error"] = "report adequacy failed; staged operation discarded"
@@ -4748,6 +6555,17 @@ def cmd_finalize(
4748
6555
  payload["warnings"] = warnings
4749
6556
  payload["ok"] = False
4750
6557
  return 1, payload
6558
+ if archive_intent == "release-candidate" and not release_decision[
6559
+ "releaseEligible"
6560
+ ]:
6561
+ payload["issues"] = release_decision.get("issues") or []
6562
+ payload["error"] = (
6563
+ "release eligibility failed; staged operation discarded"
6564
+ )
6565
+ _restore_finalize_failure()
6566
+ payload["warnings"] = warnings
6567
+ payload["ok"] = False
6568
+ return 1, payload
4751
6569
 
4752
6570
  # --- 6. render (Node, else Python fallback) ---
4753
6571
  render_result = render_final_summary(work_dir, summary_path)
@@ -4870,6 +6688,50 @@ def cmd_finalize(
4870
6688
  "exclusionReasons"
4871
6689
  ) or summary["archiveManifest"]["exclusionReasons"]
4872
6690
  summary["archiveManifest"]["coverageChecked"] = coverage.get("checked")
6691
+ final_archive_integrity = {
6692
+ "ok": bool(coverage.get("ok"))
6693
+ and str(coverage.get("checksumStatus") or "")
6694
+ in {"OK", "OK_WITH_EXCLUSIONS"},
6695
+ "code": (
6696
+ "ARCHIVE_INTEGRITY_OK"
6697
+ if bool(coverage.get("ok"))
6698
+ and str(coverage.get("checksumStatus") or "")
6699
+ in {"OK", "OK_WITH_EXCLUSIONS"}
6700
+ else "ARCHIVE_INTEGRITY_FAILED"
6701
+ ),
6702
+ "message": (
6703
+ "final archive manifest byte coverage verified"
6704
+ if bool(coverage.get("ok"))
6705
+ else "final archive manifest byte coverage failed"
6706
+ ),
6707
+ "checksumStatus": coverage.get("checksumStatus"),
6708
+ }
6709
+ final_adequacy = validate_report_adequacy(summary)
6710
+ final_release_decision = evaluate_release_eligibility(
6711
+ work_dir,
6712
+ summary,
6713
+ archive_integrity=final_archive_integrity,
6714
+ report_adequacy=final_adequacy,
6715
+ )
6716
+ summary["archiveIntegrity"] = final_archive_integrity
6717
+ summary["candidateVerification"] = final_release_decision["checks"][
6718
+ "candidateVerification"
6719
+ ]
6720
+ summary["releaseDecision"] = final_release_decision
6721
+ summary["releaseEligible"] = final_release_decision[
6722
+ "releaseEligible"
6723
+ ]
6724
+ summary.setdefault("reportPipeline", {})[
6725
+ "reportAdequacy"
6726
+ ] = final_adequacy
6727
+ summary["reportPipeline"][
6728
+ "releaseDecision"
6729
+ ] = final_release_decision
6730
+ payload["releaseDecision"] = final_release_decision
6731
+ payload["candidateVerification"] = summary[
6732
+ "candidateVerification"
6733
+ ]
6734
+ payload["releaseEligible"] = summary["releaseEligible"]
4873
6735
  write_json(summary_path, summary)
4874
6736
  # Re-render once more for checksumStatus display; remain excluded.
4875
6737
  render_final_summary(work_dir, summary_path)
@@ -4906,7 +6768,15 @@ def cmd_finalize(
4906
6768
  # Honest checksum: FAIL blocks; OK and OK_WITH_EXCLUSIONS (with reasons) pass.
4907
6769
  checksum = str(compare_result.get("checksumStatus") or "")
4908
6770
  checksum_ok = checksum in {"OK", "OK_WITH_EXCLUSIONS"}
4909
- can_close = validate_ok and manifest_ok and checksum_ok
6771
+ release_ok = bool(
6772
+ (payload.get("releaseDecision") or {}).get("releaseEligible")
6773
+ )
6774
+ can_close = (
6775
+ validate_ok
6776
+ and manifest_ok
6777
+ and checksum_ok
6778
+ and (archive_intent == "record-only" or release_ok)
6779
+ )
4910
6780
 
4911
6781
  if not can_close:
4912
6782
  issues_out = list(validate_result.get("issues") or [])
@@ -4922,6 +6792,10 @@ def cmd_finalize(
4922
6792
  ),
4923
6793
  }
4924
6794
  )
6795
+ if archive_intent == "release-candidate" and not release_ok:
6796
+ issues_out.extend(
6797
+ (payload.get("releaseDecision") or {}).get("issues") or []
6798
+ )
4925
6799
  payload["issues"] = issues_out
4926
6800
  payload["error"] = "validate or manifest check failed; staged operation discarded"
4927
6801
  payload["steps"]["delete_original"] = {"ok": False, "deleted": False}