ace-assign 0.64.0 → 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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ def transfer_binding(request) = TRANSFER_OPERATIONS[request.fetch("operation")]
8
+
9
+ def authorize_transfer!(request:, peer:, role:)
10
+ if request["operation"] == "register_assignment"
11
+ params = request.fetch("params")
12
+ strict!(params, MUTATIONS.fetch("register_assignment"))
13
+ map = @deployment.mapping(params.fetch("mapping_id"))
14
+ TaskContextEntry.validate!(map.fetch("task_context_entry"))
15
+ unless role == :launcher && peer.values_at("uid", "gid", "groups") == map.values_at("launcher_uid", "launcher_gid", "launcher_groups")
16
+ raise AttemptErrors::UnauthorizedIdentity, "prepared registration requires mapped launcher"
17
+ end
18
+ @kernel.live!(peer)
19
+ return true
20
+ end
21
+ raise ArgumentError, "unsupported launch transfer" unless request["operation"] == "prompt_attempt"
22
+ steering_principal!(request.fetch("params"), peer, role)
23
+ end
24
+
25
+ def prompt_attempt!(request:, peer:, role:, transfer:)
26
+ params = request.fetch("params")
27
+ strict!(params, %w[mapping_id assignment_id attempt_id expected_generation transfer])
28
+ token!(request.fetch("mutation_id"))
29
+ generation!(params.fetch("expected_generation"))
30
+ map = steering_principal!(params, peer, role)
31
+ raise AttemptErrors::MalformedTransfer, "Prompt transfer unavailable" unless transfer
32
+ text = transfer.bytes
33
+ unless text.is_a?(String) && text.force_encoding(Encoding::UTF_8).valid_encoding? && text.bytesize.between?(1, 16_384) && !text.match?(/\A[[:space:]]*\z/)
34
+ raise AttemptErrors::MalformedTransfer, "Prompt text is malformed"
35
+ end
36
+ journal = journal_for(map)
37
+ binding = intent = channel = reservation = nil
38
+ prepared = with_exclusion(params, map, journal) do
39
+ @mutex.synchronize do
40
+ commit = journal.ref_value
41
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
42
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
43
+ original = original_prompt_record!(events, state, params: params)
44
+ binding = {"action" => "prompt_attempt", "project_id" => map.fetch("project_id"),
45
+ "mapping_id" => params.fetch("mapping_id"), "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
46
+ "mutation_id" => request.fetch("mutation_id"), "expected_generation" => params.fetch("expected_generation"),
47
+ "caller" => peer.slice("uid", "gid", "groups").merge("role" => role.to_s),
48
+ "original_binding_digest" => original.fetch("binding_digest"), "text_bytes" => text.bytesize, "text_sha256" => Digest::SHA256.hexdigest(text)}
49
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(binding)))
50
+ replay = journal.mutation_result(request.fetch("mutation_id"), commit: commit)
51
+ if replay
52
+ unless replay["operation"] == "prompt_attempt" && replay["parameters_digest"] == digest && replay["assignment_id"] == params["assignment_id"] && replay["attempt_id"] == params["attempt_id"]
53
+ raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
54
+ end
55
+ next({data: replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit")), replayed: true})
56
+ end
57
+ unless journal.prompt_intent(request.fetch("mutation_id"), commit: commit)
58
+ channel = @control_channels[steering_key(params, map)]
59
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher channel is unavailable" unless channel
60
+ reservation = channel.reserve_dispatch!
61
+ end
62
+ issue = journal.issue_prompt(binding: binding, issued_by: peer) do |current, _old, _generation|
63
+ unless channel && reservation && channel.dispatch_reserved?(reservation)
64
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher channel is unavailable"
65
+ end
66
+ fresh = origin(current, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
67
+ authenticate = original_prompt_record!(current, fresh, params: params)
68
+ raise AttemptErrors::EvidenceUnavailable, "Original guarded record changed" unless authenticate == original
69
+ raise AttemptErrors::Conflict, "Attempt is not available for prompt input" unless fresh["phase"] == "issued" && !terminal_events?(current)
70
+ scope_open_for_effect!(events: current, params: params, map: map)
71
+ @kernel.live!(fresh.fetch("process_binding").fetch("process_identity"))
72
+ end
73
+ intent = journal.prompt_intent(request.fetch("mutation_id"), commit: issue.dig(:data, "journal_commit"))
74
+ {issue: issue, original: original}
75
+ end
76
+ end
77
+ return prepared if prepared.key?(:data)
78
+ evidence = {"outcome" => "uncertain", "origin" => prepared.fetch(:original).fetch("origin")}
79
+ if channel && !channel.closed? && !prepared.fetch(:issue).fetch(:replayed)
80
+ frame = {"version" => 1, "type" => "prompt_dispatch", "mutation_id" => request.fetch("mutation_id"),
81
+ "attempt_id" => params.fetch("attempt_id"), "intent_event_id" => intent.fetch("digest"),
82
+ "journal_commit" => prepared.dig(:issue, :data, "journal_commit"), "original_binding_digest" => binding.fetch("original_binding_digest"),
83
+ "text_descriptor" => TransferCodec.new.descriptor([text], purpose: :prompt_text), "transfer_id" => SecureRandom.hex(16)}
84
+ begin
85
+ outcome = channel.dispatch_prompt(frame: frame, bytes: text, deadline: wire.deadline(30), reservation: reservation)
86
+ evidence = outcome.fetch("guarded_evidence")
87
+ rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable, IOError, SystemCallError
88
+ # Accepted intent never yields another send permit after uncertainty.
89
+ end
90
+ end
91
+ finish_prompt_outcome!(params, map, intent, evidence)
92
+ ensure
93
+ channel&.release_dispatch!(reservation) if reservation
94
+ text = nil
95
+ end
96
+
97
+ def serve_launch_control!(request:, peer:, socket:, codec:, deadline:)
98
+ params = request.fetch("params")
99
+ strict!(params, %w[mapping_id assignment_id attempt_id])
100
+ raise ArgumentError, "Private control requires null mutation ID" unless request.fetch("mutation_id").nil?
101
+ map = steering_principal!(params, peer, :launcher)
102
+ journal = journal_for(map)
103
+ channel = ready = nil
104
+ with_containment_exclusion(params, map, journal) do
105
+ @mutex.synchronize do
106
+ commit = journal.ref_value
107
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
108
+ state = origin(events, **params.transform_keys(&:to_sym))
109
+ raise AttemptErrors::UnauthorizedIdentity, "Private channel belongs to another launcher incarnation" unless @kernel.same?(state.fetch("launcher_identity"), peer)
110
+ raise AttemptErrors::Conflict, "Original launcher is not issued" unless state["phase"] == "issued"
111
+ if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
112
+ raise AttemptErrors::EvidenceUnavailable, "Released scope cannot retain launcher control"
113
+ end
114
+ original = original_prompt_record!(events, state, params: params)
115
+ key = steering_key(params, map)
116
+ prior = @control_channels[key]
117
+ raise AttemptErrors::Conflict, "Original launcher channel already exists" if prior && !prior.closed?
118
+ channel = LaunchControlChannel.new(socket: socket, codec: codec,
119
+ outcome: ->(result) { ingest_original_prompt_outcome!(params, map, result) },
120
+ input_inhibition: ->(result) { record_original_input_inhibition!(params.merge(result.slice("original_binding_digest", "seal_event_id")), map,
121
+ result.fetch("guarded_evidence"), peer: peer) })
122
+ @control_channels[key] = channel
123
+ ready = {"version" => 1, "type" => "launch_control_ready", "attempt_id" => params.fetch("attempt_id"),
124
+ "original_binding_digest" => original.fetch("binding_digest"), "journal_commit" => commit, "generation" => journal.authority_generation(events)}
125
+ end
126
+ end
127
+ wire.write(socket, ready, deadline: deadline, limit: 16_384)
128
+ channel.serve
129
+ ensure
130
+ channel&.close
131
+ end
132
+
133
+ # This immutable-prefix query holds no slot/assignment exclusion. The
134
+ # original waiter dropped all locks before dispatch/ACK handling.
135
+ def launch_prompt_intent!(request:, peer:, role:)
136
+ params = request.fetch("params")
137
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id intent_event_id journal_commit])
138
+ raise ArgumentError, "Intent lookup is read-only" unless request.fetch("mutation_id").nil?
139
+ map = steering_principal!(params, peer, role)
140
+ raise AttemptErrors::UnauthorizedIdentity, "Intent lookup requires original launcher" unless role == :launcher
141
+ unless params.fetch("journal_commit").is_a?(String) && params.fetch("journal_commit").match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
142
+ raise ArgumentError, "Invalid immutable intent commit"
143
+ end
144
+ journal = journal_for(map)
145
+ journal.verify_canonical_prefix!(commit: params.fetch("journal_commit"))
146
+ intent = journal.prompt_intent(params.fetch("mutation_id"), commit: params.fetch("journal_commit"))
147
+ raise AttemptErrors::EvidenceUnavailable, "Accepted prompt intent differs" unless intent && intent["digest"] == params.fetch("intent_event_id")
148
+ events = journal.read_events(params.fetch("assignment_id"), commit: params.fetch("journal_commit")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
149
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
150
+ raise AttemptErrors::UnauthorizedIdentity, "Intent lookup requires exact launcher incarnation" unless @kernel.same?(state.fetch("launcher_identity"), peer)
151
+ original = original_prompt_record!(events, state, params: params)
152
+ binding = intent.fetch("payload").fetch("binding")
153
+ unless binding["original_binding_digest"] == original.fetch("binding_digest") && binding["mapping_id"] == params["mapping_id"] && binding["assignment_id"] == params["assignment_id"] && binding["attempt_id"] == params["attempt_id"]
154
+ raise AttemptErrors::EvidenceUnavailable, "Prompt intent does not join original accepted record"
155
+ end
156
+ {data: {"intent_event_id" => intent.fetch("digest"), "binding" => binding, "origin" => original.fetch("origin")}, replayed: false}
157
+ end
158
+
159
+ # Only the still-authenticated original launcher may recover its own
160
+ # already-known native ACK after a private connection disappears.
161
+ # This grants no input permit and never changes the first public reply.
162
+ def launch_prompt_completion!(request:, peer:, role:)
163
+ params = request.fetch("params")
164
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id intent_event_id original_binding_digest guarded_evidence])
165
+ raise ArgumentError, "Original completion requires null public mutation ID" unless request.fetch("mutation_id").nil?
166
+ map = steering_principal!(params, peer, role)
167
+ raise AttemptErrors::UnauthorizedIdentity, "Completion reporting requires original launcher" unless role == :launcher
168
+ journal = journal_for(map)
169
+ intent = journal.prompt_intent(params.fetch("mutation_id"))
170
+ binding = intent&.dig("payload", "binding")
171
+ unless intent && intent["digest"] == params["intent_event_id"] && binding.is_a?(Hash) &&
172
+ %w[mapping_id assignment_id attempt_id original_binding_digest].all? { |key| binding[key] == params[key] } &&
173
+ params.fetch("guarded_evidence").is_a?(Hash) && %w[submitted not_issued].include?(params.dig("guarded_evidence", "outcome"))
174
+ raise AttemptErrors::EvidenceUnavailable, "Retained native completion does not join accepted issue"
175
+ end
176
+ accepted = finish_prompt_outcome!(params.slice("mapping_id", "assignment_id", "attempt_id"), map, intent,
177
+ params.fetch("guarded_evidence"), peer: peer, completion_ack: true)
178
+ {data: accepted.merge("intent_event_id" => intent.fetch("digest")), replayed: false}
179
+ end
180
+
181
+ def prompt_status!(request:, peer:, role:)
182
+ params = request.fetch("params")
183
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id])
184
+ %w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
185
+ raise ArgumentError, "Prompt status requires null public mutation ID" unless request.fetch("mutation_id").nil?
186
+ map = steering_principal!(params, peer, role)
187
+ journal = journal_for(map)
188
+ @mutex.synchronize do
189
+ commit = journal.ref_value
190
+ intent = journal.prompt_intent(params.fetch("mutation_id"), commit: commit)
191
+ raise AttemptErrors::NotFound, "Prompt mutation is missing" unless intent
192
+ binding = intent.fetch("payload").fetch("binding")
193
+ unless binding.slice("mapping_id", "assignment_id", "attempt_id") == params.slice("mapping_id", "assignment_id", "attempt_id") &&
194
+ binding.fetch("project_id") == map.fetch("project_id")
195
+ raise AttemptErrors::Conflict, "Prompt mutation belongs to another attempt"
196
+ end
197
+ unless binding.fetch("caller") == peer.slice("uid", "gid", "groups").merge("role" => role.to_s)
198
+ raise AttemptErrors::UnauthorizedIdentity, "Prompt mutation belongs to another principal"
199
+ end
200
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
201
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
202
+ original = original_prompt_record!(events, state, params: params)
203
+ unless binding.fetch("original_binding_digest") == original.fetch("binding_digest")
204
+ raise AttemptErrors::EvidenceUnavailable, "Prompt original guarded record differs"
205
+ end
206
+ reply = journal.mutation_result(params.fetch("mutation_id"), commit: commit)
207
+ if reply && (reply["operation"] != "prompt_attempt" || reply["parameters_digest"] != intent.dig("payload", "binding_digest") ||
208
+ reply["assignment_id"] != params["assignment_id"] || reply["attempt_id"] != params["attempt_id"])
209
+ raise AttemptErrors::EvidenceUnavailable, "Prompt immutable reply differs"
210
+ end
211
+ completed = authenticated_prompt_completions!(events, intent, original, journal).last
212
+ {data: {"attempt_id" => params.fetch("attempt_id"), "mutation_id" => params.fetch("mutation_id"),
213
+ "outcome" => completed ? completed.dig("payload", "evidence", "outcome") : "uncertain",
214
+ "intent_event_id" => intent.fetch("digest"), "outcome_event_id" => completed&.fetch("digest"),
215
+ "generation" => journal.authority_generation(events), "journal_commit" => commit}, replayed: false}
216
+ end
217
+ rescue KeyError, TypeError
218
+ raise AttemptErrors::EvidenceUnavailable, "Prompt status evidence is malformed"
219
+ end
220
+
221
+ private
222
+
223
+ def steering_key(params, map)
224
+ [map.fetch("project_id"), params.fetch("mapping_id"), params.fetch("assignment_id"), params.fetch("attempt_id")]
225
+ end
226
+
227
+ def steering_principal!(params, peer, role)
228
+ @kernel.live!(peer)
229
+ map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
230
+ allowed = if role == :launcher
231
+ peer.values_at("uid", "gid", "groups") == map.values_at("launcher_uid", "launcher_gid", "launcher_groups")
232
+ elsif role == :supervisor
233
+ project = @deployment.project(map.fetch("project_id"))
234
+ credentials = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
235
+ project.fetch("supervisor_uids").include?(peer.fetch("uid")) && credentials &&
236
+ peer.values_at("gid", "groups") == credentials.values_at("gid", "groups")
237
+ end
238
+ raise AttemptErrors::UnauthorizedIdentity, "Prompt requires installed launcher or supervisor" unless allowed
239
+ map
240
+ end
241
+
242
+ def finish_prompt_outcome!(params, map, intent, evidence, peer: nil, completion_ack: false)
243
+ journal = journal_for(map)
244
+ with_containment_exclusion(params, map, journal) do
245
+ @mutex.synchronize do
246
+ authentication = lambda do |events, _commit, _generation|
247
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
248
+ if peer
249
+ @kernel.live!(peer)
250
+ unless @kernel.same?(state.fetch("launcher_identity"), peer)
251
+ raise AttemptErrors::UnauthorizedIdentity, "Completion reporting requires exact original launcher incarnation"
252
+ end
253
+ end
254
+ original_prompt_record!(events, state, params: params)
255
+ end
256
+ if peer
257
+ selected = journal.ref_value
258
+ current = journal.read_events(params.fetch("assignment_id"), commit: selected).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
259
+ authenticated = authentication.call(current, selected, journal.authority_generation(current))
260
+ journal.send(:validate_prompt_evidence!, evidence, authenticated.fetch("origin"))
261
+ end
262
+ result = journal.finalize_prompt(mutation_id: intent.dig("payload", "external_mutation_id"), intent_event_id: intent.fetch("digest"),
263
+ binding_digest: intent.dig("payload", "binding_digest"), evidence: evidence, &authentication)
264
+ accepted = result
265
+ if %w[submitted not_issued].include?(evidence["outcome"]) && %w[submitted not_issued].include?(result.dig(:data, "outcome")) &&
266
+ result.dig(:data, "guarded_evidence") != evidence
267
+ raise AttemptErrors::Conflict, "Native completion differs from accepted outcome"
268
+ end
269
+ if result.dig(:data, "outcome") == "uncertain" && %w[submitted not_issued].include?(evidence["outcome"])
270
+ accepted = journal.observe_prompt_completion(mutation_id: intent.dig("payload", "external_mutation_id"), intent_event_id: intent.fetch("digest"),
271
+ binding_digest: intent.dig("payload", "binding_digest"), evidence: evidence, &authentication)
272
+ end
273
+ completion_ack ? accepted.fetch(:data).slice("outcome", "journal_commit") : result
274
+ end
275
+ end
276
+ end
277
+
278
+ def ingest_original_prompt_outcome!(params, map, result)
279
+ journal = journal_for(map)
280
+ intent = journal.prompt_intent(result.fetch("mutation_id"))
281
+ unless intent && intent["digest"] == result.fetch("intent_event_id") &&
282
+ intent.dig("payload", "binding", "original_binding_digest") == result.fetch("original_binding_digest")
283
+ raise AttemptErrors::EvidenceUnavailable, "Original driver outcome differs from accepted issue"
284
+ end
285
+ finish_prompt_outcome!(params, map, intent, result.fetch("guarded_evidence"), completion_ack: true)
286
+ end
287
+
288
+ # An issued actor lives outside the worker unit and must be inhibited
289
+ # for its full lifetime, even with no prompt rows or only known ACKs.
290
+ # Accepted original actor input drainage removes the writer blocker. It never
291
+ # changes an unknown prompt outcome. Channel absence, caller exit and
292
+ # an empty worker cgroup cannot remove this blocker.
293
+ def pending_prompt_issuers?(events, journal, commit)
294
+ return false if accepted_input_inhibition?(events, journal, commit)
295
+ return true if issued_input_actor?(events)
296
+ events.select { |event| event["type"] == "prompt_issued" }.any? do |issued|
297
+ external_id = issued.fetch("payload").fetch("external_mutation_id")
298
+ intent = journal.prompt_intent(external_id, commit: commit)
299
+ raise AttemptErrors::EvidenceUnavailable, "Prompt issuer is not canonically accepted" unless intent && intent["digest"] == issued["digest"]
300
+ binding = intent.fetch("payload").fetch("binding")
301
+ params = binding.slice("mapping_id", "assignment_id", "attempt_id")
302
+ state = origin(events, **params.transform_keys(&:to_sym))
303
+ original = original_prompt_record!(events, state, params: params)
304
+ unless binding.fetch("original_binding_digest") == original.fetch("binding_digest")
305
+ raise AttemptErrors::EvidenceUnavailable, "Prompt issuer original binding differs"
306
+ end
307
+ authenticated_prompt_completions!(events, intent, original, journal).empty?
308
+ end
309
+ rescue KeyError, TypeError, ArgumentError
310
+ raise AttemptErrors::EvidenceUnavailable, "Prompt issuer history is malformed"
311
+ end
312
+
313
+ def issued_input_actor?(events)
314
+ releases = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" }
315
+ return false if releases.empty?
316
+ unless Models::EvidenceEvent.chain_valid?(events) && releases.one? &&
317
+ releases.first.dig("payload", "data").is_a?(Hash) && releases.first.dig("payload", "data", "phase") == "issued"
318
+ raise AttemptErrors::EvidenceUnavailable, "Original outside-unit actor issuance differs"
319
+ end
320
+ true
321
+ end
322
+
323
+ def authenticated_prompt_completions!(events, intent, original, journal)
324
+ external_id = intent.fetch("payload").fetch("external_mutation_id")
325
+ events.select { |event| %w[prompt_outcome prompt_completion_observed].include?(event["type"]) &&
326
+ event.dig("payload", "intent_event_id") == intent.fetch("digest") }.select do |event|
327
+ payload = event.fetch("payload")
328
+ evidence = payload.fetch("evidence")
329
+ journal.send(:validate_prompt_evidence!, evidence, original.fetch("origin"))
330
+ expected_operation = event["type"] == "prompt_outcome" ? "prompt_attempt" : "prompt_observation"
331
+ expected_id = expected_operation == "prompt_attempt" ? external_id : "prompt-observe.#{intent.fetch('digest')}"
332
+ acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event["digest"] &&
333
+ entry.dig("payload", "operation") == expected_operation && entry.dig("payload", "mutation_id") == expected_id }
334
+ unless payload["external_mutation_id"] == external_id && payload["binding_digest"] == intent.dig("payload", "binding_digest") &&
335
+ acceptance && acceptance.dig("payload", "data", "outcome") == evidence.fetch("outcome") &&
336
+ acceptance.dig("payload", "data", "guarded_evidence") == evidence
337
+ raise AttemptErrors::EvidenceUnavailable, "Prompt native completion acceptance differs"
338
+ end
339
+ %w[submitted not_issued].include?(evidence.fetch("outcome"))
340
+ end
341
+ rescue KeyError, TypeError, ArgumentError
342
+ raise AttemptErrors::EvidenceUnavailable, "Prompt completion history is malformed"
343
+ end
344
+
345
+ # Authentication binds the whole accepted original record, including
346
+ # its native guard. A plain process-binding digest cannot substitute.
347
+ def original_prompt_record!(events, state, params:)
348
+ Molecules::OriginalLaunchBinding.verify!(events: events, state: state, params: params)
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../organisms/attempt_coordinator"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ class LaunchLifecycle
9
+ # The stop mutation owns its seal and immutable reply. Input inhibition
10
+ # waits outside exclusions; manager stop runs after CAS outside @mutex,
11
+ # retaining the existing containment slot guard.
12
+ def stop_attempt!(request:, peer:, role:)
13
+ params = request.fetch("params")
14
+ strict!(params, %w[mapping_id assignment_id attempt_id expected_generation])
15
+ %w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
16
+ token!(request.fetch("mutation_id"))
17
+ generation!(params.fetch("expected_generation"))
18
+ map = steering_principal!(params, peer, role)
19
+ journal = journal_for(map)
20
+ principal = peer.slice("uid", "gid", "groups").merge("role" => role.to_s)
21
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.merge("caller" => principal))))
22
+ closure = nil
23
+ pending_action = pending_commit = nil
24
+ prepared = with_containment_exclusion(params, map, journal) do
25
+ @mutex.synchronize do
26
+ commit = journal.ref_value
27
+ events = stop_events!(journal, params, commit)
28
+ replay = journal.mutation_result(request.fetch("mutation_id"), commit: commit)
29
+ if replay
30
+ unless replay.values_at("operation", "parameters_digest", "assignment_id", "attempt_id") ==
31
+ ["stop_attempt", digest, params.fetch("assignment_id"), params.fetch("attempt_id")]
32
+ raise AttemptErrors::Conflict, "Mutation ID is already bound to different stop input"
33
+ end
34
+ if events.any? { |event| event["type"] == "attempt_stopped" }
35
+ terminal_scope_stopped!(events, journal, commit, deployment: @deployment)
36
+ elsif %w[succeeded failed].include?(journal.canonical_attempt_state(events))
37
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
38
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
39
+ terminal_scope_receipt!(events, lineage, journal, commit)
40
+ else
41
+ stop_owner!(params, map, events)
42
+ end
43
+ next({data: replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit")), replayed: true})
44
+ end
45
+ lineage = stop_owner!(params, map, events)
46
+ if lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
47
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
48
+ raise AttemptErrors::EvidenceUnavailable, "Settlement owner is unavailable" unless @result_owner
49
+ begin
50
+ @result_owner.service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
51
+ rescue AttemptErrors::EvidenceUnavailable => error
52
+ raise unless authenticated_stop_pending_action(error) == "settle_services"
53
+ pending_action, pending_commit = "settle_services", commit
54
+ end
55
+ next nil
56
+ end
57
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
58
+ mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
59
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |current, old, _generation|
60
+ lineage = stop_owner!(params, map, current)
61
+ closure = scope_closure_plan!(lineage: lineage, params: params, map: map, events: current, journal: journal, commit: old)
62
+ plan = closure.fetch(:plan)
63
+ plan.merge(data: stop_pending_reply(params, plan.fetch(:data)["proof_id"], "reconcile_scope"))
64
+ end
65
+ end
66
+ end
67
+ if prepared
68
+ unless prepared.fetch(:replayed)
69
+ request_original_input_inhibition!(params, map, journal) if closure&.fetch(:inhibit_required)
70
+ if closure&.fetch(:stop_required)
71
+ with_containment_exclusion(params, map, journal) do
72
+ @mutex.synchronize { stop_owner!(params, map, stop_events!(journal, params, journal.ref_value)) }
73
+ scope_observer_for(params.fetch("mapping_id")).stop_sealed_service!
74
+ end
75
+ end
76
+ end
77
+ return prepared
78
+ end
79
+ if pending_action
80
+ return record_pending_stop!(request, map, journal, digest, pending_action, pending_commit)
81
+ end
82
+ final_selection = journal.ref_value
83
+ begin
84
+ finalize_stopped_attempt!(request, peer, role, map, journal, digest)
85
+ rescue AttemptErrors::EvidenceUnavailable => error
86
+ action = authenticated_stop_pending_action(error)
87
+ raise unless action
88
+ record_pending_stop!(request, map, journal, digest, action, final_selection)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def stop_events!(journal, params, commit)
95
+ journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
96
+ end
97
+
98
+ def stop_owner!(params, map, events)
99
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
100
+ original_prompt_record!(events, state, params: params) if issued_input_actor?(events)
101
+ canonical_state = Molecules::CanonicalAttemptState.derive(events)
102
+ unless %w[running uncertain].include?(canonical_state)
103
+ raise AttemptErrors::Conflict, "Attempt is not eligible for proof-authenticated stop"
104
+ end
105
+ Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
106
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
107
+ end
108
+
109
+ def stop_pending_reply(params, proof_id, action)
110
+ {"attempt_id" => params.fetch("attempt_id"), "state" => "uncertain", "proof_id" => proof_id, "required_action" => action}
111
+ end
112
+
113
+ # Only positively authenticated owner-pending subclasses have public
114
+ # recovery actions. Missing/corrupt evidence and auth failures propagate.
115
+ def authenticated_stop_pending_action(error)
116
+ if error.is_a?(AttemptErrors::ServiceSettlementPending)
117
+ return "settle_services"
118
+ end
119
+ nil
120
+ end
121
+
122
+ def record_pending_stop!(request, map, journal, digest, action, selected_commit)
123
+ params = request.fetch("params")
124
+ with_containment_exclusion(params, map, journal) do
125
+ @mutex.synchronize do
126
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
127
+ mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
128
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
129
+ unless commit == selected_commit
130
+ raise AttemptErrors::Conflict, "Authenticated pending settlement prefix advanced"
131
+ end
132
+ lineage = stop_owner!(params, map, events)
133
+ unless lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
134
+ raise AttemptErrors::EvidenceUnavailable, "Original scope closure proof is unavailable"
135
+ end
136
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
137
+ {events: [], blobs: {}, data: stop_pending_reply(params, lineage.proof_id, action)}
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ def finalize_stopped_attempt!(request, peer, role, map, journal, digest)
144
+ params = request.fetch("params")
145
+ with_exclusion(params, map, journal) do
146
+ @mutex.synchronize do
147
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
148
+ mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
149
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
150
+ steering_principal!(params, peer, role)
151
+ lineage = stop_owner!(params, map, events)
152
+ unless lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
153
+ raise AttemptErrors::EvidenceUnavailable, "Original scope closure proof is unavailable"
154
+ end
155
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
156
+ raise AttemptErrors::EvidenceUnavailable, "Settlement owner is unavailable" unless @result_owner
157
+ services = @result_owner.service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
158
+
159
+ provisioning = events.select { |event| event["type"] == "scope_provisioning" }
160
+ raise AttemptErrors::EvidenceUnavailable, "Original deployment provenance is ambiguous" unless provisioning.one?
161
+ selection = params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id"),
162
+ "descriptor_sha256" => provisioning.first.fetch("payload").fetch("descriptor_sha256"),
163
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"),
164
+ "closed_proof_event_id" => lineage.proof_id)
165
+ plan = Organisms::AttemptCoordinator.stopped_transition_plan(events: events, selection: selection,
166
+ service_evidence: services, commit: commit)
167
+ plan.merge(data: {"attempt_id" => params.fetch("attempt_id"), "state" => "stopped", "proof_id" => lineage.proof_id, "required_action" => nil})
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ # Historical terminal authentication uses the original immutable
174
+ # descriptor and the actual introduction prefix, not today's pointer.
175
+ def terminal_scope_stopped!(events, journal, commit, deployment:)
176
+ terminal = events.select { |event| event["type"] == "attempt_stopped" }
177
+ raise AttemptErrors::EvidenceUnavailable, "Stopped terminal is missing or ambiguous" unless terminal.one?
178
+ event = terminal.first
179
+ acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
180
+ raise AttemptErrors::EvidenceUnavailable, "Stopped terminal acceptance is missing" unless acceptance
181
+ assignment_id = event.fetch("payload").fetch("assignment_id")
182
+ prefix_commit = terminal_event_commit!(journal, assignment_id: assignment_id, event_digest: acceptance.fetch("digest"), commit: commit)
183
+ verify_stopped_terminal_at_prefix!(events, journal, prefix_commit, deployment: deployment)
184
+ rescue KeyError, TypeError, NoMethodError
185
+ raise AttemptErrors::EvidenceUnavailable, "Stopped original terminal evidence is malformed"
186
+ end
187
+
188
+ # Source composition supplies only an introduction authenticated by the
189
+ # canonical Git owner. This immutable verifier grants no live exclusion.
190
+ def verify_stopped_terminal_at_prefix!(events, journal, prefix_commit, deployment:)
191
+ terminals = events.select { |entry| entry["type"] == "attempt_stopped" }
192
+ raise AttemptErrors::EvidenceUnavailable, "Stopped terminal is missing or ambiguous" unless terminals.one?
193
+ event = terminals.first
194
+ accepted = events.select { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
195
+ raise AttemptErrors::EvidenceUnavailable, "Stopped terminal acceptance is missing or ambiguous" unless accepted.one?
196
+ acceptance = accepted.first
197
+ assignment_id = event.fetch("payload").fetch("assignment_id")
198
+ prefix = journal.read_events(assignment_id, commit: prefix_commit).select { |entry| entry["attempt_id"] == event.fetch("attempt_id") }
199
+ unless prefix == events.take(events.index(acceptance) + 1) && Molecules::CanonicalAttemptState.derive(prefix) == "stopped"
200
+ raise AttemptErrors::EvidenceUnavailable, "Stopped terminal introduction prefix differs"
201
+ end
202
+ payload = event.fetch("payload")
203
+ descriptor = if deployment.artifact_reference&.fetch("sha256") == payload.fetch("descriptor_sha256")
204
+ deployment
205
+ else
206
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped descriptor history is unavailable" unless @deployment_history
207
+ @deployment_history.descriptor!(sha256: payload.fetch("descriptor_sha256"))
208
+ end
209
+ map = descriptor.mapping(payload.fetch("mapping_id"))
210
+ unless map.fetch("project_id") == payload.fetch("project_id")
211
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped project differs"
212
+ end
213
+ provisioning = prefix.select { |entry| entry["type"] == "scope_provisioning" }
214
+ reservation = prefix.select { |entry| entry["type"] == "authority_mutation" && entry.dig("payload", "operation") == "reserve_attempt" }
215
+ unless provisioning.one? && reservation.one?
216
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped reservation is ambiguous"
217
+ end
218
+ identity = provisioning.first.fetch("payload")
219
+ reservation_data = reservation.first.dig("payload", "data")
220
+ unless identity.is_a?(Hash) && identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id] &&
221
+ reservation_data.is_a?(Hash) && identity["reservation_generation"] == reservation_data["reservation_generation"] &&
222
+ reservation_data.values_at("mapping_id", "project_id") == payload.values_at("mapping_id", "project_id") &&
223
+ Digest::SHA256.hexdigest(JSON.generate(canonical(map))) == identity.fetch("deployment_digest") &&
224
+ map.fetch("execution_scope").fetch("slot_id") == identity.fetch("slot_id") &&
225
+ descriptor.project(map.fetch("project_id")).values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
226
+ [journal.repo_root, journal.ref, journal.checkout_root]
227
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped descriptor canonical association differs"
228
+ end
229
+ params = payload.slice("mapping_id", "assignment_id", "attempt_id")
230
+ lineage = Molecules::ExecutionScopeLineage.new(events: prefix, **payload.slice("project_id", "assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
231
+ unless lineage.binding.fetch("deployment_digest") == identity.fetch("deployment_digest")
232
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped parent mapping differs"
233
+ end
234
+ verify_historical_boot_baseline!(lineage.binding)
235
+ lineage.require_positive!(scope_generation: lineage.binding.fetch("scope_generation"),
236
+ scope_binding_event_id: payload.fetch("scope_binding_event_id"), seal_event_id: payload.fetch("seal_event_id"), proof_id: payload.fetch("closed_proof_event_id"))
237
+ if issued_input_actor?(prefix) && !accepted_input_inhibition?(prefix, journal, prefix_commit)
238
+ raise AttemptErrors::EvidenceUnavailable, "Stopped original input lifetime is not inhibited"
239
+ end
240
+ raise AttemptErrors::EvidenceUnavailable, "Original stopped settlement owner is unavailable" unless @result_owner
241
+ services = @result_owner.service_settlement_evidence!(journal: journal, events: prefix, params: params, map: map, commit: prefix_commit)
242
+
243
+ expected = Molecules::CanonicalAttemptState.stopped_payload!(events: prefix,
244
+ selection: payload.slice(*Molecules::CanonicalAttemptState::SELECTION_FIELDS), service_evidence: services,
245
+ commit: prefix_commit)
246
+ raise AttemptErrors::EvidenceUnavailable, "Stopped exhaustive settlement differs" unless payload == expected
247
+ event
248
+ rescue KeyError, TypeError, NoMethodError
249
+ raise AttemptErrors::EvidenceUnavailable, "Stopped original terminal evidence is malformed"
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end