ace-assign 0.63.1 → 0.66.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -4,6 +4,10 @@ require "digest"
4
4
  require "json"
5
5
  require "open3"
6
6
  require "pathname"
7
+ require "ace/herdr/errors"
8
+ require "ace/herdr/models/delivery_record"
9
+ require "ace/herdr/molecules/delivery_record_store"
10
+ require_relative "../molecules/canonical_attempt_state"
7
11
 
8
12
  module Ace
9
13
  module Assign
@@ -45,6 +49,94 @@ module Ace
45
49
 
46
50
  attr_reader :store
47
51
 
52
+ # Pure producer plan. The protected lifecycle authenticates the original
53
+ # proof and both exhaustive owner projections anew at each CAS prefix.
54
+ # No caller receipt, cache state, native wait or store mutation enters it.
55
+ def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
56
+ Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
57
+ payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
58
+ service_evidence: service_evidence, commit: commit)
59
+ {events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
60
+ end
61
+
62
+ # The protected result owner authenticates imported bytes, candidate,
63
+ # closure and independent settlements at the same CAS prefix.
64
+ def self.finished_transition_plan(events:, receipt:)
65
+ state = Molecules::CanonicalAttemptState.derive(events)
66
+ unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
67
+ raise AttemptErrors::Conflict, "Result finish requires a running attempt"
68
+ end
69
+ Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
70
+ {events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
71
+ {type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"}}], blobs: {}}
72
+ end
73
+
74
+ def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
75
+ return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
76
+ reason = if effects_pending
77
+ "unresolved_effect"
78
+ elsif state == "uncertain"
79
+ "attempt_uncertain"
80
+ elsif !bound
81
+ "launch_unbound"
82
+ elsif !checkpoint
83
+ "checkpoint_unavailable"
84
+ elsif !live
85
+ "scope_unverifiable"
86
+ end
87
+ reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
88
+ end
89
+
90
+ def self.protected_recovery_plan(events:, decision:, reason:, observation:)
91
+ state = Molecules::CanonicalAttemptState.derive(events)
92
+ if %w[succeeded failed stopped].include?(state)
93
+ raise AttemptErrors::Conflict, "Terminal recovery is read-only"
94
+ end
95
+ planned = [{type: "recovery_observation", payload: {"decision" => decision,
96
+ "recovery_reason" => reason, "observation" => observation}}]
97
+ if state == "running" && decision != "adopt"
98
+ Atoms::AttemptStateMachine.transition!(state, "uncertain")
99
+ planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
100
+ state = "uncertain"
101
+ end
102
+ {events: planned, blobs: {}, state: state}
103
+ end
104
+
105
+ # Read-only exact native reverse binding from accepted history. A PID
106
+ # supplied by a trusted boundary must be kernel-derived; no command
107
+ # flag, environment address or local cache grants this authority.
108
+ # Missing/dead/changed owners remain a classified refusal.
109
+ def runtime_binding(attempt_id:, caller_pid: nil)
110
+ attempt = recover_managed_attempt(nil, attempt_id)
111
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
112
+ unless attempt.managed? && attempt.active?
113
+ raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
114
+ end
115
+ binding = reconciler.recorded_binding(attempt)
116
+ observation = reconciler.observation(attempt)
117
+ unless binding && observation["liveness"] == "live" &&
118
+ binding["process_identity"] == observation["identity"] &&
119
+ %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
120
+ raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
121
+ end
122
+ unless caller_pid.nil?
123
+ unless caller_pid.is_a?(Integer) && caller_pid.positive?
124
+ raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
125
+ end
126
+ caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
127
+ unless caller == binding
128
+ raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
129
+ end
130
+ end
131
+ latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
132
+ unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
133
+ raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
134
+ end
135
+ binding
136
+ rescue Ace::Runtime::Error => e
137
+ raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
138
+ end
139
+
48
140
  # Claim an external service effect before dispatch. Only a managed,
49
141
  # active attempt for the exact project and candidate may own it. The
50
142
  # guard re-runs inside the journal lock per CAS attempt, so a
@@ -65,6 +157,10 @@ module Ace
65
157
  journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
66
158
  end
67
159
 
160
+ def proposal_authorization(reference, binding)
161
+ journal_for.proposal_authorize!(reference, binding)
162
+ end
163
+
68
164
  # Rechecked against the authoritative ref for every locked claim
69
165
  # attempt: the attempt must still be journal-active and the exact
70
166
  # authorization must still be unconsumed.
@@ -228,6 +324,11 @@ module Ace
228
324
  # and attested outcome. All checks read one open handle, so a swap
229
325
  # on a caller-writable path cannot mix files between checks.
230
326
  def verify_service_evidence!(evidence, request, state, require_no_effect: false)
327
+ if @journal&.evidence_mode == :protected
328
+ receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
329
+ @journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
330
+ return true
331
+ end
231
332
  repo_root = File.realpath(@repo_root)
232
333
  claimed_at = parse_claimed_at(request)
233
334
  evidence.each do |item|
@@ -391,9 +492,14 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
391
492
  @store.with_lock(assignment_id) do
392
493
  journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
393
494
 
394
- existing = @store.active(assignment_id, scope) ||
395
- journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
495
+ existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
496
+ @store.active(assignment_id, scope)
396
497
  if existing
498
+ if existing.state == "reserved"
499
+ raise AttemptErrors::Conflict,
500
+ "Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
501
+ "protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
502
+ end
397
503
  if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
398
504
  existing.binding.actor == identity.actor && existing.binding.role == identity.role
399
505
  return existing
@@ -456,7 +562,13 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
456
562
  attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
457
563
  raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
458
564
 
459
- ensure_journal_consistent!(attempt) if attempt.managed?
565
+ derived = ensure_journal_consistent!(attempt) if attempt.managed?
566
+ attempt = attempt.with(state: derived.state) if derived
567
+ if attempt.state == "reserved"
568
+ raise AttemptErrors::EvidenceUnavailable,
569
+ "Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
570
+ "no-execution/no-surviving-writer proof before abort"
571
+ end
460
572
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
461
573
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
462
574
 
@@ -498,6 +610,142 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
498
610
  attempt.projection
499
611
  end
500
612
 
613
+ # Reconstruct recovery from accepted history. Local caches and pane
614
+ # titles never override journal authority. This method has no writes.
615
+ def recovery_snapshot(assignment_id)
616
+ assignment = @manager.load(assignment_id)
617
+ managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
618
+ attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
619
+ raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
620
+
621
+ requests = managed ? journal_for.service_requests(assignment_id) : []
622
+ unresolved = requests.reject do |request|
623
+ %w[succeeded failed-settled].include?(request["state"]) ||
624
+ request["state"] == "rejected" && request["consumed"] == false
625
+ end
626
+ queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
627
+ inbox_records = recovery_inboxes(attempts)
628
+ recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
629
+ observation = reconciler.observation(attempt)
630
+ blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
631
+ unstarted = !reconciler.process_started?(attempt)
632
+ decision, reason = if blocked || attempt.uncertain?
633
+ ["reconcile-required", blocked ? "unresolved external effect" :
634
+ "attempt remains uncertain"]
635
+ elsif attempt.terminal?
636
+ ["restart-required", "attempt ended; restart requires a new attributable attempt"]
637
+ elsif unstarted
638
+ ["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
639
+ elsif !assignment || observation["liveness"] != "live"
640
+ ["reconcile-required", !assignment ? "assignment checkpoint unavailable" : observation["reason"]]
641
+ else
642
+ ["adopt", "verified owner survives; preserve current attempt"]
643
+ end
644
+ receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
645
+ .filter_map { |event| event.dig("payload", "receipt") }
646
+ accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
647
+ .map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
648
+ attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
649
+ "accepted_effects" => accepted_effects,
650
+ "accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
651
+ "liveness" => observation["liveness"], "last_verified_observation" =>
652
+ (observation["liveness"] == "live" ? observation : last_verified_observation(attempt)),
653
+ "observation" => observation)
654
+ end
655
+ decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
656
+ "reconcile-required"
657
+ elsif recovered.any? { |item| item["decision"] == "adopt" }
658
+ "adopt"
659
+ else
660
+ "restart-required"
661
+ end
662
+ {"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
663
+ "latest_attempt_id" => recovered.last&.dig("attempt_id"),
664
+ "decision" => decision, "liveness" => decision == "adopt" ? "live" : "unknown",
665
+ "last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
666
+ .max_by { |item| item["observed_at"].to_s },
667
+ "recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
668
+ recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
669
+ "goal" => assignment&.description || assignment&.name,
670
+ "checkpoint" => queue && {"state" => queue.assignment_state.to_s,
671
+ "active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
672
+ "attempts" => recovered, "inbox_events" => inbox_records,
673
+ "pending_hitl" => queue ? queue.steps.filter_map do |step|
674
+ id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
675
+ {"event_id" => id, "scope" => step.number, "rebind" => false} if id
676
+ end : [], "unresolved_effects" => unresolved.map do |request|
677
+ request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
678
+ end}
679
+ end
680
+
681
+ def resume(assignment_id:, dry_run: false)
682
+ return recovery_snapshot(assignment_id) if dry_run
683
+
684
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
685
+ @store.with_lock(assignment_id) do
686
+ snapshot = recovery_snapshot(assignment_id)
687
+ snapshot.fetch("attempts").each do |projection|
688
+ attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
689
+ @store.load(assignment_id, projection["attempt_id"])
690
+ next unless attempt && !attempt.terminal?
691
+
692
+ event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
693
+ payload: projection.slice("decision", "recovery_reason", "observation"),
694
+ previous_digest: last_event_digest(attempt))
695
+ attempt = append_events(attempt, [event])
696
+ if attempt.state == "running" && projection["decision"] != "adopt"
697
+ target = projection["decision"] == "restart-required" ? "stopped" : "uncertain"
698
+ attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
699
+ attempt = attempt.transition(target)
700
+ @store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
701
+ end
702
+ @store.save(attempt)
703
+ end
704
+ recovery_snapshot(assignment_id)
705
+ end
706
+ end
707
+ end
708
+
709
+ # Inbox delivery is transport evidence, never business-effect proof.
710
+ # Herdr owns signature, pinned key, generation and native identity
711
+ # verification; this consumer records only its verified references.
712
+
713
+
714
+ # Register a transport reference in the existing attempt journal so
715
+ # a missing delivery record cannot be mistaken for no pending effect.
716
+ def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
717
+ identity ||= @identity_resolver.resolve
718
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
719
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
720
+
721
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
722
+ @store.with_lock(attempt.binding.assignment_id) do
723
+ attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
724
+ @store.load(attempt.binding.assignment_id, attempt_id)
725
+ unless attempt.active? && attempt.binding.actor == identity.actor
726
+ raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
727
+ end
728
+ record = inbox.status(event: event_id)
729
+ unless record["attempt_id"] == attempt_id
730
+ raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
731
+ end
732
+ payload = record.slice("event_id", "attempt_id", "payload_sha256")
733
+ existing = reconciler.events(attempt).find do |event|
734
+ event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
735
+ end
736
+ if existing && existing["payload"] != payload
737
+ raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
738
+ end
739
+ unless existing
740
+ event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
741
+ payload: payload, previous_digest: last_event_digest(attempt))
742
+ @store.save(append_events(attempt, [event]))
743
+ end
744
+ payload
745
+ end
746
+ end
747
+ end
748
+
501
749
  # Read accepted check evidence without transitions, locks, cache writes
502
750
  # or audit checkout creation. Managed history comes from the Git ref.
503
751
  def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
@@ -579,14 +827,19 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
579
827
  raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
580
828
 
581
829
  derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
582
- # The journal is authoritative for managed attempts: uncertainty
583
- # journaled after the local save drives reconciliation.
584
- attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
830
+ # The journal is authoritative, including an unbound reservation
831
+ # that a stale local running projection must not promote.
832
+ attempt = attempt.with(state: derived.state) if derived
585
833
 
586
834
  if attempt.terminal?
587
835
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
588
836
  end
589
837
 
838
+ if attempt.state == "reserved"
839
+ raise AttemptErrors::EvidenceUnavailable,
840
+ "Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
841
+ "no-surviving-writer proof before abort; missing process_start is not proof"
842
+ end
590
843
  return classify_running(attempt) if attempt.state == "running"
591
844
 
592
845
  identity ||= @identity_resolver.resolve
@@ -597,6 +850,42 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
597
850
 
598
851
  private
599
852
 
853
+ def last_verified_observation(attempt)
854
+ event = reconciler.events(attempt).reverse.find do |candidate|
855
+ candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
856
+ end
857
+ event&.dig("payload", "observation")
858
+ end
859
+
860
+ def recovery_inboxes(attempts)
861
+ # Ordinary local recovery selects Herdr's configuration only here;
862
+ # protected launch admission must not initialize that broad entry.
863
+ require "ace/herdr"
864
+ root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
865
+ entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
866
+ registrations = attempts.flat_map do |attempt|
867
+ reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
868
+ .map { |event| [attempt, event["payload"]] }
869
+ end
870
+ records = registrations.map do |attempt, payload|
871
+ record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
872
+ attributed = record && record.inbox && record.event_id == payload["event_id"] &&
873
+ record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
874
+ record.answer_digest == payload["payload_sha256"]
875
+ state = attributed ? record.state : "unknown"
876
+ payload.slice("event_id", "attempt_id").merge("state" => state)
877
+ rescue JSON::ParserError, ArgumentError, SystemCallError
878
+ payload.slice("event_id", "attempt_id").merge("state" => "unknown")
879
+ end
880
+ entries.each do |entry|
881
+ next if records.any? { |record| record["event_id"] == entry[:event_id] }
882
+ record = entry[:record]
883
+ next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
884
+ records << {"event_id" => entry[:event_id], "state" => "unknown"}
885
+ end
886
+ records
887
+ end
888
+
600
889
  # Attempt IDs must be unique per assignment AND globally: records,
601
890
  # journal events, and reconciliation lookups key on them. Allocation
602
891
  # runs under the assignment lock, avoids journal-known IDs, and
@@ -698,12 +987,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
698
987
  when :live
699
988
  raise AttemptErrors::InvalidState,
700
989
  "Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
701
- when :stopped
702
- attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
703
- attempt = attempt.transition("stopped")
704
- @store.save(attempt)
705
- @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
706
- attempt
707
990
  else
708
991
  attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
709
992
  attempt = attempt.transition("uncertain")
@@ -809,7 +1092,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
809
1092
  "actor" => identity.actor,
810
1093
  "role" => identity.role,
811
1094
  "runtime" => identity.runtime,
812
- "pid" => Process.pid
1095
+ "pid" => identity.process_pid,
1096
+ "process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
1097
+ "runtime_binding" => identity.runtime_binding
813
1098
  },
814
1099
  previous_digest: intent["digest"]
815
1100
  )
@@ -833,6 +1118,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
833
1118
  end
834
1119
 
835
1120
  def accept(attempt, receipt, live_head, pre_events = [])
1121
+ # Reject illegal terminal outcomes before accepting any receipt.
1122
+ if %w[succeeded failed].include?(receipt.verdict)
1123
+ Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
1124
+ end
836
1125
  previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
837
1126
  event = Models::EvidenceEvent.build(
838
1127
  type: "receipt_accepted",