ace-assign 0.63.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ RESULT_FIELDS = %w[result_id head candidate_generation verdict uploaded_receipt_sha256
8
+ original_receipt_digest receipt_digest artifacts].freeze
9
+ RESULT_PAYLOAD_FIELDS = %w[version result_id binding uploaded_receipt_sha256
10
+ original_receipt_digest receipt_digest receipt artifacts].freeze
11
+ RESULT_BINDING_FIELDS = %w[mapping_id project_id assignment_id attempt_id result_id worker_uid
12
+ worker_actor launch_ticket process_binding head candidate_generation uploaded_receipt_sha256
13
+ original_receipt_digest].freeze
14
+ DIGEST = /\A[0-9a-f]{64}\z/
15
+
16
+ # Attached to the existing launch status owner, never a second route.
17
+ def result_status(journal:, events:, params:, map:, peer:, role:, commit:)
18
+ protected_journal!(journal)
19
+ @kernel.live!(peer)
20
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
21
+ raise AttemptErrors::UnauthorizedIdentity, "executor result discovery is unauthorized" if role == :executor
22
+ origin = retained_origin(events, params)
23
+ selected = params.fetch("result_candidate_generation")
24
+ current = selected.nil? ? candidate(events) : retained_candidate(events, selected)
25
+ if !current && events.any? { |event| event["type"] == "result_submitted" &&
26
+ (selected.nil? || event.dig("payload", "binding", "candidate_generation") == selected) }
27
+ raise AttemptErrors::EvidenceUnavailable, "submitted result candidate is missing"
28
+ end
29
+ raise AttemptErrors::NotFound, "candidate not found" if !selected.nil? && !current
30
+ purpose_peer!(events, params, map, origin, peer, role, kind: "result", current: current)
31
+ result = current && verified_result(journal, events, params, map, origin, current, commit)
32
+ {"result_candidate_generation" => current && current.fetch("candidate_generation"),
33
+ "submitted_result" => result && result_projection(result),
34
+ "authority_generation" => journal.authority_generation(events)}
35
+ end
36
+
37
+ private
38
+
39
+ def result_request(request)
40
+ operation = request.fetch("operation")
41
+ params = request.fetch("params")
42
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch(operation).sort
43
+ raise ArgumentError, "result authority fields differ"
44
+ end
45
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params[key]) }
46
+ if operation == "submit_result"
47
+ result_id!(request.fetch("mutation_id"))
48
+ unless params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
49
+ %w[expected_generation candidate_generation].all? { |key| params[key].is_a?(Integer) && params[key] >= 0 } &&
50
+ params["receipt_sha256"].is_a?(String) && params["receipt_sha256"].match?(DIGEST)
51
+ raise ArgumentError, "invalid result candidate or digest"
52
+ end
53
+ else
54
+ raise ArgumentError, "fetch mutation ID must be null" unless request.fetch("mutation_id").nil?
55
+ raise ArgumentError, "unknown evidence kind" unless Molecules::CanonicalEvidence::ROLES.key?(params["kind"])
56
+ %w[purpose_id artifact_id].each { |key| result_id!(params[key]) }
57
+ end
58
+ map = @deployment.mapping(params.fetch("mapping_id"))
59
+ raise AttemptErrors::UnauthorizedIdentity, "project mapping differs" unless map.fetch("project_id") == request.fetch("project_id")
60
+ [params, map]
61
+ end
62
+
63
+ def result_id!(value)
64
+ raise ArgumentError, "invalid result identifier" unless value.is_a?(String) && value.match?(Molecules::JournalMutation::ID)
65
+ end
66
+
67
+ def retained_origin(events, params)
68
+ raise AttemptErrors::EvidenceUnavailable, "canonical attempt chain differs" unless Models::EvidenceEvent.chain_valid?(events)
69
+ @launch.origin(events, assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
70
+ mapping_id: params.fetch("mapping_id"))
71
+ end
72
+
73
+ def retained_candidate(events, generation)
74
+ candidates = events.filter_map do |event|
75
+ event.dig("payload", "data") if event["type"] == "authority_mutation" &&
76
+ event.dig("payload", "operation") == "submit_candidate" &&
77
+ event.dig("payload", "data", "candidate_generation") == generation
78
+ end
79
+ raise AttemptErrors::EvidenceUnavailable, "duplicate candidate generation" if candidates.length > 1
80
+ candidates.first
81
+ end
82
+
83
+ def submitting_worker!(events, params, map, peer, role)
84
+ origin = retained_origin(events, params)
85
+ unless %w[bound issued].include?(origin["phase"]) &&
86
+ !events.any? { |event| %w[receipt_accepted transition reconciliation].include?(event["type"]) &&
87
+ %w[succeeded failed stopped uncertain].include?(event.dig("payload", "to") ||
88
+ event.dig("payload", "resolution") || event.dig("payload", "receipt", "verdict")) }
89
+ raise AttemptErrors::EvidenceUnavailable, "result attempt is no longer live and active"
90
+ end
91
+ raise AttemptErrors::UnauthorizedIdentity, "result requires owning worker" unless role == :worker && peer["uid"] == map.fetch("worker_uid")
92
+ @kernel.live!(peer)
93
+ @kernel.live!(origin.fetch("process_binding").fetch("process_identity"))
94
+ @kernel.live!(origin.fetch("process_binding").fetch("native_origin").fetch("server_identity"))
95
+ unless @kernel.descendant?(peer, origin.fetch("process_binding").fetch("process_identity"))
96
+ raise AttemptErrors::UnauthorizedIdentity, "result worker incarnation differs"
97
+ end
98
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
99
+ origin
100
+ end
101
+
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:)
108
+ params, map = result_request(request)
109
+ @launch.with_assignment(params: params, map: map) do |journal, _|
110
+ protected_journal!(journal)
111
+ commit = journal.ref_value
112
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
113
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
114
+ if request.fetch("operation") == "submit_result"
115
+ submitting_worker!(events, params, map, peer, role)
116
+ else
117
+ fetch_plan(journal, events, params, map, peer, role, commit)
118
+ end
119
+ end
120
+ true
121
+ end
122
+
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)
129
+ params, map = result_request(request)
130
+ admitted = if request.fetch("operation") == "submit_result"
131
+ authorize_result_transfer!(request: request, peer: peer, role: role)
132
+ ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
133
+ artifact_field: "artifacts", reference_key: "path")
134
+ end
135
+ @launch.with_assignment(params: params, map: map) do |journal, _|
136
+ protected_journal!(journal)
137
+ commit = journal.ref_value
138
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
139
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
140
+ return fetch_plan(journal, events, params, map, peer, role, commit) unless admitted
141
+ origin = submitting_worker!(events, params, map, peer, role)
142
+ replay = journal.mutation_result(request.fetch("mutation_id"))
143
+ if replay && replay["operation"] == "submit_result"
144
+ current = retained_candidate(events, replay.dig("data", "candidate_generation"))
145
+ raise AttemptErrors::EvidenceUnavailable, "result candidate is missing" unless current
146
+ retained = verified_result(journal, events, params, map, origin, current, commit)
147
+ unless retained && result_projection(retained) == replay.fetch("data").slice(*RESULT_FIELDS)
148
+ raise AttemptErrors::EvidenceUnavailable, "result replay provenance differs"
149
+ end
150
+ end
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"),
154
+ mutation_id: request.fetch("mutation_id"), operation: "submit_result",
155
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
156
+ with_replay: true) do |fresh_events, fresh_commit, generation|
157
+ origin = submitting_worker!(fresh_events, params, map, peer, role)
158
+ current = exact_candidate!(candidate(fresh_events), params)
159
+ if verified_result(journal, fresh_events, params, map, origin, current, fresh_commit)
160
+ raise AttemptErrors::Conflict, "candidate already has a result"
161
+ end
162
+ result_plan(journal, fresh_events, params, map, origin, current, admitted, fresh_commit, generation)
163
+ end
164
+ end
165
+ # A CAS loser may return a competing accepted same-ID reply. It
166
+ # still needs exact canonical private provenance outside callback.
167
+ read_commit = journal.ref_value
168
+ retained_events = journal.read_events(params.fetch("assignment_id"), commit: read_commit)
169
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
170
+ current = retained_candidate(retained_events, result.dig(:data, "candidate_generation"))
171
+ retained = current && verified_result(journal, retained_events, params, map, origin, current, read_commit)
172
+ unless retained && result_projection(retained) == result.fetch(:data).slice(*RESULT_FIELDS)
173
+ raise AttemptErrors::EvidenceUnavailable, "accepted result provenance differs"
174
+ end
175
+ result
176
+ end
177
+ rescue KeyError, TypeError
178
+ raise AttemptErrors::EvidenceUnavailable, "canonical result binding is incomplete"
179
+ end
180
+
181
+ def result_context(binding)
182
+ {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
183
+ attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
184
+ request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
185
+ end
186
+
187
+ def result_attempt(journal, events, params)
188
+ intent = events.find { |event| event["type"] == "intent" }
189
+ raise AttemptErrors::EvidenceUnavailable, "result attempt intent missing" unless intent
190
+ journal.send(:build_attempt, params.fetch("assignment_id"), params.fetch("attempt_id"),
191
+ intent.fetch("payload"), events, "running")
192
+ end
193
+
194
+ def verify_result_receipt(journal, events, params, map, head, receipt, reader, commit:)
195
+ unless receipt["producer"] == {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"}
196
+ raise AttemptErrors::ReceiptRejected, "result producer differs from mapped worker"
197
+ end
198
+ allowed = Models::ExecutionReceipt.from_h(receipt).to_h.keys
199
+ raise AttemptErrors::ReceiptRejected, "result receipt fields differ" unless (receipt.keys - allowed).empty?
200
+ # Failed receipts can omit artifacts, but every declared artifact still
201
+ # needs canonical byte/provenance validation.
202
+ Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
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,
208
+ attempt: result_attempt(journal, events, params), live_head: head, repo_root: journal.repo_root)
209
+ end
210
+
211
+ def result_plan(journal, events, params, map, origin, current, admitted, commit, generation)
212
+ receipt = admitted.fetch(:receipt)
213
+ campaign_child_result!(journal: journal, commit: commit, events: events, params: params, map: map, receipt: receipt)
214
+ original = Models::ExecutionReceipt.from_h(receipt)
215
+ unless original.digest == Atoms::EvidenceDigest.digest(original.digest_payload)
216
+ raise AttemptErrors::ReceiptRejected, "original result digest differs"
217
+ end
218
+ id = SecureRandom.hex(16)
219
+ binding = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
220
+ "project_id" => map.fetch("project_id"), "result_id" => id, "worker_uid" => map.fetch("worker_uid"),
221
+ "worker_actor" => map.fetch("worker_actor"), "launch_ticket" => origin.fetch("launch_ticket"),
222
+ "process_binding" => origin.fetch("process_binding"), "head" => current.fetch("head"),
223
+ "candidate_generation" => current.fetch("candidate_generation"),
224
+ "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256), "original_receipt_digest" => original.digest)
225
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
226
+ plan = admitted.fetch(:artifacts).empty? ? {events: [], blobs: {}, references: []} :
227
+ canonical.import_plan(**result_context(binding), artifacts: admitted.fetch(:artifacts),
228
+ admitted_after_event_digest: events.last.fetch("digest"))
229
+ normalized = JSON.parse(JSON.generate(receipt)).except("digest", "recorded_at")
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
236
+ pending = journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
237
+ reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
238
+ **result_context(binding), current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: commit) }
239
+ verified = verify_result_receipt(journal, events, params, map, current.fetch("head"), normalized, reader, commit: commit)
240
+ payload = {"version" => 1, "result_id" => id, "binding" => binding,
241
+ "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256), "original_receipt_digest" => original.digest,
242
+ "receipt_digest" => verified.digest, "receipt" => verified.to_h, "artifacts" => verified.artifacts}
243
+ data = result_projection(payload)
244
+ @launch.send(:bounded_reply!, data, generation + 1)
245
+ plan.merge(events: plan.fetch(:events) + [{type: "result_submitted", payload: payload}], data: data)
246
+ end
247
+
248
+ def result_projection(payload)
249
+ payload.slice("result_id", "uploaded_receipt_sha256", "original_receipt_digest", "receipt_digest", "artifacts")
250
+ .merge(payload.fetch("binding").slice("head", "candidate_generation"), "verdict" => payload.fetch("receipt").fetch("verdict"))
251
+ end
252
+
253
+ def verified_result(journal, events, params, map, origin, current, commit, result_id: nil)
254
+ raise AttemptErrors::EvidenceUnavailable, "result chain differs" unless Models::EvidenceEvent.chain_valid?(events)
255
+ records = events.select { |event| event["type"] == "result_submitted" &&
256
+ (event.dig("payload", "binding", "candidate_generation") == current.fetch("candidate_generation") ||
257
+ (result_id && event.dig("payload", "result_id") == result_id)) }
258
+ replies = events.select { |event| event["type"] == "authority_mutation" &&
259
+ event.dig("payload", "operation") == "submit_result" &&
260
+ event.dig("payload", "data", "candidate_generation") == current.fetch("candidate_generation") }
261
+ return nil if records.empty? && replies.empty?
262
+ raise AttemptErrors::EvidenceUnavailable, "result record is missing or duplicated" unless records.length == 1 && replies.length == 1
263
+ payload = records.first.fetch("payload")
264
+ binding = payload.fetch("binding")
265
+ expected = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
266
+ "project_id" => map.fetch("project_id"), "worker_uid" => map.fetch("worker_uid"),
267
+ "worker_actor" => map.fetch("worker_actor"), "launch_ticket" => origin.fetch("launch_ticket"),
268
+ "process_binding" => origin.fetch("process_binding"), "head" => current.fetch("head"),
269
+ "candidate_generation" => current.fetch("candidate_generation"), "result_id" => payload.fetch("result_id"),
270
+ "uploaded_receipt_sha256" => payload.fetch("uploaded_receipt_sha256"),
271
+ "original_receipt_digest" => payload.fetch("original_receipt_digest"))
272
+ unless payload.keys.sort == RESULT_PAYLOAD_FIELDS.sort && payload["version"] == 1 &&
273
+ binding.keys.sort == RESULT_BINDING_FIELDS.sort && binding == expected &&
274
+ payload["result_id"].is_a?(String) && payload["result_id"].match?(/\A[0-9a-f]{32}\z/) &&
275
+ %w[uploaded_receipt_sha256 original_receipt_digest receipt_digest].all? { |key| payload[key].is_a?(String) && payload[key].match?(DIGEST) } &&
276
+ events.count { |event| event["type"] == "result_submitted" && event.dig("payload", "result_id") == payload["result_id"] } == 1 &&
277
+ payload["artifacts"] == payload.dig("receipt", "artifacts") &&
278
+ replies.first.fetch("payload").fetch("data").slice(*RESULT_FIELDS) == result_projection(payload)
279
+ raise AttemptErrors::EvidenceUnavailable, "private result provenance differs"
280
+ end
281
+ campaign_child_result!(journal: journal, commit: commit, events: events, params: params, map: map,
282
+ receipt: payload.fetch("receipt"))
283
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
284
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
285
+ **result_context(binding), commit: commit) }
286
+ receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), payload.fetch("receipt"), reader, commit: commit)
287
+ raise AttemptErrors::EvidenceUnavailable, "normalized result digest differs" unless receipt.digest == payload.fetch("receipt_digest")
288
+ payload
289
+ rescue KeyError, ArgumentError, TypeError, AttemptErrors::ReceiptRejected
290
+ raise AttemptErrors::EvidenceUnavailable, "canonical result is unverifiable"
291
+ end
292
+
293
+ def purpose_peer!(events, params, map, origin, peer, role, kind:, current: nil, purpose_id: nil, record: nil)
294
+ @kernel.live!(peer)
295
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
296
+ permitted = case role
297
+ when :supervisor
298
+ @deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
299
+ when :launcher
300
+ peer["uid"] == map.fetch("launcher_uid") && @kernel.same?(peer, origin.fetch("launcher_identity"))
301
+ when :worker
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")))
306
+ when :reviewer
307
+ review = assigned_review(events)
308
+ %w[result review].include?(kind) && review && current && review["head"] == current["head"] &&
309
+ review["candidate_generation"] == current["candidate_generation"] && review["reviewer_uid"] == peer["uid"] &&
310
+ (kind != "review" || review["review_id"] == purpose_id) && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
311
+ when :executor
312
+ kind == "service" && record && record["executor_uid"] == peer["uid"] && record["request_id"] == purpose_id
313
+ else
314
+ false
315
+ end
316
+ raise AttemptErrors::UnauthorizedIdentity, "canonical evidence purpose is unauthorized" unless permitted
317
+ end
318
+
319
+ def fetch_plan(journal, events, params, map, peer, role, commit)
320
+ @kernel.live!(peer)
321
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
322
+ origin = retained_origin(events, params)
323
+ kind, id = params.values_at("kind", "purpose_id")
324
+ if (role == :worker && !%w[result service].include?(kind)) || (role == :reviewer && !%w[result review].include?(kind)) ||
325
+ (role == :executor && kind != "service")
326
+ raise AttemptErrors::UnauthorizedIdentity, "canonical evidence kind is unauthorized"
327
+ end
328
+ context, references = case kind
329
+ when "result"
330
+ event = events.find { |item| item["type"] == "result_submitted" && item.dig("payload", "result_id") == id }
331
+ unless event
332
+ raise AttemptErrors::EvidenceUnavailable, "result record is missing" if events.any? { |item| item.dig("payload", "operation") == "submit_result" && item.dig("payload", "data", "result_id") == id }
333
+ raise AttemptErrors::NotFound, "result purpose not found"
334
+ end
335
+ current = retained_candidate(events, event.dig("payload", "binding", "candidate_generation"))
336
+ raise AttemptErrors::EvidenceUnavailable, "result candidate missing" unless current
337
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, current: current, purpose_id: id)
338
+ result = verified_result(journal, events, params, map, origin, current, commit, result_id: id)
339
+ [result_context(result.fetch("binding")), result.fetch("artifacts")]
340
+ when "review"
341
+ accepted = events.filter_map { |item| item.dig("payload", "data") if item["type"] == "authority_mutation" &&
342
+ item.dig("payload", "operation") == "accept_review" && item.dig("payload", "data", "review_id") == id }
343
+ raise AttemptErrors::NotFound, "review purpose not found" if accepted.empty?
344
+ raise AttemptErrors::EvidenceUnavailable, "duplicate review purpose" unless accepted.length == 1
345
+ review = accepted.first
346
+ current = retained_candidate(events, review.fetch("candidate_generation"))
347
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, current: current, purpose_id: id)
348
+ ctx = {kind: kind, project_id: map.fetch("project_id"), assignment_id: params.fetch("assignment_id"),
349
+ attempt_id: params.fetch("attempt_id"), peer_uid: review.fetch("reviewer_uid"), binding: review.fetch("review_binding"),
350
+ request_id_or_event_id: id, generation: review.fetch("candidate_generation")}
351
+ verify_retained_review!(journal, events, params, map, current, review, ctx, commit)
352
+ [ctx, review.fetch("review_receipt").fetch("artifacts")]
353
+ when "service"
354
+ record = journal.service_request(id, commit: commit)
355
+ raise AttemptErrors::NotFound, "service purpose not found" unless record
356
+ unless record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] && record["project_id"] == map["project_id"]
357
+ raise AttemptErrors::UnauthorizedIdentity, "service attempt differs"
358
+ end
359
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, purpose_id: id, record: record)
360
+ raise AttemptErrors::EvidenceUnavailable, "service completion evidence unavailable" unless record["receipt"]
361
+ ctx = ServiceEvidence.new(journal: journal).context(record, no_effect: record["state"] == "failed-settled")
362
+ [ctx, record.fetch("receipt").fetch("evidence").map { |ref| {"path" => ref.fetch("ref"), "sha256" => ref.fetch("sha256")} }]
363
+ else
364
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, purpose_id: id)
365
+ raise AttemptErrors::EvidenceUnavailable, "canonical signer or observer provenance unavailable"
366
+ end
367
+ reference = references.find { |ref| ref["path"] == "evidence/imports/#{params.fetch('artifact_id')}" }
368
+ raise AttemptErrors::NotFound, "artifact not found in exact purpose" unless reference
369
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
370
+ bytes = canonical.read({"ref" => reference.fetch("path"), "sha256" => reference.fetch("sha256")}, **context, commit: commit)
371
+ descriptor = events.find { |event| event["type"] == "evidence_import" && event.dig("payload", "artifact_id") == params["artifact_id"] }.fetch("payload")
372
+ {data: {"descriptor" => descriptor, "generation" => journal.authority_generation(events), "journal_commit" => commit},
373
+ replayed: false, transfer_parts: [bytes]}
374
+ rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
375
+ raise AttemptErrors::EvidenceUnavailable, "canonical evidence purpose is unverifiable"
376
+ end
377
+
378
+ def verify_retained_review!(journal, events, params, map, current, accepted, context, commit)
379
+ assignments = events.filter_map { |event| event.dig("payload", "data") if event["type"] == "authority_mutation" &&
380
+ event.dig("payload", "operation") == "assign_review" && event.dig("payload", "data", "review_id") == accepted["review_id"] }
381
+ review = assignments.first
382
+ unless assignments.length == 1 && current && review["reviewer_uid"] != map.fetch("worker_uid") &&
383
+ accepted["head"] == current["head"] && accepted["reviewer_uid"] == review["reviewer_uid"] &&
384
+ accepted["review_binding"] == review.slice("review_id", "head", "candidate_generation", "reviewer_uid", "reviewer_process_binding") &&
385
+ accepted.dig("review_receipt", "review", "reviewer", "actor") == review["reviewer_actor"]
386
+ raise AttemptErrors::EvidenceUnavailable, "retained review binding differs"
387
+ end
388
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
389
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")}, **context, commit: commit) }
390
+ receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), accepted.fetch("review_receipt"), reader, commit: commit)
391
+ unless receipt.operation == "review" && receipt.verdict == "succeeded" && receipt.digest == accepted.fetch("receipt_digest")
392
+ raise AttemptErrors::EvidenceUnavailable, "retained review receipt differs"
393
+ end
394
+ end
395
+ end
396
+ end
397
+ end
398
+ 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