ace-assign 0.64.0 → 0.66.1

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 +25 -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 +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -100,6 +100,11 @@ module Ace
100
100
  end
101
101
 
102
102
  def authorize_result_transfer!(request:, peer:, role:)
103
+ params, map = result_request(request)
104
+ authorize_admitted_result_transfer!(request: request, peer: peer, role: role)
105
+ end
106
+
107
+ def authorize_admitted_result_transfer!(request:, peer:, role:)
103
108
  params, map = result_request(request)
104
109
  @launch.with_assignment(params: params, map: map) do |journal, _|
105
110
  protected_journal!(journal)
@@ -116,6 +121,11 @@ module Ace
116
121
  end
117
122
 
118
123
  def dispatch_result(request:, peer:, role:, transfer: nil)
124
+ params, map = result_request(request)
125
+ dispatch_admitted_result(request: request, peer: peer, role: role, transfer: transfer)
126
+ end
127
+
128
+ def dispatch_admitted_result(request:, peer:, role:, transfer: nil)
119
129
  params, map = result_request(request)
120
130
  admitted = if request.fetch("operation") == "submit_result"
121
131
  authorize_result_transfer!(request: request, peer: peer, role: role)
@@ -138,7 +148,9 @@ module Ace
138
148
  raise AttemptErrors::EvidenceUnavailable, "result replay provenance differs"
139
149
  end
140
150
  end
141
- result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
151
+ result = with_campaign_submission!(journal: journal, commit: commit, events: events, params: params,
152
+ map: map, admitted: admitted, replay: replay) do
153
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
142
154
  mutation_id: request.fetch("mutation_id"), operation: "submit_result",
143
155
  parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
144
156
  with_replay: true) do |fresh_events, fresh_commit, generation|
@@ -148,6 +160,7 @@ module Ace
148
160
  raise AttemptErrors::Conflict, "candidate already has a result"
149
161
  end
150
162
  result_plan(journal, fresh_events, params, map, origin, current, admitted, fresh_commit, generation)
163
+ end
151
164
  end
152
165
  # A CAS loser may return a competing accepted same-ID reply. It
153
166
  # still needs exact canonical private provenance outside callback.
@@ -178,22 +191,26 @@ module Ace
178
191
  intent.fetch("payload"), events, "running")
179
192
  end
180
193
 
181
- def verify_result_receipt(journal, events, params, map, head, receipt, reader)
194
+ def verify_result_receipt(journal, events, params, map, head, receipt, reader, commit:)
182
195
  unless receipt["producer"] == {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"}
183
196
  raise AttemptErrors::ReceiptRejected, "result producer differs from mapped worker"
184
197
  end
185
- raise AttemptErrors::EvidenceUnavailable, "protected campaigns require their canonical owner" if receipt["campaign"]
186
198
  allowed = Models::ExecutionReceipt.from_h(receipt).to_h.keys
187
199
  raise AttemptErrors::ReceiptRejected, "result receipt fields differ" unless (receipt.keys - allowed).empty?
188
200
  # Failed receipts can omit artifacts, but every declared artifact still
189
201
  # needs canonical byte/provenance validation.
190
202
  Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
191
- Molecules::ReceiptVerifier.new(artifact_reader: reader).verify_result!(receipt,
203
+ campaign_verifier = ->(data, result, _head) {
204
+ with_campaign_receipt!(journal: journal, commit: commit, events: events, params: params,
205
+ map: map, receipt: data, result: result, historical: true) { true }
206
+ }
207
+ Molecules::ReceiptVerifier.new(artifact_reader: reader, campaign_verifier: campaign_verifier).verify_result!(receipt,
192
208
  attempt: result_attempt(journal, events, params), live_head: head, repo_root: journal.repo_root)
193
209
  end
194
210
 
195
211
  def result_plan(journal, events, params, map, origin, current, admitted, commit, generation)
196
212
  receipt = admitted.fetch(:receipt)
213
+ campaign_child_result!(journal: journal, commit: commit, events: events, params: params, map: map, receipt: receipt)
197
214
  original = Models::ExecutionReceipt.from_h(receipt)
198
215
  unless original.digest == Atoms::EvidenceDigest.digest(original.digest_payload)
199
216
  raise AttemptErrors::ReceiptRejected, "original result digest differs"
@@ -211,10 +228,15 @@ module Ace
211
228
  admitted_after_event_digest: events.last.fetch("digest"))
212
229
  normalized = JSON.parse(JSON.generate(receipt)).except("digest", "recorded_at")
213
230
  normalized["artifacts"] = plan.fetch(:references).map { |ref| {"path" => ref.fetch("ref"), "sha256" => ref.fetch("sha256")} }
231
+ if normalized["campaign"]
232
+ campaign_index = receipt.fetch("artifacts").index(receipt.fetch("campaign").fetch("result"))
233
+ raise AttemptErrors::ReceiptRejected, "campaign result artifact missing" unless campaign_index
234
+ normalized["campaign"]["result"] = normalized.fetch("artifacts").fetch(campaign_index)
235
+ end
214
236
  pending = journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
215
237
  reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
216
238
  **result_context(binding), current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: commit) }
217
- verified = verify_result_receipt(journal, events, params, map, current.fetch("head"), normalized, reader)
239
+ verified = verify_result_receipt(journal, events, params, map, current.fetch("head"), normalized, reader, commit: commit)
218
240
  payload = {"version" => 1, "result_id" => id, "binding" => binding,
219
241
  "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256), "original_receipt_digest" => original.digest,
220
242
  "receipt_digest" => verified.digest, "receipt" => verified.to_h, "artifacts" => verified.artifacts}
@@ -256,10 +278,12 @@ module Ace
256
278
  replies.first.fetch("payload").fetch("data").slice(*RESULT_FIELDS) == result_projection(payload)
257
279
  raise AttemptErrors::EvidenceUnavailable, "private result provenance differs"
258
280
  end
281
+ campaign_child_result!(journal: journal, commit: commit, events: events, params: params, map: map,
282
+ receipt: payload.fetch("receipt"))
259
283
  canonical = Molecules::CanonicalEvidence.new(journal: journal)
260
284
  reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
261
285
  **result_context(binding), commit: commit) }
262
- receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), payload.fetch("receipt"), reader)
286
+ receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), payload.fetch("receipt"), reader, commit: commit)
263
287
  raise AttemptErrors::EvidenceUnavailable, "normalized result digest differs" unless receipt.digest == payload.fetch("receipt_digest")
264
288
  payload
265
289
  rescue KeyError, ArgumentError, TypeError, AttemptErrors::ReceiptRejected
@@ -275,8 +299,10 @@ module Ace
275
299
  when :launcher
276
300
  peer["uid"] == map.fetch("launcher_uid") && @kernel.same?(peer, origin.fetch("launcher_identity"))
277
301
  when :worker
278
- kind == "result" && peer["uid"] == map.fetch("worker_uid") &&
279
- @kernel.descendant?(peer, origin.fetch("process_binding").fetch("process_identity"))
302
+ peer["uid"] == map.fetch("worker_uid") &&
303
+ @kernel.descendant?(peer, origin.fetch("process_binding").fetch("process_identity")) &&
304
+ (kind == "result" || kind == "service" && record && record["caller_uid"] == peer["uid"] &&
305
+ record["request_id"] == purpose_id && @kernel.descendant?(peer, record.fetch("worker_process_binding")))
280
306
  when :reviewer
281
307
  review = assigned_review(events)
282
308
  %w[result review].include?(kind) && review && current && review["head"] == current["head"] &&
@@ -295,7 +321,7 @@ module Ace
295
321
  service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
296
322
  origin = retained_origin(events, params)
297
323
  kind, id = params.values_at("kind", "purpose_id")
298
- if (role == :worker && kind != "result") || (role == :reviewer && !%w[result review].include?(kind)) ||
324
+ if (role == :worker && !%w[result service].include?(kind)) || (role == :reviewer && !%w[result review].include?(kind)) ||
299
325
  (role == :executor && kind != "service")
300
326
  raise AttemptErrors::UnauthorizedIdentity, "canonical evidence kind is unauthorized"
301
327
  end
@@ -324,14 +350,6 @@ module Ace
324
350
  request_id_or_event_id: id, generation: review.fetch("candidate_generation")}
325
351
  verify_retained_review!(journal, events, params, map, current, review, ctx, commit)
326
352
  [ctx, review.fetch("review_receipt").fetch("artifacts")]
327
- when "inbox"
328
- registrations = events.select { |entry| entry["type"] == "inbox_binding" && entry.dig("payload", "event_id") == id }
329
- raise AttemptErrors::NotFound, "inbox purpose not found" if registrations.empty?
330
- raise AttemptErrors::EvidenceUnavailable, "inbox registration differs" unless registrations.one?
331
- selected = params.merge("event_id" => id, "inbox_context_id" => registrations.first.dig("payload", "inbox_context_id"))
332
- retained = verified_inbox(journal, events, selected, map, peer, role, commit)
333
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox proof missing" unless retained
334
- [inbox_context(retained.fetch("binding")), %w[receipt_ref signature_ref].map { |key| retained.fetch(key).slice("sha256").merge("path" => retained.fetch(key).fetch("ref")) }]
335
353
  when "service"
336
354
  record = journal.service_request(id, commit: commit)
337
355
  raise AttemptErrors::NotFound, "service purpose not found" unless record
@@ -369,7 +387,7 @@ module Ace
369
387
  end
370
388
  canonical = Molecules::CanonicalEvidence.new(journal: journal)
371
389
  reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")}, **context, commit: commit) }
372
- receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), accepted.fetch("review_receipt"), reader)
390
+ receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), accepted.fetch("review_receipt"), reader, commit: commit)
373
391
  unless receipt.operation == "review" && receipt.verdict == "succeeded" && receipt.digest == accepted.fetch("receipt_digest")
374
392
  raise AttemptErrors::EvidenceUnavailable, "retained review receipt differs"
375
393
  end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ private
8
+
9
+ def review_requester!(params, map, peer, role, recorded: nil)
10
+ @kernel.live!(peer)
11
+ current = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
12
+ raise AttemptErrors::UnauthorizedIdentity, "review mapping changed" unless current == map
13
+ project = @deployment.project(map.fetch("project_id"))
14
+ credentials = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
15
+ principal = peer.slice("uid", "gid", "groups").merge("role" => role.to_s)
16
+ unless role == :reviewer && project.fetch("reviewer_uids").include?(peer.fetch("uid")) &&
17
+ peer.fetch("uid") != map.fetch("worker_uid") && credentials &&
18
+ peer.values_at("gid", "groups") == credentials.values_at("gid", "groups") &&
19
+ (!recorded || Atoms::EvidenceDigest.digest(principal) == Atoms::EvidenceDigest.digest(recorded))
20
+ raise AttemptErrors::UnauthorizedIdentity, "review request requires its independently mapped principal"
21
+ end
22
+ principal
23
+ end
24
+
25
+ def request_review(request, params, map, peer, role, transfer)
26
+ raise ArgumentError, "review request forbids upload" unless transfer.nil?
27
+ unless request["mutation_id"].is_a?(String) && request["mutation_id"].match?(Molecules::JournalMutation::ID) &&
28
+ params["expected_generation"].is_a?(Integer) && params["expected_generation"] >= 0
29
+ raise ArgumentError, "review request mutation binding differs"
30
+ end
31
+ principal = review_requester!(params, map, peer, role)
32
+ digest = Atoms::EvidenceDigest.digest(params.merge("requester_process_binding" => peer))
33
+ channel = reservation = accepted = nil
34
+ prepared = @launch.with_assignment(params: params, map: map) do |journal, _registration|
35
+ protected_journal!(journal)
36
+ events = attempt_events(journal, params)
37
+ active_origin(events, params)
38
+ replay = journal.mutation_result(request.fetch("mutation_id"))
39
+ if replay
40
+ unless replay["operation"] == "request_review" && replay["parameters_digest"] == digest &&
41
+ replay["assignment_id"] == params.fetch("assignment_id") && replay["attempt_id"] == params.fetch("attempt_id")
42
+ raise AttemptErrors::Conflict, "review mutation is already bound to different input"
43
+ end
44
+ accepted = {data: replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit")), replayed: true}
45
+ else
46
+ channel, reservation, original = @launch.reserve_review_dispatch!(events: events, params: params, map: map)
47
+ accepted = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
48
+ mutation_id: request.fetch("mutation_id"), operation: "request_review", parameters_digest: digest,
49
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |fresh, _commit, _generation|
50
+ review_requester!(params, map, peer, role)
51
+ active_origin(fresh, params)
52
+ @launch.scope_open_for_effect!(events: fresh, params: params, map: map)
53
+ current = exact_candidate!(candidate(fresh), params)
54
+ if active_review_reservation(fresh, current)
55
+ raise AttemptErrors::Conflict, "candidate review reservation is active"
56
+ end
57
+ unless channel.dispatch_reserved?(reservation) && @launch.review_original_reference!(events: fresh, params: params) == original
58
+ raise AttemptErrors::EvidenceUnavailable, "original review channel reservation changed"
59
+ end
60
+ {data: current.slice("head", "candidate_generation").merge(
61
+ "original_binding_digest" => original.fetch("binding_digest"), "requester" => principal,
62
+ "reviewer_uid" => peer.fetch("uid"), "reviewer_process_binding" => peer)}
63
+ end
64
+ end
65
+ review_event_reply(journal, accepted, request, params, "request_event_id")
66
+ end
67
+ unless prepared.fetch(:replayed)
68
+ frame = {"version" => 1, "type" => "launch_review_delegate", "attempt_id" => params.fetch("attempt_id"),
69
+ "mutation_id" => request.fetch("mutation_id"), "request_event_id" => prepared.dig(:data, "request_event_id"),
70
+ "journal_commit" => prepared.dig(:data, "journal_commit"),
71
+ "original_binding_digest" => prepared.dig(:data, "original_binding_digest")}
72
+ begin
73
+ channel.dispatch_review(frame: frame, deadline: Ace::Runtime::Molecules::ProtectedSocket.deadline(30), reservation: reservation)
74
+ rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable, IOError, SystemCallError
75
+ # The canonical assignment, not a transport ACK or its absence,
76
+ # decides whether the recorded first reply can name an assignment.
77
+ end
78
+ end
79
+ finish_review_reply(request, params, map, peer, role, prepared)
80
+ ensure
81
+ channel&.release_dispatch!(reservation) if reservation
82
+ end
83
+
84
+ def review_request_event!(journal, events, params, commit: journal.ref_value)
85
+ accepted = journal.mutation_result(params.fetch("mutation_id"), commit: commit)
86
+ unless accepted && accepted["operation"] == "request_review" &&
87
+ accepted["assignment_id"] == params.fetch("assignment_id") && accepted["attempt_id"] == params.fetch("attempt_id")
88
+ raise AttemptErrors::NotFound, "canonical review request is unavailable"
89
+ end
90
+ event = events.find { |item| item["type"] == "authority_mutation" && item.dig("payload", "mutation_id") == params.fetch("mutation_id") }
91
+ unless event && event.fetch("payload") == accepted.except("journal_commit")
92
+ raise AttemptErrors::EvidenceUnavailable, "canonical review request differs"
93
+ end
94
+ [event, accepted]
95
+ end
96
+
97
+ def review_assignment_observation(journal, events, params, event, commit: journal.ref_value)
98
+ mutation = "review-delegate.#{event.fetch('digest')}"
99
+ accepted = journal.mutation_result(mutation, commit: commit)
100
+ return nil unless accepted
101
+ source = event.fetch("payload").fetch("data")
102
+ expected = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
103
+ source.slice("head", "candidate_generation", "reviewer_uid", "reviewer_process_binding"),
104
+ "expected_generation" => source.fetch("generation"))
105
+ actual = events.find { |item| item["type"] == "authority_mutation" && item.dig("payload", "mutation_id") == mutation }
106
+ data = accepted.fetch("data")
107
+ unless accepted["operation"] == "assign_review" && accepted["assignment_id"] == params.fetch("assignment_id") &&
108
+ accepted["attempt_id"] == params.fetch("attempt_id") && actual && actual.fetch("payload") == accepted.except("journal_commit") &&
109
+ accepted["parameters_digest"] == Atoms::EvidenceDigest.digest(expected) &&
110
+ data.keys.sort == %w[candidate_generation generation head review_id reviewer_actor reviewer_process_binding reviewer_uid] &&
111
+ data["generation"].is_a?(Integer) && data.fetch("generation") == source.fetch("generation") + 1 &&
112
+ data["review_id"].is_a?(String) && data["review_id"].match?(/\A[0-9a-f]{32}\z/) &&
113
+ data["reviewer_actor"] == "uid-#{source.fetch('reviewer_uid')}" &&
114
+ Atoms::EvidenceDigest.digest(data.slice("head", "candidate_generation", "reviewer_uid", "reviewer_process_binding")) ==
115
+ Atoms::EvidenceDigest.digest(source.slice("head", "candidate_generation", "reviewer_uid", "reviewer_process_binding"))
116
+ raise AttemptErrors::EvidenceUnavailable, "canonical delegated assignment differs"
117
+ end
118
+ data.merge("assignment_event_id" => actual.fetch("digest"), "journal_commit" => accepted.fetch("journal_commit"))
119
+ end
120
+
121
+ def finish_review_reply(request, params, map, peer, role, prepared)
122
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
123
+ events = attempt_events(journal, params)
124
+ selectors = params.slice("mapping_id", "assignment_id", "attempt_id").merge("mutation_id" => request.fetch("mutation_id"))
125
+ event, = review_request_event!(journal, events, selectors)
126
+ review_requester!(params, map, peer, role, recorded: event.dig("payload", "data", "requester"))
127
+ reply_id = "review-reply.#{event.fetch('digest')}"
128
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
129
+ mutation_id: reply_id, operation: "review_reply", parameters_digest: Atoms::EvidenceDigest.digest(selectors),
130
+ expected_generation: journal.authority_generation(events), with_replay: true) do |fresh, selected, _generation|
131
+ assignment = review_assignment_observation(journal, fresh, params, event, commit: selected)
132
+ {data: {"request_event_id" => event.fetch("digest"), "request_commit" => prepared.dig(:data, "journal_commit"),
133
+ "state" => assignment ? "assigned" : "uncertain", "assignment" => assignment,
134
+ "required_action" => assignment ? nil : "inspect_review_status"}}
135
+ end
136
+ result.merge(replayed: prepared.fetch(:replayed) || result.fetch(:replayed))
137
+ end
138
+ end
139
+
140
+ def review_status(request, peer, role, transfer)
141
+ raise ArgumentError, "review status forbids upload" unless transfer.nil?
142
+ params = request.fetch("params")
143
+ unless request["mutation_id"].nil? && params.is_a?(Hash) && params.keys.sort == %w[assignment_id attempt_id mapping_id mutation_id] &&
144
+ params.values.all? { |value| value.is_a?(String) && value.match?(Molecules::JournalMutation::ID) }
145
+ raise ArgumentError, "invalid review status selectors"
146
+ end
147
+ map = @deployment.mapping(params.fetch("mapping_id"))
148
+ raise AttemptErrors::UnauthorizedIdentity, "review project differs" unless map.fetch("project_id") == request.fetch("project_id")
149
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
150
+ protected_journal!(journal)
151
+ commit = journal.ref_value
152
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
153
+ event, = review_request_event!(journal, events, params, commit: commit)
154
+ review_requester!(params, map, peer, role, recorded: event.dig("payload", "data", "requester"))
155
+ assignment = review_assignment_observation(journal, events, params, event, commit: commit)
156
+ cancellation = events.find { |item| item["type"] == "authority_mutation" && item.dig("payload", "operation") == "cancel_review" &&
157
+ item.dig("payload", "data", "review_event_id") == event.fetch("digest") }
158
+ acceptance = assignment && events.reverse.find do |item|
159
+ item["type"] == "authority_mutation" && item.dig("payload", "operation") == "accept_review" &&
160
+ item.dig("payload", "data", "review_id") == assignment.fetch("review_id")
161
+ end
162
+ current = candidate(events)
163
+ state = if cancellation
164
+ "cancelled"
165
+ elsif !current || current.values_at("head", "candidate_generation") != event.dig("payload", "data").values_at("head", "candidate_generation")
166
+ "candidate_replaced"
167
+ elsif assignment
168
+ "assigned"
169
+ else
170
+ "requested"
171
+ end
172
+ first = journal.mutation_result("review-reply.#{event.fetch('digest')}", commit: commit)
173
+ {data: {"request_event_id" => event.fetch("digest"), "assignment" => assignment, "state" => state,
174
+ "accepted_review_event_id" => acceptance&.fetch("digest"),
175
+ "first_reply" => first && first.fetch("data").merge("journal_commit" => first.fetch("journal_commit")),
176
+ "cancellation_event_id" => cancellation&.fetch("digest"), "generation" => journal.authority_generation(events),
177
+ "journal_commit" => commit}, replayed: false}
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ private
8
+
9
+ # Historical assignment lookup stays separate: revocation removes
10
+ # permission, never the original receipt or response.
11
+ def active_review_reservation(events, current)
12
+ reservation = events.reverse.find do |event|
13
+ event["type"] == "authority_mutation" &&
14
+ %w[request_review assign_review].include?(event.dig("payload", "operation")) &&
15
+ !event.dig("payload", "mutation_id").to_s.start_with?("review-delegate.") &&
16
+ event.dig("payload", "data", "head") == current.fetch("head") &&
17
+ event.dig("payload", "data", "candidate_generation") == current.fetch("candidate_generation")
18
+ end
19
+ return unless reservation
20
+ cancelled = events.any? do |event|
21
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == "cancel_review" &&
22
+ event.dig("payload", "data", "review_event_id") == reservation.fetch("digest")
23
+ end
24
+ reservation unless cancelled
25
+ end
26
+
27
+ def active_review_event(events, current)
28
+ reservation = active_review_reservation(events, current)
29
+ return unless reservation
30
+ return reservation if reservation.dig("payload", "operation") == "assign_review"
31
+ events.find do |event|
32
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == "assign_review" &&
33
+ event.dig("payload", "mutation_id") == "review-delegate.#{reservation.fetch('digest')}"
34
+ end
35
+ end
36
+
37
+ # Reserved spelling is not authorization. Reconstruct the immutable
38
+ # request through the original-launcher reader before historical replay,
39
+ # then separately check the active reservation for a fresh mutation.
40
+ def delegated_review_request!(journal, events, request, params, peer, role)
41
+ mutation = request.fetch("mutation_id")
42
+ return unless mutation.is_a?(String) && mutation.start_with?("review-delegate.")
43
+ digest = mutation.delete_prefix("review-delegate.")
44
+ original = events.find { |event| event["digest"] == digest && event["type"] == "authority_mutation" &&
45
+ event.dig("payload", "operation") == "request_review" }
46
+ raise AttemptErrors::UnauthorizedIdentity, "delegation requires a canonical request" unless original
47
+ accepted = journal.mutation_result(original.dig("payload", "mutation_id"))
48
+ selectors = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
49
+ "mutation_id" => original.dig("payload", "mutation_id"), "request_event_id" => digest,
50
+ "journal_commit" => accepted.fetch("journal_commit"),
51
+ "original_binding_digest" => original.dig("payload", "data", "original_binding_digest"))
52
+ intent = @launch.launch_review_intent!(request: request.merge("operation" => "launch_review_intent",
53
+ "mutation_id" => nil, "params" => selectors), peer: peer, role: role)
54
+ unless Atoms::EvidenceDigest.digest(intent.dig(:data, "assignment_params")) == Atoms::EvidenceDigest.digest(params)
55
+ raise AttemptErrors::UnauthorizedIdentity, "delegation differs from exact canonical request"
56
+ end
57
+ original
58
+ end
59
+
60
+ def cancel_review(request, params, map, peer, role, transfer)
61
+ raise ArgumentError, "review cancellation forbids upload" unless transfer.nil?
62
+ unless params["review_event_id"].is_a?(String) && params["review_event_id"].match?(/\A[0-9a-f]{64}\z/)
63
+ raise ArgumentError, "review cancellation selector differs"
64
+ end
65
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
66
+ protected_journal!(journal)
67
+ events = attempt_events(journal, params)
68
+ authenticate_review_cancellation!(events, params, map, peer, role)
69
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
70
+ mutation_id: request.fetch("mutation_id"), operation: "cancel_review",
71
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
72
+ with_replay: true) do |current_events, _commit, _generation|
73
+ authenticate_review_cancellation!(current_events, params, map, peer, role)
74
+ current = exact_candidate!(candidate(current_events), params)
75
+ reservation = active_review_reservation(current_events, current)
76
+ unless reservation && reservation.fetch("digest") == params.fetch("review_event_id")
77
+ raise AttemptErrors::Conflict, "review reservation is no longer active"
78
+ end
79
+ {data: current.slice("head", "candidate_generation").merge(
80
+ "review_event_id" => reservation.fetch("digest"),
81
+ "review_id" => active_review_event(current_events, current)&.dig("payload", "data", "review_id"), "state" => "cancelled")}
82
+ end
83
+ review_event_reply(journal, result, request, params, "cancellation_event_id")
84
+ end
85
+ end
86
+
87
+ def review_event_reply(journal, result, request, params, field)
88
+ accepted = journal.read_events(params.fetch("assignment_id"), commit: result.fetch(:data).fetch("journal_commit")).find do |event|
89
+ event["attempt_id"] == params.fetch("attempt_id") && event["type"] == "authority_mutation" &&
90
+ event.dig("payload", "operation") == request.fetch("operation") &&
91
+ event.dig("payload", "mutation_id") == request.fetch("mutation_id")
92
+ end
93
+ raise AttemptErrors::EvidenceUnavailable, "accepted review event is unavailable" unless accepted
94
+ result.merge(data: result.fetch(:data).merge(field => accepted.fetch("digest")))
95
+ end
96
+
97
+ def authenticate_review_cancellation!(events, params, map, peer, role)
98
+ origin = active_origin(events, params)
99
+ @kernel.live!(peer)
100
+ original = events.find { |event| event["digest"] == params.fetch("review_event_id") }
101
+ unless original && original["type"] == "authority_mutation" &&
102
+ %w[request_review assign_review].include?(original.dig("payload", "operation")) &&
103
+ !original.dig("payload", "mutation_id").to_s.start_with?("review-delegate.") &&
104
+ original.dig("payload", "data", "head") == params.fetch("head") &&
105
+ original.dig("payload", "data", "candidate_generation") == params.fetch("candidate_generation")
106
+ raise AttemptErrors::UnauthorizedIdentity, "review cancellation requires the exact reservation root"
107
+ end
108
+ if role == :launcher
109
+ unless @kernel.same?(origin.fetch("launcher_identity"), peer)
110
+ raise AttemptErrors::UnauthorizedIdentity, "cancellation requires original launcher birth"
111
+ end
112
+ worker_or_launcher!(peer, role, map, origin, launcher_only: true)
113
+ elsif role == :reviewer && original.dig("payload", "operation") == "request_review"
114
+ project = @deployment.project(map.fetch("project_id"))
115
+ credentials = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
116
+ principal = peer.slice("uid", "gid", "groups").merge("role" => "reviewer")
117
+ unless project.fetch("reviewer_uids").include?(peer.fetch("uid")) && peer.fetch("uid") != map.fetch("worker_uid") &&
118
+ credentials && peer.values_at("gid", "groups") == credentials.values_at("gid", "groups") &&
119
+ Atoms::EvidenceDigest.digest(principal) == Atoms::EvidenceDigest.digest(original.dig("payload", "data", "requester"))
120
+ raise AttemptErrors::UnauthorizedIdentity, "cancellation requires the original mapped requester"
121
+ end
122
+ else
123
+ raise AttemptErrors::UnauthorizedIdentity, "review reservation cancellation is unauthorized"
124
+ end
125
+ original
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ private
8
+
9
+ def authorize_service_settlement!(request, params, map, peer, role)
10
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
11
+ protected_journal!(journal)
12
+ service_settlement_record!(journal, params, map, peer, role)
13
+ end
14
+ true
15
+ end
16
+
17
+ def service_settlement_record!(journal, params, map, peer, role, record: nil)
18
+ record ||= journal.service_request(params.fetch("request_id"))
19
+ raise AttemptErrors::NotFound, "canonical service request is missing" unless record
20
+ service_executor!(peer, role, record)
21
+ receiver = service_receiver!(peer, role, map, record.fetch("service_id"))
22
+ credentials = @deployment.project(map.fetch("project_id")).fetch("peer_credentials").fetch(peer.fetch("uid").to_s)
23
+ unless peer["gid"] == credentials.fetch("gid") && peer["groups"] == credentials.fetch("groups") &&
24
+ receiver.fetch("executor_uid") == record.fetch("executor_uid") &&
25
+ %w[assignment_id attempt_id mapping_id].all? { |key| record[key] == params[key] } &&
26
+ record["project_id"] == map.fetch("project_id") && record["candidate_head"] == params.fetch("head") &&
27
+ record["candidate_generation"] == params.fetch("candidate_generation") &&
28
+ %w[issued dispatch_started].include?(record["dispatch_phase"])
29
+ raise AttemptErrors::UnauthorizedIdentity, "original service settlement identity differs"
30
+ end
31
+ # The normal protected binding validates original ticket/claim, even
32
+ # while effect policy, worker liveness or current candidate changed.
33
+ ServiceEvidence.new(journal: journal).context(record)
34
+ record
35
+ rescue KeyError
36
+ raise AttemptErrors::EvidenceUnavailable, "original service settlement binding is incomplete"
37
+ end
38
+
39
+ def dispatch_service_settlement(request, params, map, peer, role, transfer)
40
+ authorize_service_settlement!(request, params, map, peer, role)
41
+ completion = request.fetch("operation") == "complete_no_effect"
42
+ admitted = completion && ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
43
+ artifact_field: "evidence", reference_key: "ref")
44
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
45
+ record = service_settlement_record!(journal, params, map, peer, role)
46
+ no_effect_completion_binding!(record, params, admitted) if completion
47
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
48
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
49
+ parameters_digest: Atoms::EvidenceDigest.digest(params),
50
+ expected_generation: completion ? nil : params.fetch("expected_generation"),
51
+ generation_mode: completion ? :recorded_completion : :expected, with_replay: true) do |events, commit, generation|
52
+ current = service_settlement_record!(journal, params, map, peer, role)
53
+ if completion
54
+ no_effect_completion_plan(journal, events, current, params, admitted)
55
+ else
56
+ service_challenge_plan(journal, events, current, params, map, generation, commit)
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def service_challenge_projection(record)
63
+ record.slice("request_id", "state", "dispatch_ticket_id", "claim_binding", "dispatch_phase").merge(
64
+ "reconciliation_challenge" => %w[succeeded failed-settled].include?(record.fetch("state")) ? nil :
65
+ record.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"))
66
+ end
67
+
68
+ def service_challenge_plan(journal, events, record, params, map, generation, commit)
69
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
70
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
71
+ unless lineage.sealed? && lineage.binding && record["reservation_generation"] == lineage.binding.fetch("reservation_generation")
72
+ raise AttemptErrors::Conflict, "original service generation is not sealed"
73
+ end
74
+ return {data: service_challenge_projection(record)} if %w[succeeded failed-settled].include?(record.fetch("state"))
75
+ unless %w[uncertain failed].include?(record.fetch("state"))
76
+ raise AttemptErrors::EvidenceUnavailable, "original protected request has no uncertainty outcome"
77
+ end
78
+ if record["challenge_event_digest"]
79
+ begin
80
+ ServiceEvidence.new(journal: journal).challenge!(record, pending: {commit: commit})
81
+ return {data: service_challenge_projection(record)}
82
+ rescue AttemptErrors::EvidenceUnavailable
83
+ # Only a positively authenticated later failure may supersede a
84
+ # challenge. Corrupt/missing old history never becomes freshness.
85
+ old = ServiceEvidence.new(journal: journal).challenge!(record, pending: {commit: commit}, current: false)
86
+ raise unless events.drop_while { |event| event["digest"] != old.fetch("digest") }.drop(1).any? do |event|
87
+ %w[service_claim service_transition].include?(event["type"]) && event.dig("payload", "request_id") == record.fetch("request_id") &&
88
+ %w[uncertain failed].include?(event.dig("payload", "state"))
89
+ end
90
+ end
91
+ end
92
+ failure = events.reverse.find { |event| %w[service_claim service_transition].include?(event["type"]) &&
93
+ event.dig("payload", "request_id") == record.fetch("request_id") && %w[uncertain failed].include?(event.dig("payload", "state")) }
94
+ raise AttemptErrors::EvidenceUnavailable, "canonical service failure is missing" unless failure
95
+ failure_prefix = events.take_while { |event| event["digest"] != failure.fetch("digest") }
96
+ payload = record.slice("request_id", "input_digest", "claim_binding", "dispatch_ticket_id").merge(
97
+ "version" => 1, "no_effect_challenge" => SecureRandom.hex(16), "challenge_generation" => generation + 1,
98
+ "failure_event_digest" => failure.fetch("digest"), "failure_generation" => journal.authority_generation(failure_prefix) + 1)
99
+ recorded_at = Time.now.utc
100
+ challenge = Models::EvidenceEvent.build(type: "service_no_effect_challenge", attempt_id: params.fetch("attempt_id"),
101
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: recorded_at)
102
+ replacement = record.merge(payload.slice("no_effect_challenge", "challenge_generation"), "challenge_event_digest" => challenge.fetch("digest"))
103
+ {events: [{type: "service_no_effect_challenge", payload: payload, recorded_at: recorded_at}],
104
+ data: service_challenge_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
105
+ expected: record, replacement: replacement, event_type: "service_challenge"}]}
106
+ end
107
+
108
+ def no_effect_completion_binding!(record, params, admitted)
109
+ selector = params.fetch("reconciliation_challenge")
110
+ unless selector.is_a?(Hash) && selector.keys.sort == %w[no_effect_challenge challenge_generation challenge_event_digest].sort &&
111
+ selector["no_effect_challenge"].is_a?(String) && selector["no_effect_challenge"].match?(/\A[0-9a-f]{32}\z/) &&
112
+ selector["challenge_generation"].is_a?(Integer) && selector["challenge_generation"].positive? &&
113
+ selector["challenge_event_digest"].is_a?(String) && selector["challenge_event_digest"].match?(/\A[0-9a-f]{64}\z/)
114
+ raise ArgumentError, "no-effect challenge selector is malformed"
115
+ end
116
+ unless selector.all? { |key, value| record[key] == value } && record["claim_binding"] == params.fetch("claim_binding")
117
+ raise AttemptErrors::Conflict, "no-effect challenge or claim differs"
118
+ end
119
+ receipt = admitted.fetch(:receipt)
120
+ unless receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort && receipt["outcome"] == "failed" &&
121
+ Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.all? { |field| receipt[field] == record[field] }
122
+ raise AttemptErrors::ReceiptRejected, "no-effect receipt original binding differs"
123
+ end
124
+ digest = Atoms::EvidenceDigest.digest("challenge" => selector, "receipt_sha256" => admitted.fetch(:receipt_sha256),
125
+ "artifacts" => admitted.fetch(:artifacts).map { |bytes| Digest::SHA256.hexdigest(bytes) })
126
+ if record["state"] == "succeeded" || record["no_effect_completion_digest"] && record["no_effect_completion_digest"] != digest
127
+ raise AttemptErrors::Conflict, "no-effect completion contradicts retained outcome"
128
+ end
129
+ digest
130
+ end
131
+
132
+ def no_effect_completion_plan(journal, events, record, params, admitted)
133
+ digest = no_effect_completion_binding!(record, params, admitted)
134
+ return {data: service_projection(record)} if record["no_effect_completion_digest"]
135
+ owner = ServiceEvidence.new(journal: journal)
136
+ challenge = owner.challenge!(record)
137
+ if record["operation"] == "prune-preserved-workspace"
138
+ owner.cleanup_inspection_inputs!(admitted.fetch(:artifacts), record, challenge)
139
+ else
140
+ admitted.fetch(:artifacts).each { |bytes| owner.inspection!(bytes, record, challenge) }
141
+ end
142
+ plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record, no_effect: true),
143
+ artifacts: admitted.fetch(:artifacts), admitted_after_event_digest: challenge.fetch("digest"))
144
+ receipt = admitted.fetch(:receipt).merge("evidence" => plan.fetch(:references))
145
+ replacement = record.merge("state" => "failed-settled", "receipt" => receipt, "no_effect_completion_digest" => digest)
146
+ plan.merge(data: service_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
147
+ expected: record, replacement: replacement, event_type: "service_transition"}])
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end