@kontourai/flow-agents 2.3.0 → 3.0.0

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 (301) hide show
  1. package/.github/CODEOWNERS +16 -0
  2. package/.github/workflows/ci.yml +44 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/AGENTS.md +4 -4
  5. package/CHANGELOG.md +62 -0
  6. package/CONTEXT.md +35 -0
  7. package/README.md +9 -3
  8. package/agents/dev.json +1 -1
  9. package/agents/tool-code-reviewer.json +1 -1
  10. package/agents/tool-planner.json +3 -3
  11. package/agents/tool-verifier.json +3 -3
  12. package/build/src/cli/assignment-provider.d.ts +1 -0
  13. package/build/src/cli/assignment-provider.js +748 -0
  14. package/build/src/cli/console-learning-projection.js +3 -2
  15. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  16. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  17. package/build/src/cli/init.js +104 -21
  18. package/build/src/cli/kit.js +37 -6
  19. package/build/src/cli/pull-work-provider.js +1 -1
  20. package/build/src/cli/usage-feedback.js +3 -3
  21. package/build/src/cli/validate-hook-influence.js +1 -0
  22. package/build/src/cli/validate-workflow-artifacts.js +27 -7
  23. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  24. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  25. package/build/src/cli/workflow-sidecar.js +938 -168
  26. package/build/src/cli.js +6 -0
  27. package/build/src/flow-kit/validate.d.ts +23 -0
  28. package/build/src/flow-kit/validate.js +67 -1
  29. package/build/src/index.d.ts +1 -1
  30. package/build/src/index.js +1 -1
  31. package/build/src/lib/flow-resolver.d.ts +6 -4
  32. package/build/src/lib/flow-resolver.js +125 -58
  33. package/build/src/lib/local-artifact-root.d.ts +14 -2
  34. package/build/src/lib/local-artifact-root.js +22 -5
  35. package/build/src/lib/workflow-learning-projection.js +2 -2
  36. package/build/src/runtime-adapters.d.ts +12 -0
  37. package/build/src/runtime-adapters.js +56 -16
  38. package/build/src/tools/build-universal-bundles.js +49 -16
  39. package/build/src/tools/generate-context-map.js +10 -8
  40. package/build/src/tools/validate-source-tree.js +24 -1
  41. package/console.telemetry.json +22 -0
  42. package/context/contracts/artifact-contract.md +40 -7
  43. package/context/contracts/assignment-provider-contract.md +239 -0
  44. package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
  45. package/context/contracts/decision-registry-contract.md +265 -0
  46. package/context/contracts/delivery-contract.md +3 -1
  47. package/context/contracts/execution-contract.md +25 -0
  48. package/context/contracts/governance-adapter-contract.md +2 -0
  49. package/context/contracts/knowledge-store-contract.md +197 -0
  50. package/context/contracts/planning-contract.md +4 -1
  51. package/context/contracts/review-contract.md +7 -3
  52. package/context/contracts/sandbox-policy.md +2 -0
  53. package/context/contracts/standing-directives.md +13 -0
  54. package/context/contracts/verification-contract.md +11 -1
  55. package/context/contracts/work-item-contract.md +3 -1
  56. package/context/deferred/parallelization.md +1 -1
  57. package/context/gate-awareness.md +4 -4
  58. package/context/scripts/git-status.sh +2 -2
  59. package/context/scripts/hooks/config-protection.js +502 -2
  60. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  61. package/context/scripts/hooks/run-hook.js +14 -1
  62. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  63. package/context/scripts/hooks/workflow-steering.js +212 -6
  64. package/context/settings/assignment-provider-settings.json +33 -0
  65. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  66. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  67. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  68. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  69. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  70. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +463 -0
  71. package/docs/agent-system-guidebook.md +3 -3
  72. package/docs/agent-usage-feedback-loop.md +6 -6
  73. package/docs/context-map.md +10 -7
  74. package/docs/decisions/decision-records.md +57 -0
  75. package/docs/decisions/index.md +17 -0
  76. package/docs/decisions/knowledge-store-provider.md +51 -0
  77. package/docs/decisions/model-routing.md +63 -0
  78. package/docs/decisions/promotion-gate.md +52 -0
  79. package/docs/decisions/standing-directives.md +66 -0
  80. package/docs/developer-architecture.md +1 -1
  81. package/docs/fixture-ownership.md +4 -0
  82. package/docs/flow-kit-repository-contract.md +5 -5
  83. package/docs/getting-started.md +12 -2
  84. package/docs/integrations/harness-install.md +2 -1
  85. package/docs/kit-authoring-guide.md +64 -5
  86. package/docs/knowledge-kit.md +5 -1
  87. package/docs/learnings/2026-07-improvement-program.md +203 -0
  88. package/docs/migrations.md +6 -5
  89. package/docs/north-star.md +2 -2
  90. package/docs/operating-layers.md +4 -3
  91. package/docs/repository-structure.md +5 -5
  92. package/docs/skills-map.md +8 -8
  93. package/docs/spec/runtime-hook-surface.md +19 -15
  94. package/docs/spikes/graph-provider-2026-07.md +155 -0
  95. package/docs/standards-register.md +7 -7
  96. package/docs/trust-anchor-adoption.md +105 -4
  97. package/docs/work-item-adapters.md +1 -1
  98. package/docs/workflow-artifact-lifecycle.md +72 -9
  99. package/docs/workflow-eval-strategy.md +3 -3
  100. package/docs/workflow-shared-contracts.md +3 -2
  101. package/docs/workflow-usage-guide.md +260 -21
  102. package/evals/acceptance/DEMO-false-completion.md +20 -11
  103. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  104. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  105. package/evals/acceptance/test_kiro_harness.sh +1 -1
  106. package/evals/ci/antigaming-suite.sh +5 -0
  107. package/evals/ci/run-baseline.sh +71 -1
  108. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  109. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  110. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  111. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  112. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  113. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  114. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  115. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  116. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  117. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  118. package/evals/fixtures/hook-influence/cases.json +32 -0
  119. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  120. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  121. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  122. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  123. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  124. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  125. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  126. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  127. package/evals/integration/test_actor_identity.sh +254 -0
  128. package/evals/integration/test_assignment_provider_github.sh +318 -0
  129. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  130. package/evals/integration/test_builder_step_producers.sh +33 -23
  131. package/evals/integration/test_bundle_install.sh +22 -16
  132. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  133. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  134. package/evals/integration/test_checkpoint_signing.sh +43 -0
  135. package/evals/integration/test_claim_lookup.sh +4 -4
  136. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  137. package/evals/integration/test_command_log_concurrency.sh +8 -8
  138. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  139. package/evals/integration/test_command_log_integrity.sh +16 -16
  140. package/evals/integration/test_console_learning_projection.sh +1 -1
  141. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  142. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  143. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  144. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  145. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  146. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  147. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  148. package/evals/integration/test_flow_kit_repository.sh +2 -0
  149. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  150. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  151. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  152. package/evals/integration/test_gate_lockdown.sh +97 -73
  153. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  154. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  155. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  156. package/evals/integration/test_goal_fit_hook.sh +32 -32
  157. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  158. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  159. package/evals/integration/test_hook_influence_cases.sh +1 -0
  160. package/evals/integration/test_install_merge.sh +262 -1
  161. package/evals/integration/test_kit_identity_trust.sh +6 -6
  162. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  163. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  164. package/evals/integration/test_liveness_verdict.sh +394 -0
  165. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  166. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  167. package/evals/integration/test_promote_gate.sh +178 -0
  168. package/evals/integration/test_publish_delivery.sh +23 -6
  169. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  170. package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
  171. package/evals/integration/test_pull_work_provider.sh +2 -2
  172. package/evals/integration/test_reconcile_soundness.sh +37 -13
  173. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  174. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  175. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  176. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  177. package/evals/integration/test_trust_reconcile.sh +11 -10
  178. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  179. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  180. package/evals/integration/test_trust_reconcile_negatives.sh +759 -0
  181. package/evals/integration/test_usage_feedback_global.sh +5 -5
  182. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  183. package/evals/integration/test_verify_cli.sh +2 -2
  184. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  185. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  186. package/evals/integration/test_workflow_artifacts.sh +33 -0
  187. package/evals/integration/test_workflow_sidecar_writer.sh +802 -74
  188. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  189. package/evals/lib/codex-provider.sh +1 -1
  190. package/evals/lib/node.sh +19 -1
  191. package/evals/run.sh +32 -0
  192. package/evals/static/test_decisions.sh +150 -0
  193. package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
  194. package/evals/static/test_knowledge_providers.sh +23 -0
  195. package/evals/static/test_package.sh +19 -10
  196. package/evals/static/test_universal_bundles.sh +48 -3
  197. package/evals/static/test_workflow_skills.sh +41 -8
  198. package/kits/builder/flows/build.flow.json +3 -74
  199. package/kits/builder/flows/publish-learn.flow.json +90 -0
  200. package/kits/builder/kit.json +11 -0
  201. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  202. package/kits/builder/skills/deliver/SKILL.md +32 -11
  203. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  204. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  205. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  206. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  207. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  208. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  209. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  210. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  211. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  212. package/kits/builder/skills/pull-work/SKILL.md +148 -5
  213. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  214. package/kits/builder/skills/review-work/SKILL.md +24 -1
  215. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  216. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  217. package/kits/catalog.json +6 -0
  218. package/kits/knowledge/kit.json +35 -0
  219. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  220. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  221. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  222. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  223. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  224. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  225. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  226. package/kits/knowledge/providers/git-repo/index.js +236 -0
  227. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  228. package/kits/knowledge/providers/health/index.js +153 -0
  229. package/kits/knowledge/providers/index.js +24 -0
  230. package/kits/knowledge/providers/lib/model.js +91 -0
  231. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  232. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  233. package/kits/knowledge/providers/work-item/index.js +204 -0
  234. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  235. package/kits/veritas-governance/docs/README.md +75 -0
  236. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  237. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  238. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  239. package/kits/veritas-governance/kit.json +16 -0
  240. package/package.json +11 -5
  241. package/packaging/README.md +2 -2
  242. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  243. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  244. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  245. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  246. package/packaging/manifest.json +9 -10
  247. package/schemas/assignment-provider-settings.schema.json +125 -0
  248. package/schemas/decision-record.schema.json +109 -0
  249. package/schemas/knowledge/edge.schema.json +54 -0
  250. package/schemas/knowledge/health-report.schema.json +45 -0
  251. package/schemas/knowledge/node.schema.json +49 -0
  252. package/schemas/knowledge/proposal.schema.json +53 -0
  253. package/schemas/workflow-evidence.schema.json +1 -1
  254. package/schemas/workflow-state.schema.json +5 -0
  255. package/scripts/README.md +8 -3
  256. package/scripts/check-content-boundary.cjs +1 -1
  257. package/scripts/check-decisions.cjs +356 -0
  258. package/scripts/ci/derive-claim-status.mjs +74 -0
  259. package/scripts/ci/trust-reconcile.js +957 -103
  260. package/scripts/git-status.sh +2 -2
  261. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  262. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  263. package/scripts/hooks/config-protection.js +102 -30
  264. package/scripts/hooks/evidence-capture.js +3 -3
  265. package/scripts/hooks/lib/actor-identity.js +368 -0
  266. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  267. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  268. package/scripts/hooks/lib/liveness-policy.js +127 -0
  269. package/scripts/hooks/lib/liveness-read.js +105 -12
  270. package/scripts/hooks/lib/liveness-write.js +46 -0
  271. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  272. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  273. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  274. package/scripts/hooks/stop-goal-fit.js +157 -26
  275. package/scripts/hooks/workflow-steering.js +77 -3
  276. package/scripts/install-codex-home.sh +138 -38
  277. package/scripts/statusline/flow-agents-statusline.js +2 -2
  278. package/src/cli/assignment-provider.ts +845 -0
  279. package/src/cli/console-learning-projection.ts +3 -2
  280. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  281. package/src/cli/init.ts +101 -21
  282. package/src/cli/kit.ts +37 -6
  283. package/src/cli/public-api.test.mjs +58 -6
  284. package/src/cli/pull-work-provider.ts +1 -1
  285. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  286. package/src/cli/usage-feedback.ts +3 -3
  287. package/src/cli/validate-hook-influence.ts +1 -0
  288. package/src/cli/validate-workflow-artifacts.ts +27 -7
  289. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  290. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  291. package/src/cli/workflow-sidecar.ts +933 -168
  292. package/src/cli.ts +6 -0
  293. package/src/flow-kit/validate.ts +74 -1
  294. package/src/index.ts +6 -2
  295. package/src/lib/flow-resolver.ts +123 -55
  296. package/src/lib/local-artifact-root.ts +24 -5
  297. package/src/lib/workflow-learning-projection.ts +2 -2
  298. package/src/runtime-adapters.ts +61 -15
  299. package/src/tools/build-universal-bundles.ts +49 -16
  300. package/src/tools/generate-context-map.ts +10 -8
  301. package/src/tools/validate-source-tree.ts +24 -1
@@ -16,13 +16,56 @@
16
16
  * e. checkpoint-only bundle (no evidence/claims) → DIVERGENCE (full bundle required)
17
17
  *
18
18
  * Exit codes:
19
- * 0 — fresh verify passed AND no divergence (or no bundle present)
20
- * 1 fresh verify failed OR any divergence detected OR compile-only fallback
19
+ * 0 — fresh verify passed AND no divergence (bundle reconciled clean, OR bundle
20
+ * absence is covered by a well-formed, in-scope delivery/DECLARED marker)
21
+ * 1 — fresh verify failed OR any divergence detected OR compile-only fallback OR
22
+ * bundle absent with no valid delivery/DECLARED marker (bundle-required by default)
21
23
  *
22
- * Fail-open on bundle absence: if no bundle is provided (and none auto-discovered at
23
- * delivery/trust.bundle or delivery/trust.checkpoint.json), only the fresh verify is
24
- * enforced. Fail-closed on divergence: any claimed-pass command CI cannot confirm blocks.
25
- * Fail-closed on compile-only: if no comprehensive verify is configured, exits 1.
24
+ * Bundle-required by default (ADR 0022 §1): if no bundle is provided (and none
25
+ * auto-discovered at delivery/trust.bundle or delivery/trust.checkpoint.json), the
26
+ * reconciler no longer fails open. It looks for a delivery/DECLARED no-agent-delivery
27
+ * marker (ADR 0022 §2) a single {scope, reason, approved_by, declared_at} object, or a
28
+ * JSON array of such objects, resolved via the same path seam bundle discovery uses (see
29
+ * resolveDeliveryCandidates()). A well-formed marker whose `scope` matches this change
30
+ * (`ref:`, `commit:`/`commit:a..b`, `author:`, or `branch-prefix:` — see matchesScope())
31
+ * exempts Step 2 ONLY; Step 1 (fresh verify) still runs unconditionally and is never
32
+ * skipped. An absent, malformed (missing any required field), or out-of-scope marker is
33
+ * treated the same as bundle-absence-with-no-exemption: it produces a
34
+ * `bundle-required-no-declared-marker` issue and exits 1. Fail-closed on divergence: any
35
+ * claimed-pass command CI cannot confirm blocks. Fail-closed on compile-only: if no
36
+ * comprehensive verify is configured, exits 1.
37
+ *
38
+ * "Bundle-required" means a bundle FOR THIS CHANGE (ADR 0022 addendum §2), not merely a
39
+ * bundle reachable at the checkout: an AUTO-DISCOVERED bundle whose trust.checkpoint.json
40
+ * `commit_sha` neither equals nor is a git-ancestor of this change's own commit sha
41
+ * (TRUST_RECONCILE_SHA/GITHUB_SHA) is treated as ABSENT, not present — see
42
+ * bundleAttestsThisChange(). Without this, a bundle inherited unchanged from main via
43
+ * update-branch/rebase (live incident: PR #278) would satisfy Step 2 trivially for every
44
+ * dependabot/release-please PR, since neither bot's branch ever runs the deliver skill
45
+ * itself. A stale bundle prints a loud `stale bundle ignored — attests <theirs>, this
46
+ * change is <ours>` line and then falls through to delivery/DECLARED resolution exactly
47
+ * as if no bundle had been discovered at all. This check applies ONLY to auto-discovery —
48
+ * an explicit --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests,
49
+ * programmatic callers), not something silently inherited from committed git tree state.
50
+ *
51
+ * Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement (the
52
+ * `bundle-required-no-declared-marker` fail-closed branch, and the staleness-gate
53
+ * consequence of it above) applies ONLY when this run is gating a proposed change —
54
+ * TRUST_RECONCILE_EVENT (set from `github.event_name` by
55
+ * .github/workflows/trust-reconcile.yml) resolving to anything other than 'push'
56
+ * (conservative default: unknown/absent → enforce; see resolveEnforcementEvent()). On a
57
+ * 'push' event, an absent or stale auto-discovered bundle is a LOUD NO-OP (`push event:
58
+ * ... — skipping Step 2 (gating happened on the PR run)`, exit 0), never a failure and
59
+ * never a delivery/DECLARED requirement — a push run on a protected branch happens AFTER
60
+ * the gating PR run already passed (branch protection already excludes direct pushes),
61
+ * and after a squash-merge the merge commit almost never has git ancestry back to the
62
+ * feature-branch commit a checkpoint was sealed against, so bundle absence/staleness on
63
+ * push is the EXPECTED case, not a divergence (reproduced empirically as a HIGH
64
+ * launch-blocker: without this scoping, every delivery would fail Trust Reconcile on
65
+ * `main` immediately post-merge and Phase 2 attestation minting would stop). Step 1
66
+ * (fresh verify) is unaffected by event scoping — it always runs. A fresh auto-discovered
67
+ * bundle on push still reconciles normally (Step 2 runs, forming the Phase 2 attestation
68
+ * basis) — only absence/staleness is treated differently by event.
26
69
  *
27
70
  * Inputs (CLI args take precedence over env):
28
71
  * --bundle <path> Delivered trust.bundle (JSON) path. May also be a
@@ -32,13 +75,27 @@
32
75
  * --repo-root <path> Repository root. Default: TRUST_RECONCILE_REPO_ROOT or cwd.
33
76
  *
34
77
  * Environment fallbacks:
35
- * TRUST_RECONCILE_BUNDLE Path to delivered bundle (same as --bundle).
36
- * TRUST_RECONCILE_COMMANDS Comma- or newline-separated canonical commands.
37
- * TRUST_RECONCILE_REPO_ROOT Repository root.
78
+ * TRUST_RECONCILE_BUNDLE Path to delivered bundle (same as --bundle).
79
+ * TRUST_RECONCILE_COMMANDS Comma- or newline-separated canonical commands.
80
+ * TRUST_RECONCILE_REPO_ROOT Repository root.
81
+ * TRUST_RECONCILE_COMMAND_TIMEOUT_MS Timeout (ms) for each canonical/manifest command
82
+ * run (Step 1 fresh-verify + on-demand manifest
83
+ * reconcile). Default: 600000 (10 min).
84
+ * TRUST_RECONCILE_REF/_ACTOR/_SHA Override the delivery/DECLARED scope-matching
85
+ * context (ref / actor / commit sha) that otherwise
86
+ * falls back to GITHUB_HEAD_REF|GITHUB_REF,
87
+ * GITHUB_ACTOR, GITHUB_SHA respectively — for
88
+ * deterministic local/eval testing.
89
+ * TRUST_RECONCILE_EVENT CI event name (`github.event_name`) driving
90
+ * event-scoped bundle-required enforcement (ADR
91
+ * 0022 addendum, part 4) — see
92
+ * resolveEnforcementEvent(). Default (unset/
93
+ * unrecognized): treated as a gating event.
38
94
  *
39
95
  * Auto-discovery (when no --bundle or TRUST_RECONCILE_BUNDLE is set):
40
- * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root.
41
- * If neither exists, continues fail-open (fresh verify only).
96
+ * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
97
+ * (via the resolveDeliveryCandidates() seam). If neither exists, falls through to
98
+ * delivery/DECLARED marker resolution — see "Bundle-required by default" above.
42
99
  *
43
100
  * Canonical commands resolution (fail-closed on compile-only):
44
101
  * Priority: CLI --commands > TRUST_RECONCILE_COMMANDS env > package.json
@@ -46,9 +103,11 @@
46
103
  * "no comprehensive trust-reconcile-verify configured" — refuses to attest a
47
104
  * compile-only check.
48
105
  *
49
- * NOTE: This job is intended to be a REQUIRED status check in GitHub branch
50
- * protection (making it the un-disablable CI anchor). Enabling it as required is
51
- * a server-side branch-protection step see .github/workflows/trust-reconcile.yml.
106
+ * NOTE: This job IS ALREADY a required status check in GitHub branch protection on
107
+ * `main` (verified via the GitHub branch-protection API — required_status_checks.contexts
108
+ * includes "Trust Reconcile", enforce_admins: true). Disabling or downgrading that
109
+ * requirement is a server-side branch-protection change — it cannot be done by editing
110
+ * this file or .github/workflows/trust-reconcile.yml.
52
111
  *
53
112
  * Programmatic use:
54
113
  * const { runTrustReconcile } = require('./trust-reconcile.js');
@@ -88,103 +147,223 @@ function isPassingValue(v) {
88
147
  // CI reconciler and the stop-goal-fit verifier apply the identical exit-code-mask
89
148
  // heuristic — see that module for the rules.
90
149
 
150
+ /**
151
+ * Default manifest/canonical-command execution timeout (ms). Overridable via
152
+ * TRUST_RECONCILE_COMMAND_TIMEOUT_MS. 10 minutes is comfortably above the slowest
153
+ * required-lane check (this repo's own CI lanes allow up to ~10 min); the prior
154
+ * hardcoded 180000ms (3 min) killed legitimately slow evals (e.g.
155
+ * evals/integration/test_workflow_sidecar_writer.sh, ~150s on a fast Mac, >180s on
156
+ * GitHub Actions runners) mid-run, producing a spurious divergence rather than a real
157
+ * failure.
158
+ */
159
+ const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
160
+
161
+ /** Resolve the manifest/canonical-command timeout: TRUST_RECONCILE_COMMAND_TIMEOUT_MS env, else default. */
162
+ function resolveCommandTimeoutMs() {
163
+ const raw = process.env.TRUST_RECONCILE_COMMAND_TIMEOUT_MS;
164
+ if (raw !== undefined && raw !== '') {
165
+ const parsed = Number(raw);
166
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
167
+ }
168
+ return DEFAULT_COMMAND_TIMEOUT_MS;
169
+ }
170
+
91
171
  /**
92
172
  * Run a single shell command under bash, capturing exit code.
93
- * @returns {{ cmd, exitCode, passed, stdout, stderr }}
173
+ * @returns {{ cmd, exitCode, passed, timedOut, timeoutMs, stdout, stderr }}
94
174
  */
95
175
  function runCommand(cmd, repoRoot) {
176
+ const timeoutMs = resolveCommandTimeoutMs();
96
177
  const result = spawnSync('bash', ['-c', cmd], {
97
178
  cwd: repoRoot,
98
179
  encoding: 'utf8',
99
- timeout: 180000,
180
+ timeout: timeoutMs,
100
181
  killSignal: 'SIGKILL',
101
182
  stdio: ['ignore', 'pipe', 'pipe'],
102
183
  });
103
184
  const exitCode = (result.status !== null && result.status !== undefined)
104
185
  ? result.status
105
186
  : 1;
187
+ // spawnSync sets result.error (ETIMEDOUT) when the timeout kills the process — this is
188
+ // NOT a real command failure and must be distinguishable in the FAIL log line so a
189
+ // future reader chases the timeout config, not a phantom test bug.
190
+ const timedOut = !!(result.error && result.error.code === 'ETIMEDOUT');
106
191
  return {
107
192
  cmd,
108
193
  exitCode,
109
194
  passed: exitCode === 0 && !result.error,
195
+ timedOut,
196
+ timeoutMs,
110
197
  stdout: result.stdout || '',
111
198
  stderr: result.stderr || '',
112
199
  };
113
200
  }
114
201
 
115
202
  /**
116
- * Parse the trust.bundle and extract every evidence item asserting a command PASSED.
117
- * Returns an array of { cmd, claimId, evId, claimType, noEvidence? } objects.
203
+ * Classify a trust.bundle's claims into: reconcilable command claims (test_output +
204
+ * execution.label), session-local claims (attestation/observation/citation), never-captured
205
+ * or unbacked command claims (not-run divergence), and command-backed claims carrying a
206
+ * waiver (waiver-on-command divergence). Returns
207
+ * { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
118
208
  *
119
- * Source of truth: evidence[].execution.label is the command string recorded at
120
- * capture time. evidence[].passing (normalized) means the agent claimed this passed.
209
+ * Source of truth: evidence[].execution.label is the command string recorded at capture time.
210
+ * evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
211
+ * trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
121
212
  *
122
- * Also collects workflow.check.command claims (or any claim with value "pass") that
123
- * have no matching evidence item these represent never-captured claimed passes
124
- * and will produce a not-run divergence (Part-B logic mirroring stop-goal-fit.js).
213
+ * WS8 iteration-2 hardening:
214
+ * - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
215
+ * (Surface's default when unset) but which did NOT reconcile i.e. it has no
216
+ * manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
217
+ * claim either reconciles against the manifest or is a divergence; it is never accepted on
218
+ * self-reported status. (Previously only the literal claimType `workflow.check.command`
219
+ * was guarded, so a fabricated kind:"test" claim with no command slipped through.)
220
+ * - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
221
+ * command-backed check reconciles against CI or fails; it cannot be waived.
125
222
  */
126
- function getClaimedPassCommands(bundle) {
223
+ function classifyBundleClaims(bundle) {
127
224
  const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
128
225
  const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
129
226
 
130
- // Build a map from claimId -> claim for fast lookup
131
227
  const claimById = new Map();
228
+ for (const c of claims) if (c && c.id) claimById.set(c.id, c);
229
+
230
+ // Evidence indexing. A missing evidenceType defaults to test_output for backward
231
+ // compatibility with pre-classification bundles (same default classifyEvidence uses).
232
+ const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
233
+ const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
234
+ // WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
235
+ // claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
236
+ // human_attestation/attestation/external claim with no --command is otherwise
237
+ // indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
238
+ const claimEvidenceType = new Map();
239
+ for (const ev of evidence) {
240
+ if (!ev || !ev.claimId) continue;
241
+ const evType = ev.evidenceType || 'test_output';
242
+ if (evType !== 'test_output') {
243
+ if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
244
+ continue;
245
+ }
246
+ claimHasTestOutputEvidence.add(ev.claimId);
247
+ if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
248
+ }
249
+
250
+ // finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
251
+ const waiverOnCommand = [];
132
252
  for (const c of claims) {
133
- if (c && c.id) claimById.set(c.id, c);
253
+ if (!c || !c.id) continue;
254
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
255
+ if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
256
+ waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
257
+ }
134
258
  }
135
259
 
136
- const commands = [];
260
+ // (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
261
+ // carry an execution.label, and assert pass. Session-local evidenceTypes
262
+ // (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
263
+ // document_citation, calculation_trace) are NOT reconciled per-command — they are handled
264
+ // by the session-local/waiver path below.
265
+ const reconcilable = [];
266
+ const reconcilableClaimIds = new Set();
137
267
  const seen = new Set();
138
-
139
- // (A) Evidence items with execution.label and a passing value
140
268
  for (const ev of evidence) {
141
269
  if (!ev || !ev.execution || !ev.execution.label) continue;
142
- if (!isPassingValue(ev.passing)) continue; // only claimed-pass items
143
-
270
+ if (!isPassingValue(ev.passing)) continue;
271
+ const evType = ev.evidenceType || 'test_output';
272
+ if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
144
273
  const cmd = normalizeCmd(ev.execution.label);
145
- if (!cmd || seen.has(cmd)) continue;
274
+ if (!cmd) continue;
275
+ reconcilableClaimIds.add(ev.claimId);
276
+ if (seen.has(cmd)) continue;
146
277
  seen.add(cmd);
147
-
148
278
  const claim = claimById.get(ev.claimId);
149
- const claimType = claim ? String(claim.claimType || '') : '';
150
-
151
- commands.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType });
279
+ reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
152
280
  }
153
281
 
154
- // (B) Claims with claimType workflow.check.command (or any claim asserting pass)
155
- // that have NO matching evidence item with execution.label — never-captured claimed passes.
156
- const evidenceClaimIds = new Set(
157
- evidence
158
- .filter(ev => ev && ev.claimId && ev.execution && ev.execution.label)
159
- .map(ev => ev.claimId)
160
- );
161
-
282
+ // (B) Session-local claims, never-captured command claims, and unreconciled test_output.
283
+ const sessionLocal = [];
284
+ const noEvidenceCommand = [];
285
+ const seenClaims = new Set();
162
286
  for (const c of claims) {
163
287
  if (!c || !c.id || typeof c.claimType !== 'string') continue;
164
- // Require claimType === workflow.check.command OR any claim asserting pass with no evidence
165
- const isCommandClaim = c.claimType === 'workflow.check.command';
166
- const assertsPass = isPassingValue(c.value) || c.status === 'verified';
167
- if (!isCommandClaim && !assertsPass) continue;
168
- if (evidenceClaimIds.has(c.id)) continue; // already covered in (A)
169
-
170
- // Use fieldOrBehavior or value as command identifier (may be non-executable — that's ok,
171
- // the key is that no evidence captured this pass claim, so emit not-run divergence).
172
- const rawCmd = c.fieldOrBehavior || c.value || '';
173
- const cmd = normalizeCmd(rawCmd);
174
- const synthetic = cmd || `[claim:${c.id}:${c.claimType}]`;
175
- if (seen.has(synthetic)) continue;
176
- seen.add(synthetic);
177
-
178
- commands.push({
179
- cmd: synthetic,
288
+ // #267/#282: a superseded critique write is HISTORY excluded from reconcile evaluation so a
289
+ // resolved session converges (a fail critique that a later same-reviewer pass superseded no
290
+ // longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
291
+ // a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
292
+ if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
293
+ if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
294
+ if (seenClaims.has(c.id)) continue;
295
+ const status = String(c.status || '');
296
+ const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
297
+ const isFailing = status === 'disputed' || status === 'rejected';
298
+ if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
299
+ seenClaims.add(c.id);
300
+
301
+ // finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
302
+ // (it has test_output evidence but no manifest-matchable execution.label — otherwise it
303
+ // would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
304
+ // manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
305
+ if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
306
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
307
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
308
+ continue;
309
+ }
310
+
311
+ // A workflow.check.command claim with no captured (labeled) evidence is a never-captured
312
+ // claimed pass — not-run divergence (anti-gaming teeth preserved).
313
+ if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
314
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
315
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
316
+ continue;
317
+ }
318
+
319
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
320
+ sessionLocal.push({
180
321
  claimId: c.id,
181
- evId: null,
182
322
  claimType: c.claimType,
183
- noEvidence: true,
323
+ assertedStatus: status,
324
+ value: c.value,
325
+ waiver: (waiver && typeof waiver === 'object') ? waiver : null,
326
+ subject: c.subjectId || c.fieldOrBehavior || c.id,
327
+ evidenceType: claimEvidenceType.get(c.id) || 'unknown',
184
328
  });
185
329
  }
186
330
 
187
- return commands;
331
+ return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
332
+ }
333
+
334
+ /**
335
+ * WS8 (finding 3): re-derive every claim's status CI-side from the bundle's OWN
336
+ * evidence/events/policies, using @kontourai/surface's canonical deriveClaimStatus (the same
337
+ * function the producer used). The reconciler MUST NOT trust a bundle's self-reported
338
+ * `claim.status`; it compares the asserted status to this re-derived status and treats a
339
+ * mismatch as a `status-misassertion` divergence.
340
+ *
341
+ * Runs the ESM helper scripts/ci/derive-claim-status.mjs via spawnSync (Surface is ESM-only;
342
+ * this reconciler is CJS with a synchronous entrypoint). Surface is resolved from the helper's
343
+ * own location (the reconciler's node_modules), so it works even when reconciling an adopter
344
+ * repo that does not itself depend on Surface.
345
+ *
346
+ * @returns {Map<string,string|null>|null} claimId → derived status; null if re-derivation is
347
+ * unavailable (Surface could not load / helper failed to run). A per-claim null value means
348
+ * that specific claim could not be derived.
349
+ */
350
+ function deriveClaimStatuses(bundlePath, repoRoot) {
351
+ const helper = path.join(__dirname, 'derive-claim-status.mjs');
352
+ if (!fs.existsSync(helper)) return null;
353
+ const res = spawnSync('node', [helper, bundlePath], { cwd: repoRoot, encoding: 'utf8', timeout: 60000 });
354
+ if (res.status !== 0 || !res.stdout) {
355
+ if (res.stderr) process.stderr.write(`[trust-reconcile] status re-derivation unavailable: ${res.stderr.trim()}
356
+ `);
357
+ return null;
358
+ }
359
+ try {
360
+ const obj = JSON.parse(res.stdout);
361
+ const m = new Map();
362
+ for (const [k, v] of Object.entries(obj)) m.set(k, v);
363
+ return m;
364
+ } catch {
365
+ return null;
366
+ }
188
367
  }
189
368
 
190
369
  // ---------------------------------------------------------------------------
@@ -192,7 +371,7 @@ function getClaimedPassCommands(bundle) {
192
371
  // ---------------------------------------------------------------------------
193
372
 
194
373
  function parseArgs(argv) {
195
- const args = { bundle: null, commands: [], repoRoot: null };
374
+ const args = { bundle: null, commands: [], repoRoot: null, manifest: null };
196
375
  for (let i = 0; i < argv.length; i++) {
197
376
  const arg = argv[i];
198
377
  const next = argv[i + 1];
@@ -203,11 +382,104 @@ function parseArgs(argv) {
203
382
  i++;
204
383
  } else if (arg === '--repo-root' && next) {
205
384
  args.repoRoot = next; i++;
385
+ } else if (arg === '--manifest' && next) {
386
+ args.manifest = next; i++;
206
387
  }
207
388
  }
208
389
  return args;
209
390
  }
210
391
 
392
+ // ---------------------------------------------------------------------------
393
+ // WS8 (ADR 0020): reconcile manifest resolution + slugification.
394
+ // The manifest is a list of named {id, command} entries the reconciler treats as
395
+ // individually CI-reconcilable. It is resolved (priority order) from:
396
+ // 1. CLI --manifest <json>
397
+ // 2. env TRUST_RECONCILE_MANIFEST <json>
398
+ // 3. package.json "trust-reconcile-manifest" (inline array, OR a string command
399
+ // that emits the JSON — this repo points it at run-baseline.sh --manifest-json)
400
+ // 4. evals/ci/run-baseline.sh --manifest-json (this repo's live LANE_* registry —
401
+ // every entry runs in a required ci.yml lane by construction)
402
+ // 5. legacy fallback: the resolved fresh-verify commands as a manifest of size N
403
+ // (so the historical single-command behavior is a strict subset — backward compat).
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /** Slugify a label the same way evals/ci/run-baseline.sh's slugify() does. */
407
+ function slugifyLabel(s) {
408
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
409
+ }
410
+
411
+ /** Normalize a raw manifest array (of strings or {id, command}) to {id, command}[]. */
412
+ function normalizeManifestEntries(raw) {
413
+ if (!Array.isArray(raw)) return null;
414
+ const out = [];
415
+ for (const item of raw) {
416
+ if (typeof item === 'string') {
417
+ const cmd = normalizeCmd(item);
418
+ if (cmd) out.push({ id: slugifyLabel(cmd), command: cmd });
419
+ } else if (item && typeof item === 'object' && typeof item.command === 'string') {
420
+ const cmd = normalizeCmd(item.command);
421
+ if (cmd) out.push({ id: item.id ? String(item.id) : slugifyLabel(cmd), command: cmd });
422
+ }
423
+ }
424
+ return out.length ? out : null;
425
+ }
426
+
427
+ function parseManifestJson(text, label) {
428
+ try {
429
+ return normalizeManifestEntries(JSON.parse(text));
430
+ } catch {
431
+ process.stderr.write(`[trust-reconcile] ignoring ${label}: not a valid JSON array of {id, command} entries\n`);
432
+ return null;
433
+ }
434
+ }
435
+
436
+ function runBaselineManifest(repoRoot) {
437
+ const script = path.join(repoRoot, 'evals', 'ci', 'run-baseline.sh');
438
+ if (!fs.existsSync(script)) return null;
439
+ const res = spawnSync('bash', [script, '--manifest-json'], { cwd: repoRoot, encoding: 'utf8', timeout: 30000 });
440
+ if (res.status !== 0 || !res.stdout) return null;
441
+ return parseManifestJson(res.stdout, 'evals/ci/run-baseline.sh --manifest-json');
442
+ }
443
+
444
+ /**
445
+ * Resolve the reconcile manifest. Returns { entries: {id, command}[], source }.
446
+ * canonicalCommands is used only for the legacy size-N fallback (tier 5).
447
+ */
448
+ function resolveManifest(args, repoRoot, canonicalCommands) {
449
+ if (args.manifest) {
450
+ const m = parseManifestJson(args.manifest, '--manifest');
451
+ if (m) return { entries: m, source: 'cli:--manifest' };
452
+ }
453
+ if (process.env.TRUST_RECONCILE_MANIFEST) {
454
+ const m = parseManifestJson(process.env.TRUST_RECONCILE_MANIFEST, 'TRUST_RECONCILE_MANIFEST');
455
+ if (m) return { entries: m, source: 'env:TRUST_RECONCILE_MANIFEST' };
456
+ }
457
+ try {
458
+ const pkgPath = path.join(repoRoot, 'package.json');
459
+ if (fs.existsSync(pkgPath)) {
460
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
461
+ const raw = (pkg && pkg['trust-reconcile-manifest'])
462
+ || (pkg && pkg.scripts && pkg.scripts['trust-reconcile-manifest']);
463
+ if (Array.isArray(raw)) {
464
+ const m = normalizeManifestEntries(raw);
465
+ if (m) return { entries: m, source: 'package.json:trust-reconcile-manifest (inline array)' };
466
+ } else if (typeof raw === 'string' && raw.trim()) {
467
+ const res = spawnSync('bash', ['-c', raw], { cwd: repoRoot, encoding: 'utf8', timeout: 60000 });
468
+ if (res.status === 0 && res.stdout) {
469
+ const m = parseManifestJson(res.stdout, 'package.json trust-reconcile-manifest emit');
470
+ if (m) return { entries: m, source: `package.json:trust-reconcile-manifest (${raw})` };
471
+ }
472
+ }
473
+ }
474
+ } catch { /* ignore */ }
475
+ const rb = runBaselineManifest(repoRoot);
476
+ if (rb) return { entries: rb, source: 'evals/ci/run-baseline.sh' };
477
+ const legacy = (canonicalCommands || [])
478
+ .map((c) => ({ id: slugifyLabel(normalizeCmd(c)), command: normalizeCmd(c) }))
479
+ .filter((e) => e.command);
480
+ return { entries: legacy, source: 'legacy:fresh-verify-commands' };
481
+ }
482
+
211
483
  /**
212
484
  * Resolve the list of canonical verify commands.
213
485
  * Priority: CLI --commands > TRUST_RECONCILE_COMMANDS env > package.json scripts key.
@@ -242,22 +514,370 @@ function resolveCanonicalCommands(args, repoRoot) {
242
514
  return null;
243
515
  }
244
516
 
517
+ // ---------------------------------------------------------------------------
518
+ // delivery/ path-resolution seam (#335-aware) + ADR 0022 DECLARED-marker support.
519
+ // ---------------------------------------------------------------------------
520
+
521
+ const DELIVERY_DIR = 'delivery';
522
+
523
+ /**
524
+ * Single seam: every delivery/ path resolution in this file routes through here.
525
+ * #335 (per-session delivery paths, e.g. delivery/<session-slug>/<filename> instead of
526
+ * one shared path) is expected to change ONLY this function's body — e.g. glob
527
+ * delivery/<slug-glob>/<filename> and return every match in a defined precedence order,
528
+ * or
529
+ * resolve the current PR's session slug and return its single subpath. discoverBundle()
530
+ * and discoverDeclaredMarker() both iterate whatever candidates this returns and pick the
531
+ * first existing one, so a future per-session layout composes with zero call-site changes.
532
+ */
533
+ function resolveDeliveryCandidates(repoRoot, filename) {
534
+ return [path.join(repoRoot, DELIVERY_DIR, filename)];
535
+ }
536
+
245
537
  /**
246
538
  * Auto-discover the delivery bundle when no explicit path is provided.
247
- * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root.
248
- * Returns null if neither is present (fail-open on bundle absence).
539
+ * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
540
+ * (via resolveDeliveryCandidates()). Returns null if neither is present the caller
541
+ * then falls through to delivery/DECLARED marker resolution (ADR 0022 §1); bundle
542
+ * absence is no longer fail-open on its own.
249
543
  */
250
544
  function discoverBundle(repoRoot) {
251
- const candidates = [
252
- path.join(repoRoot, 'delivery', 'trust.bundle'),
253
- path.join(repoRoot, 'delivery', 'trust.checkpoint.json'),
254
- ];
255
- for (const candidate of candidates) {
545
+ for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
546
+ for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
547
+ if (fs.existsSync(candidate)) return candidate;
548
+ }
549
+ }
550
+ return null;
551
+ }
552
+
553
+ /**
554
+ * Auto-discover the delivery/DECLARED no-agent-delivery marker (ADR 0022 §2) via the
555
+ * same resolveDeliveryCandidates() seam bundle discovery uses. Returns null if absent.
556
+ */
557
+ function discoverDeclaredMarker(repoRoot) {
558
+ for (const candidate of resolveDeliveryCandidates(repoRoot, 'DECLARED')) {
256
559
  if (fs.existsSync(candidate)) return candidate;
257
560
  }
258
561
  return null;
259
562
  }
260
563
 
564
+ /** Required, non-empty-string fields on every delivery/DECLARED marker entry (ADR 0022 §2). */
565
+ const DECLARED_REQUIRED_FIELDS = ['scope', 'reason', 'approved_by', 'declared_at'];
566
+
567
+ /**
568
+ * Parse and validate a delivery/DECLARED marker file. Accepts either a single
569
+ * {scope, reason, approved_by, declared_at} object, or a JSON array of such objects
570
+ * (small, backward-compatible extension so one marker file can cover multiple
571
+ * non-agent-delivery scopes, e.g. dependabot AND release-please, without a blanket scope).
572
+ *
573
+ * @returns {{ok:false, reason:string}} on unreadable/malformed-JSON input, else
574
+ * {{ok:true, wellFormed:object[], malformed:{entry:*, missing:string[]}[], totalEntries:number}}
575
+ * — every entry is validated; malformed entries are reported individually rather than
576
+ * invalidating the whole file (so one bad entry cannot silently mask a good one).
577
+ */
578
+ function parseDeclaredMarker(markerPath) {
579
+ let raw;
580
+ try {
581
+ raw = fs.readFileSync(markerPath, 'utf8');
582
+ } catch (err) {
583
+ return { ok: false, reason: `delivery/DECLARED marker is malformed: not valid JSON (unreadable: ${err.message})` };
584
+ }
585
+ let parsed;
586
+ try {
587
+ parsed = JSON.parse(raw);
588
+ } catch {
589
+ return { ok: false, reason: 'delivery/DECLARED marker is malformed: not valid JSON' };
590
+ }
591
+ if (Array.isArray(parsed) && parsed.length === 0) {
592
+ return { ok: false, reason: 'delivery/DECLARED marker is malformed: array contains zero entries' };
593
+ }
594
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
595
+ const wellFormed = [];
596
+ const malformed = [];
597
+ for (const entry of entries) {
598
+ const missing = DECLARED_REQUIRED_FIELDS.filter((field) => {
599
+ const value = entry && typeof entry === 'object' ? entry[field] : undefined;
600
+ return typeof value !== 'string' || value.trim() === '';
601
+ });
602
+ if (missing.length > 0) {
603
+ malformed.push({ entry, missing });
604
+ } else {
605
+ wellFormed.push(entry);
606
+ }
607
+ }
608
+ return { ok: true, wellFormed, malformed, totalEntries: entries.length };
609
+ }
610
+
611
+ /**
612
+ * Resolve the scope-matching context for delivery/DECLARED evaluation:
613
+ * ref = TRUST_RECONCILE_REF || GITHUB_HEAD_REF || GITHUB_REF stripped of 'refs/heads/' || ''
614
+ * actor = TRUST_RECONCILE_ACTOR || GITHUB_ACTOR || ''
615
+ * sha = TRUST_RECONCILE_SHA || GITHUB_SHA || ''
616
+ * An empty context value never matches any scope segment (see matchesScope) — this
617
+ * prevents an empty-string scope segment from becoming an accidental wildcard.
618
+ */
619
+ function resolveScopeContext(repoRoot) {
620
+ const githubRef = process.env.GITHUB_REF || '';
621
+ const strippedGithubRef = githubRef.startsWith('refs/heads/')
622
+ ? githubRef.slice('refs/heads/'.length)
623
+ : githubRef;
624
+ const ref = process.env.TRUST_RECONCILE_REF
625
+ || process.env.GITHUB_HEAD_REF
626
+ || strippedGithubRef
627
+ || '';
628
+ const actor = process.env.TRUST_RECONCILE_ACTOR || process.env.GITHUB_ACTOR || '';
629
+ const sha = process.env.TRUST_RECONCILE_SHA || process.env.GITHUB_SHA || '';
630
+ return { ref, actor, sha, repoRoot };
631
+ }
632
+
633
+ /**
634
+ * Resolve the CI event this run is gating (ADR 0022 addendum, part 4 — event-scoped
635
+ * bundle-required enforcement). `TRUST_RECONCILE_EVENT` (set by
636
+ * .github/workflows/trust-reconcile.yml from `github.event_name`) is the source.
637
+ *
638
+ * Conservative default: an unknown/absent event value is treated as a GATING event
639
+ * (enforce) — every existing local/test caller that sets no TRUST_RECONCILE_EVENT keeps
640
+ * today's fail-closed behavior unchanged, and a misconfigured/unrecognized CI event fails
641
+ * toward the STRICTER gate, never the looser post-merge one. Only the literal value
642
+ * 'push' is treated as a post-merge run — bundle-required enforcement (and its
643
+ * staleness-gate consequence) applies to every other event, including
644
+ * `workflow_dispatch`, which is not guaranteed to be post-merge.
645
+ *
646
+ * Residual (HIGH, documented not solved): the VALUE `github.event_name` resolves to is
647
+ * authoritative GitHub Actions context, but the WORKFLOW FILE that sets
648
+ * `TRUST_RECONCILE_EVENT: ${{ github.event_name }}` is itself PR-editable content on a
649
+ * `pull_request` run — a PR could hardcode `TRUST_RECONCILE_EVENT: push` in its own copy
650
+ * of the workflow to fake the post-merge no-op path. This is closed by required
651
+ * code-owner review on `.github/workflows/trust-reconcile.yml` (#225, not yet server-side
652
+ * enforced), the same residual class as every other self-asserted CI input in this file.
653
+ * Step 1 (fresh verify) is unaffected by event scoping either way — it always runs and
654
+ * must pass regardless of which branch this function's caller takes.
655
+ */
656
+ function resolveEnforcementEvent() {
657
+ return (process.env.TRUST_RECONCILE_EVENT || '').trim() || 'pull_request';
658
+ }
659
+
660
+ /**
661
+ * Best-effort single-direction ancestor check: is `ancestorSha` an ancestor of (or equal
662
+ * to) `descendantSha`? Uses `git merge-base --is-ancestor` (a commit is its own ancestor).
663
+ * Never throws — underivable (git missing, shallow clone, unknown sha, etc.) → false.
664
+ * Shared by isShaInRange() (commit: scope ranges) and bundleAttestsThisChange() (the
665
+ * bundle-ownership staleness check, ADR 0022 addendum §2).
666
+ */
667
+ function isAncestorCommit(repoRoot, ancestorSha, descendantSha) {
668
+ if (!ancestorSha || !descendantSha) return false;
669
+ try {
670
+ const res = spawnSync('git', ['merge-base', '--is-ancestor', ancestorSha, descendantSha], {
671
+ cwd: repoRoot, stdio: 'ignore',
672
+ });
673
+ return !!res && !res.error && res.status === 0;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Best-effort commit-range membership check: is `sha` reachable from `to` AND is `from`
681
+ * an ancestor of `sha`? (a commit is its own ancestor, so sha === from or sha === to both
682
+ * count as in-range). Never throws — underivable → false, never a match.
683
+ */
684
+ function isShaInRange(repoRoot, sha, from, to) {
685
+ if (!sha || !from || !to) return false;
686
+ return isAncestorCommit(repoRoot, from, sha) && isAncestorCommit(repoRoot, sha, to);
687
+ }
688
+
689
+ /**
690
+ * Match a SINGLE scope condition (one of the four forms) against the resolved context.
691
+ * Extracted from matchesScope() so a compound (space-separated, AND) scope can validate
692
+ * each condition independently and still fail closed if any one is unrecognized.
693
+ * String equality/prefix ops ONLY — zero RegExp construction from marker content (AC4).
694
+ * Supported forms: `ref:<exact>`, `commit:<sha>` or `commit:<from>..<to>` (range, via
695
+ * isShaInRange), `author:<exact>`, `branch-prefix:<prefix>` (via String#startsWith).
696
+ * An unrecognized prefix does not match (not an error — fails closed silently). Every
697
+ * arm requires the COMPARED CONTEXT value to be non-empty, so an empty ref/actor/sha
698
+ * (e.g. running outside CI with no override) can never be treated as a wildcard match.
699
+ */
700
+ function matchesScopeCondition(condition, ctx) {
701
+ const s = typeof condition === 'string' ? condition : '';
702
+
703
+ if (s.startsWith('ref:')) {
704
+ const want = s.slice('ref:'.length);
705
+ return !!want && !!ctx.ref && ctx.ref === want;
706
+ }
707
+
708
+ if (s.startsWith('commit:')) {
709
+ const want = s.slice('commit:'.length);
710
+ if (!want || !ctx.sha) return false;
711
+ const rangeSep = want.indexOf('..');
712
+ if (rangeSep === -1) {
713
+ return ctx.sha === want;
714
+ }
715
+ const from = want.slice(0, rangeSep);
716
+ const to = want.slice(rangeSep + 2);
717
+ if (!from || !to) return false;
718
+ return isShaInRange(ctx.repoRoot, ctx.sha, from, to);
719
+ }
720
+
721
+ if (s.startsWith('author:')) {
722
+ const want = s.slice('author:'.length);
723
+ return !!want && !!ctx.actor && ctx.actor === want;
724
+ }
725
+
726
+ if (s.startsWith('branch-prefix:')) {
727
+ const want = s.slice('branch-prefix:'.length);
728
+ return !!want && !!ctx.ref && ctx.ref.startsWith(want);
729
+ }
730
+
731
+ // Unrecognized prefix (or empty condition) — does not match, not an error.
732
+ return false;
733
+ }
734
+
735
+ /**
736
+ * Match a delivery/DECLARED marker's `scope` string against the resolved context.
737
+ *
738
+ * `scope` is either a single condition (backward compatible, unchanged behavior) OR
739
+ * multiple space-separated conditions that must ALL match (AND) — e.g.
740
+ * `"author:github-actions[bot] branch-prefix:release-please--"` binds an identity claim
741
+ * (`author:`, bound to the platform-set `GITHUB_ACTOR`, not attacker-controlled) to a
742
+ * narrower blast radius (`branch-prefix:`) rather than trusting either alone. This
743
+ * closes a security-review finding: `ref:`/`branch-prefix:` ALONE match against
744
+ * `GITHUB_HEAD_REF`, which is PUSHER-CONTROLLED on a fork PR — a `ref:`-only or
745
+ * `branch-prefix:`-only scope can therefore be satisfied by anyone who can name a
746
+ * branch, not just the bot the marker is meant to identify. Compound (AND) scopes let a
747
+ * marker require both signals. Any unrecognized-prefix condition anywhere in a compound
748
+ * scope makes the WHOLE scope never match (fail closed) — see matchesScopeCondition().
749
+ * Zero RegExp construction from marker content (AC4) — space-splitting uses a plain
750
+ * string split, not a marker-derived pattern.
751
+ */
752
+ function matchesScope(scope, ctx) {
753
+ const s = typeof scope === 'string' ? scope : '';
754
+ const conditions = s.split(' ').map((c) => c.trim()).filter(Boolean);
755
+ if (conditions.length === 0) return false;
756
+ return conditions.every((condition) => matchesScopeCondition(condition, ctx));
757
+ }
758
+
759
+ /**
760
+ * Orchestrate delivery/DECLARED resolution for the bundle-absent path: discover → parse
761
+ * → scope-match → return {exempt, marker?, diagnostic?}. First in-scope, well-formed
762
+ * entry wins (array form); every entry is validated regardless, and malformed entries
763
+ * are logged individually so a bad entry never silently masks a good one.
764
+ *
765
+ * Diagnostic strings below are pinned (grepped by callers/evals) — do not reword.
766
+ */
767
+ function resolveDeclaredExemption(repoRoot, ctx) {
768
+ const markerPath = discoverDeclaredMarker(repoRoot);
769
+ if (!markerPath) {
770
+ return {
771
+ exempt: false,
772
+ diagnostic: 'no bundle present and no delivery/DECLARED marker found — bundle-required by default (ADR 0022 §1); publish delivery/trust.bundle, or add a well-formed, in-scope delivery/DECLARED marker',
773
+ };
774
+ }
775
+
776
+ const parsed = parseDeclaredMarker(markerPath);
777
+ if (!parsed.ok) {
778
+ return { exempt: false, diagnostic: parsed.reason };
779
+ }
780
+
781
+ for (const { missing } of parsed.malformed) {
782
+ process.stderr.write(`[trust-reconcile] delivery/DECLARED marker is malformed: missing required field(s) [${missing.join(', ')}]\n`);
783
+ }
784
+
785
+ if (parsed.wellFormed.length === 0) {
786
+ const first = parsed.malformed[0];
787
+ const diagnostic = first
788
+ ? `delivery/DECLARED marker is malformed: missing required field(s) [${first.missing.join(', ')}]`
789
+ : 'delivery/DECLARED marker is malformed: not valid JSON';
790
+ return { exempt: false, diagnostic };
791
+ }
792
+
793
+ for (const marker of parsed.wellFormed) {
794
+ if (matchesScope(marker.scope, ctx)) {
795
+ return { exempt: true, marker };
796
+ }
797
+ }
798
+
799
+ return {
800
+ exempt: false,
801
+ diagnostic: 'delivery/DECLARED marker present but out of scope for this change',
802
+ };
803
+ }
804
+
805
+ /**
806
+ * Extract the strongest available commit-identity binding from a discovered bundle/
807
+ * checkpoint, for the bundle-ownership staleness check (ADR 0022 addendum §2). Returns
808
+ * the commit sha string the bundle/checkpoint attests to, or null if no usable binding
809
+ * exists (fail-closed: the caller treats null the same as a mismatch — stale/absent).
810
+ *
811
+ * trust.checkpoint.json's envelope carries `commit_sha` directly (stamped by
812
+ * sealTrustCheckpoint() from `git rev-parse HEAD` at seal time —
813
+ * src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
814
+ * (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
815
+ * by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
816
+ * sibling delivery/trust.checkpoint.json (same resolveDeliveryCandidates() seam) for the
817
+ * binding — publishDelivery() always copies both together.
818
+ */
819
+ function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
820
+ if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
821
+ return bundleJson.commit_sha.trim();
822
+ }
823
+ for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
824
+ if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
825
+ try {
826
+ const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
827
+ if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
828
+ return checkpoint.commit_sha.trim();
829
+ }
830
+ } catch {
831
+ // Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
832
+ }
833
+ }
834
+ return null;
835
+ }
836
+
837
+ /**
838
+ * Does the discovered bundle/checkpoint attest THIS change? "Bundle-required" (ADR 0022
839
+ * §1) means a bundle FOR THIS CHANGE, not any bundle merely reachable at the checkout — a
840
+ * bundle inherited unchanged from main via update-branch/rebase (live incident: PR #278, a
841
+ * dependabot PR whose stale main-authored trust.checkpoint.json otherwise satisfied Step 2
842
+ * trivially) must NOT be treated as owned by this change.
843
+ *
844
+ * "Attests this change" = the bundle's commit_sha equals this change's own sha, OR is an
845
+ * ancestor of it (the bundle was sealed earlier in the SAME open PR's own linear commit
846
+ * history, before a later delivery commit — the normal, legitimate shape; sealTrustCheckpoint
847
+ * necessarily stamps a commit that precedes its own delivery commit, so exact equality alone
848
+ * would reject every legitimate delivery). Reuses the same `git merge-base --is-ancestor`
849
+ * primitive `commit:` scope ranges already use (isAncestorCommit()) rather than a new check.
850
+ *
851
+ * FAIL CLOSED on ambiguity: no extractable commit_sha (bundle/checkpoint carries none, or
852
+ * this change's own sha is unresolvable) → never treated as fresh/owned.
853
+ *
854
+ * CORRECTNESS REQUIRES the caller's CI context to provide two things (see
855
+ * .github/workflows/trust-reconcile.yml's "CI-context contract" comment for the concrete
856
+ * settings this repo's own required check uses):
857
+ * 1. A full-history (non-shallow) checkout. `git merge-base --is-ancestor` needs the
858
+ * parent commit objects; a shallow clone (the common `fetch-depth: 1` default) does
859
+ * not have them, so the underlying `git merge-base` call exits 128 (unresolvable).
860
+ * 2. `changeSha` bound to the actual commit a checkpoint could plausibly have been
861
+ * sealed against — on a GitHub `pull_request` trigger this is the PR's own HEAD
862
+ * commit, NOT the synthetic merge commit `GITHUB_SHA` resolves to by default (a
863
+ * seal-checkpoint run never stamps a merge commit that did not exist yet).
864
+ * Shallow-clone / missing-object conditions are DEGRADED-BUT-SAFE, never silently
865
+ * accepted: isAncestorCommit() (and therefore this function) fails toward STALE, the same
866
+ * as any other unresolvable ancestry, so a real fresh bundle under a misconfigured shallow
867
+ * checkout is loudly, diagnosably rejected (the `stale bundle ignored — attests <theirs>,
868
+ * this change is <ours>` line still names both shas — an operator can immediately see the
869
+ * mismatch is a checkout-depth artifact, not a real staleness), rather than a bundle CI
870
+ * cannot actually verify slipping through as accepted.
871
+ *
872
+ * @returns {{fresh: boolean, bundleSha: string|null}}
873
+ */
874
+ function bundleAttestsThisChange(repoRoot, bundlePath, bundleJson, changeSha) {
875
+ const bundleSha = extractBundleCommitSha(repoRoot, bundlePath, bundleJson);
876
+ if (!bundleSha || !changeSha) return { fresh: false, bundleSha };
877
+ const fresh = bundleSha === changeSha || isAncestorCommit(repoRoot, bundleSha, changeSha);
878
+ return { fresh, bundleSha };
879
+ }
880
+
261
881
  // ---------------------------------------------------------------------------
262
882
  // Core reconcile function (exported for programmatic use)
263
883
  // ---------------------------------------------------------------------------
@@ -275,16 +895,62 @@ function discoverBundle(repoRoot) {
275
895
  * @param {string|null} [opts.repoRoot] - Repo root path. null = TRUST_RECONCILE_REPO_ROOT env or cwd.
276
896
  * @returns {number} Exit code: 0 = pass, 1 = fail/divergence.
277
897
  */
278
- function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {}) {
898
+ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, manifest = null } = {}) {
279
899
  const resolvedRepoRoot = path.resolve(
280
900
  repoRoot || process.env.TRUST_RECONCILE_REPO_ROOT || process.cwd()
281
901
  );
282
902
 
283
- // Resolve bundle path: explicit arg > env > auto-discovery > null (fail-open)
284
- const bundlePath = bundle
285
- || process.env.TRUST_RECONCILE_BUNDLE
286
- || discoverBundle(resolvedRepoRoot)
287
- || null;
903
+ // Resolve bundle path: explicit arg > env > auto-discovery > null. The staleness check
904
+ // below (ADR 0022 addendum §2) applies ONLY to the auto-discovered case — an explicit
905
+ // --bundle/TRUST_RECONCILE_BUNDLE is a deliberate caller choice (tests, programmatic
906
+ // callers), not something silently picked up from committed git tree state, and real CI
907
+ // (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
908
+ // auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
909
+ const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
910
+ let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
911
+ const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
912
+
913
+ // Scope-matching context (ref/actor/sha) — resolved once, reused by both the
914
+ // bundle-ownership staleness check below and the delivery/DECLARED exemption path
915
+ // further down (bundle-absent branch).
916
+ const scopeCtx = resolveScopeContext(resolvedRepoRoot);
917
+
918
+ // Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
919
+ // (and the staleness-gate consequence of it, immediately below) applies ONLY when this
920
+ // run is gating a proposed change. A push run on main happens AFTER the gating PR run
921
+ // already passed — see the isPostMergeEvent branch further down for the full rationale.
922
+ const enforcementEvent = resolveEnforcementEvent();
923
+ const isPostMergeEvent = enforcementEvent === 'push';
924
+
925
+ // Bundle-ownership staleness check (ADR 0022 addendum §2): an AUTO-DISCOVERED bundle
926
+ // must attest THIS change, not a historically-committed bundle inherited via
927
+ // update-branch/rebase (live incident: PR #278). A stale bundle is treated as ABSENT —
928
+ // on a gating event it falls through to the exact same DECLARED/fail-closed branch as
929
+ // no-bundle-at-all; on a push event it falls through to a loud no-op instead (below).
930
+ // A bundle that fails to parse here is NOT treated as stale (left alone so the existing
931
+ // Step 2 "failed to read bundle" diagnostic still fires with its own clearer message).
932
+ // bundleWasDiscardedAsStale is tracked only to word the push-event no-op line accurately
933
+ // (an auto-discovered-but-stale bundle vs. no bundle ever being found are both a no-op
934
+ // on push, but "inherited bundle does not attest" is only true of the former).
935
+ let bundleWasDiscardedAsStale = false;
936
+ if (bundlePath && bundleWasAutoDiscovered) {
937
+ let bundleJsonForStaleness = null;
938
+ try {
939
+ bundleJsonForStaleness = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
940
+ } catch {
941
+ bundleJsonForStaleness = null;
942
+ }
943
+ if (bundleJsonForStaleness) {
944
+ const { fresh, bundleSha } = bundleAttestsThisChange(resolvedRepoRoot, bundlePath, bundleJsonForStaleness, scopeCtx.sha);
945
+ if (!fresh) {
946
+ const theirs = bundleSha || 'no usable commit binding';
947
+ const ours = scopeCtx.sha || 'unknown (no TRUST_RECONCILE_SHA/GITHUB_SHA)';
948
+ process.stdout.write(`[trust-reconcile] stale bundle ignored — attests ${theirs}, this change is ${ours}\n`);
949
+ bundlePath = null;
950
+ bundleWasDiscardedAsStale = true;
951
+ }
952
+ }
953
+ }
288
954
 
289
955
  const canonicalCommands = resolveCanonicalCommands({ commands: commands || [] }, resolvedRepoRoot);
290
956
 
@@ -299,13 +965,28 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
299
965
  return 1;
300
966
  }
301
967
 
968
+ // WS8 (ADR 0020): resolve the reconcile manifest (named {id, command} entries used
969
+ // for per-command classification-aware reconcile). The canonical (fresh-verify)
970
+ // commands above remain the standalone CI-truth run; the manifest is the set of
971
+ // required-lane commands a test_output claim is permitted to reconcile against.
972
+ const manifestResolution = resolveManifest({ manifest }, resolvedRepoRoot, canonicalCommands);
973
+ const manifestEntries = manifestResolution.entries;
974
+ const manifestByCmd = new Map();
975
+ for (const e of manifestEntries) manifestByCmd.set(normalizeCmd(e.command), e);
976
+
302
977
  process.stdout.write('[trust-reconcile] starting CI trust anchor reconcile\n');
303
978
  process.stdout.write(`[trust-reconcile] repo-root: ${resolvedRepoRoot}\n`);
304
979
  process.stdout.write(`[trust-reconcile] canonical commands: ${canonicalCommands.join(' | ')}\n`);
980
+ process.stdout.write(`[trust-reconcile] manifest: ${manifestEntries.length} entr${manifestEntries.length === 1 ? 'y' : 'ies'} (source: ${manifestResolution.source})\n`);
981
+ for (const e of manifestEntries) {
982
+ process.stdout.write(`[trust-reconcile] manifest[${e.id}] = ${e.command}\n`);
983
+ }
305
984
  if (bundlePath) {
306
985
  process.stdout.write(`[trust-reconcile] bundle: ${bundlePath}\n`);
986
+ } else if (isPostMergeEvent) {
987
+ process.stdout.write('[trust-reconcile] no bundle present — push event (post-merge run): Step 1 still runs; Step 2/delivery-DECLARED enforcement does not apply here (ADR 0022 addendum, part 4)\n');
307
988
  } else {
308
- process.stdout.write('[trust-reconcile] no bundle present — fresh verify only (fail-open on bundle absence)\n');
989
+ process.stdout.write('[trust-reconcile] no bundle present — bundle-required by default (ADR 0022 §1); Step 1 still runs, then checking delivery/DECLARED for a Step-2 exemption\n');
309
990
  }
310
991
 
311
992
  // -------------------------------------------------------------------------
@@ -337,6 +1018,8 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
337
1018
 
338
1019
  if (result.passed) {
339
1020
  process.stdout.write(`[trust-reconcile] PASS: ${cmd}\n`);
1021
+ } else if (result.timedOut) {
1022
+ process.stderr.write(`[trust-reconcile] FAIL: ${cmd} (TIMED OUT after ${result.timeoutMs}ms — this is a timeout kill, not a real command failure; if this command is a legitimately slow check, raise TRUST_RECONCILE_COMMAND_TIMEOUT_MS)\n`);
340
1023
  } else {
341
1024
  process.stderr.write(`[trust-reconcile] FAIL: ${cmd} (exit ${result.exitCode})\n`);
342
1025
  if (result.stderr) {
@@ -407,25 +1090,45 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
407
1090
  }
408
1091
  }
409
1092
  } else {
410
- // Full bundle: extract claimed-pass commands from evidence items + claims
411
- const claimedPasses = getClaimedPassCommands(bundle);
412
- process.stdout.write(`[trust-reconcile] found ${claimedPasses.length} claimed-pass command(s) in bundle\n`);
1093
+ // WS8 (ADR 0020): classify the bundle's claims into reconcilable (test_output,
1094
+ // manifest-matched), session-local (waiver/Surface-status), and never-captured
1095
+ // command claims instead of forcing every claim to match one composite command.
1096
+ const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = classifyBundleClaims(bundle);
1097
+ process.stdout.write(`[trust-reconcile] classified bundle: ${reconcilable.length} reconcilable command claim(s), ${sessionLocal.length} session-local claim(s), ${noEvidenceCommand.length} unbacked/unreconciled command claim(s), ${waiverOnCommand.length} waiver-on-command claim(s)\n`);
413
1098
 
414
- for (const { cmd, claimId, claimType, noEvidence } of claimedPasses) {
415
- const normalCmd = normalizeCmd(cmd);
1099
+ // finding 3: re-derive every claim's status CI-side (never trust self-reported
1100
+ // claim.status). Consumed by the session-local loop below to catch status-misassertion.
1101
+ const derivedStatus = deriveClaimStatuses(bundlePath, resolvedRepoRoot);
1102
+ if (derivedStatus) {
1103
+ process.stdout.write(`[trust-reconcile] re-derived ${derivedStatus.size} claim status(es) CI-side from the bundle's own evidence/events/policies (self-reported claim.status is NOT trusted)\n`);
1104
+ } else {
1105
+ process.stderr.write(`[trust-reconcile] WARNING: CI-side status re-derivation is unavailable — every session-local pass-asserting claim will fail closed (cannot verify self-reported status)\n`);
1106
+ }
416
1107
 
417
- // (d) Claims with no evidence are an immediate not-run divergence.
418
- // A pass was claimed but never captured CI cannot confirm it.
419
- if (noEvidence) {
420
- issues.push({
421
- type: 'not-run',
422
- cmd,
423
- message: `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`,
424
- });
425
- continue;
426
- }
1108
+ // finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
1109
+ // waiver is a divergence a command-backed check reconciles against CI or fails; it
1110
+ // cannot be waived away.
1111
+ for (const { claimId, claimType, subject } of waiverOnCommand) {
1112
+ issues.push({
1113
+ type: 'waiver-on-command-check',
1114
+ message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
1115
+ });
1116
+ }
427
1117
 
428
- // (a) Laundering operator check must come first (most specific signal)
1118
+ // not-run divergences: never-captured command claims (no evidence) AND test_output
1119
+ // claims that did not reconcile (no manifest-matchable execution.label).
1120
+ for (const { cmd, claimId, claimType, reason } of noEvidenceCommand) {
1121
+ const message = reason === 'test_output-unreconciled'
1122
+ ? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
1123
+ : `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
1124
+ issues.push({ type: 'not-run', cmd, message });
1125
+ }
1126
+
1127
+ // Reconcilable test_output claims: laundering → manifest match → CI fresh result.
1128
+ for (const { cmd } of reconcilable) {
1129
+ const normalCmd = normalizeCmd(cmd);
1130
+
1131
+ // (a) Laundering operator check — must come first (most specific signal).
429
1132
  if (hasLaunderingOperator(cmd)) {
430
1133
  issues.push({
431
1134
  type: 'laundering',
@@ -435,21 +1138,41 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
435
1138
  continue;
436
1139
  }
437
1140
 
438
- // (b/c) Look up in CI's fresh run results
439
- const ciResult = ciResults.get(normalCmd);
440
-
441
- if (!ciResult) {
442
- // (c) CI never ran this claimed-pass command — fail-closed
1141
+ // A test_output claim MUST name a manifest (required-lane) command. An agent
1142
+ // cannot self-label an arbitrary command test_output to dodge the manifest.
1143
+ const entry = manifestByCmd.get(normalCmd);
1144
+ if (!entry) {
443
1145
  issues.push({
444
1146
  type: 'not-run',
445
1147
  cmd,
446
- message: `trust divergence: agent claimed '${cmd}' passed; CI did not run this command (not in canonical verify set)`,
1148
+ message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
447
1149
  });
448
1150
  continue;
449
1151
  }
450
1152
 
1153
+ // Prefer the fresh-verify Step 1 result; otherwise re-run this (manifest, and
1154
+ // therefore required-lane) command fresh now. Only manifest commands are ever
1155
+ // run on demand, so the set is bounded by the registry.
1156
+ let ciResult = ciResults.get(normalCmd);
1157
+ if (!ciResult) {
1158
+ process.stdout.write(`[trust-reconcile] manifest reconcile: running '${entry.command}' (id: ${entry.id})\n`);
1159
+ const r = runCommand(entry.command, resolvedRepoRoot);
1160
+ ciResult = { exitCode: r.exitCode, passed: r.passed };
1161
+ ciResults.set(normalCmd, ciResult);
1162
+ if (r.passed) {
1163
+ process.stdout.write(`[trust-reconcile] PASS: ${entry.command}\n`);
1164
+ } else if (r.timedOut) {
1165
+ process.stderr.write(`[trust-reconcile] FAIL: ${entry.command} (TIMED OUT after ${r.timeoutMs}ms — this is a timeout kill, not a real command failure; if this manifest command is a legitimately slow check, raise TRUST_RECONCILE_COMMAND_TIMEOUT_MS)\n`);
1166
+ } else {
1167
+ process.stderr.write(`[trust-reconcile] FAIL: ${entry.command} (exit ${r.exitCode})\n`);
1168
+ if (r.stderr) {
1169
+ const tail = r.stderr.trim().split('\n').slice(-5).join('\n');
1170
+ process.stderr.write(`[trust-reconcile] --- stderr tail ---\n${tail}\n[trust-reconcile] ---\n`);
1171
+ }
1172
+ }
1173
+ }
1174
+
451
1175
  if (!ciResult.passed) {
452
- // (b) Claimed pass + CI fresh FAIL = divergence
453
1176
  issues.push({
454
1177
  type: 'divergence',
455
1178
  cmd,
@@ -459,9 +1182,139 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null } = {
459
1182
  continue;
460
1183
  }
461
1184
 
462
- // Clean: claimed pass, CI confirms pass
463
- process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' — claimed pass, CI fresh run = PASS\n`);
1185
+ process.stdout.write(`[trust-reconcile] RECONCILED: '${cmd}' (manifest id: ${entry.id}) — claimed pass, CI fresh run = PASS\n`);
1186
+ }
1187
+
1188
+ // Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
1189
+ // (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
1190
+ // status. WS8 iteration-2 hardening:
1191
+ // - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
1192
+ // claim.status. A mismatch is a `status-misassertion` divergence. If re-derivation
1193
+ // is unavailable we fail closed (`status-underivable`) — we never fall back to
1194
+ // trusting the bundle's own status.
1195
+ // - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
1196
+ // ONLY with a waiver (printed as a loud WAIVED line). An unwaived `assumed` claim is
1197
+ // an `unwaived-assumed` divergence (restores pre-WS8 semantics where `assumed` alone
1198
+ // never satisfied assertsPass).
1199
+ //
1200
+ // WS8 iteration-4 (converged iteration-3 finding, both gates): a session-local claim
1201
+ // that re-derives `verified` with NO waiver is, by definition, backed only by
1202
+ // human_attestation/attestation/external/etc. evidence with no --command — i.e.
1203
+ // `deriveClaimStatus` only proves the bundle is internally self-consistent, never that
1204
+ // the underlying attestation is true. A fully fabricated bundle (fake claim + fake
1205
+ // evidence + fake event, all hand-authored to agree) is INDISTINGUISHABLE from a real
1206
+ // one at this layer. This was previously printed as a quiet `SESSION-LOCAL OK` line
1207
+ // indistinguishable from a genuinely reconciled check. It is now always printed as a
1208
+ // loud, distinct `ATTESTED (not independently verifiable at L0)` marker — see ADR 0020
1209
+ // Residuals. This does NOT change the exit code (attestations are not blocked at L0;
1210
+ // blocking every honest human-attestation use is not the fix) — it is a visibility-only
1211
+ // change so a reviewer/downstream tool can grep for and count exactly how many claims in
1212
+ // a passing bundle rest on bundle-internal consistency alone rather than independent
1213
+ // (CI fresh-run or cryptographically-signed) verification.
1214
+ let attestedCount = 0;
1215
+ for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal) {
1216
+ // finding 3: re-derive; never trust the asserted status.
1217
+ let status;
1218
+ if (!derivedStatus) {
1219
+ issues.push({
1220
+ type: 'status-underivable',
1221
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
1222
+ });
1223
+ continue;
1224
+ }
1225
+ const derived = derivedStatus.get(claimId);
1226
+ if (derived === undefined || derived === null) {
1227
+ issues.push({
1228
+ type: 'status-underivable',
1229
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
1230
+ });
1231
+ continue;
1232
+ }
1233
+ if (derived !== assertedStatus) {
1234
+ issues.push({
1235
+ type: 'status-misassertion',
1236
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
1237
+ });
1238
+ continue;
1239
+ }
1240
+ status = derived;
1241
+
1242
+ if (status === 'disputed' || status === 'rejected') {
1243
+ issues.push({
1244
+ type: 'session-local-failed',
1245
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
1246
+ });
1247
+ continue;
1248
+ }
1249
+ // finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
1250
+ // session-local claim passes, and it is printed loudly. `verified` still passes on its
1251
+ // own re-derived status.
1252
+ if (waiver && waiver.reason && waiver.approved_by) {
1253
+ process.stdout.write(`[trust-reconcile] WAIVED: ${subject} (${claimType}) status=${status} — ${waiver.reason} (approved by ${waiver.approved_by})\n`);
1254
+ continue;
1255
+ }
1256
+ if (status === 'verified') {
1257
+ attestedCount++;
1258
+ process.stdout.write(`[trust-reconcile] ATTESTED (not independently verifiable at L0): '${claimId}' (${claimType}) evidenceType=${evidenceType} — accepted on bundle-internal consistency only; see ADR 0020 Residuals\n`);
1259
+ continue;
1260
+ }
1261
+ if (status === 'assumed') {
1262
+ issues.push({
1263
+ type: 'unwaived-assumed',
1264
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
1265
+ });
1266
+ continue;
1267
+ }
1268
+ issues.push({
1269
+ type: 'unwaived-session-local',
1270
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
1271
+ });
464
1272
  }
1273
+
1274
+ // WS8 iteration-4: always emit the summary count, even when zero, so a passing bundle
1275
+ // with zero attested claims is distinguishable in the log from one where the count line
1276
+ // is simply absent (grep-stable for downstream tooling / reviewers).
1277
+ process.stdout.write(`[trust-reconcile] ${attestedCount} attested claim(s) accepted without independent verification\n`);
1278
+ }
1279
+ } else if (isPostMergeEvent) {
1280
+ // Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
1281
+ // (and delivery/DECLARED as its only exemption) exists to gate a PROPOSED change —
1282
+ // it applies on pull_request (and any other/unknown event, conservatively). A push
1283
+ // run on `main` happens AFTER the gating PR run already passed: branch protection
1284
+ // already excludes direct pushes from anyone but the merge machinery, so this run's
1285
+ // job is Step 1 fresh-verify (unconditional, already ran above) plus, IF the
1286
+ // just-merged bundle happens to still attest this exact commit, Step 2 reconcile as
1287
+ // the basis for Phase 2 attestation minting (handled by the `if (bundlePath)` branch
1288
+ // above when fresh — unaffected by this branch). A squash-merge commit on main almost
1289
+ // never has git ancestry back to the feature-branch commit a checkpoint was sealed
1290
+ // against (squash discards the original commit graph — see the staleness-check
1291
+ // addendum), so bundlePath being null here on push is the EXPECTED, common case, not
1292
+ // a divergence — reproduced empirically (synthetic squash pair) as a HIGH
1293
+ // launch-blocker: without this branch, EVERY delivery would falsely fail Trust
1294
+ // Reconcile on main immediately post-merge and Phase 2 attestation minting would stop
1295
+ // entirely. Requiring a delivery/DECLARED marker on every push to main would also be
1296
+ // nonsensical — there is no "change" being gated here to declare an exemption from.
1297
+ // This is a loud no-op: exit 0, no issue pushed.
1298
+ const detail = bundleWasDiscardedAsStale
1299
+ ? 'inherited bundle does not attest this commit'
1300
+ : 'no bundle to reconcile';
1301
+ process.stdout.write(`[trust-reconcile] push event: ${detail} — skipping Step 2 (gating happened on the PR run)\n`);
1302
+ } else {
1303
+ // Bundle absent (ADR 0022 §1) — either never discovered, or discovered-but-stale and
1304
+ // nulled out above. The ONLY exemption is a well-formed, in-scope delivery/DECLARED
1305
+ // marker, and it exempts Step 2 ONLY — Step 1 (fresh verify, above) already ran
1306
+ // unconditionally and was never skipped. Reuses the scopeCtx resolved above (same
1307
+ // context the staleness check already used).
1308
+ const exemption = resolveDeclaredExemption(resolvedRepoRoot, scopeCtx);
1309
+ if (exemption.exempt) {
1310
+ const { scope, reason, approved_by, declared_at } = exemption.marker;
1311
+ process.stdout.write(`[trust-reconcile] DECLARED (no-agent-delivery): ${scope} — ${reason} (approved by ${approved_by}, declared ${declared_at})\n`);
1312
+ } else {
1313
+ process.stderr.write(`[trust-reconcile] ${exemption.diagnostic}\n`);
1314
+ issues.push({
1315
+ type: 'bundle-required-no-declared-marker',
1316
+ message: exemption.diagnostic,
1317
+ });
465
1318
  }
466
1319
  }
467
1320
 
@@ -517,6 +1370,7 @@ function main() {
517
1370
  bundle: args.bundle || null,
518
1371
  commands: args.commands,
519
1372
  repoRoot: args.repoRoot || null,
1373
+ manifest: args.manifest || null,
520
1374
  }));
521
1375
  }
522
1376