ace-assign 0.63.1 → 0.64.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +43 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,380 @@
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
+ @launch.with_assignment(params: params, map: map) do |journal, _|
105
+ protected_journal!(journal)
106
+ commit = journal.ref_value
107
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
108
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
109
+ if request.fetch("operation") == "submit_result"
110
+ submitting_worker!(events, params, map, peer, role)
111
+ else
112
+ fetch_plan(journal, events, params, map, peer, role, commit)
113
+ end
114
+ end
115
+ true
116
+ end
117
+
118
+ def dispatch_result(request:, peer:, role:, transfer: nil)
119
+ params, map = result_request(request)
120
+ admitted = if request.fetch("operation") == "submit_result"
121
+ authorize_result_transfer!(request: request, peer: peer, role: role)
122
+ ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
123
+ artifact_field: "artifacts", reference_key: "path")
124
+ end
125
+ @launch.with_assignment(params: params, map: map) do |journal, _|
126
+ protected_journal!(journal)
127
+ commit = journal.ref_value
128
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
129
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
130
+ return fetch_plan(journal, events, params, map, peer, role, commit) unless admitted
131
+ origin = submitting_worker!(events, params, map, peer, role)
132
+ replay = journal.mutation_result(request.fetch("mutation_id"))
133
+ if replay && replay["operation"] == "submit_result"
134
+ current = retained_candidate(events, replay.dig("data", "candidate_generation"))
135
+ raise AttemptErrors::EvidenceUnavailable, "result candidate is missing" unless current
136
+ retained = verified_result(journal, events, params, map, origin, current, commit)
137
+ unless retained && result_projection(retained) == replay.fetch("data").slice(*RESULT_FIELDS)
138
+ raise AttemptErrors::EvidenceUnavailable, "result replay provenance differs"
139
+ end
140
+ end
141
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
142
+ mutation_id: request.fetch("mutation_id"), operation: "submit_result",
143
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
144
+ with_replay: true) do |fresh_events, fresh_commit, generation|
145
+ origin = submitting_worker!(fresh_events, params, map, peer, role)
146
+ current = exact_candidate!(candidate(fresh_events), params)
147
+ if verified_result(journal, fresh_events, params, map, origin, current, fresh_commit)
148
+ raise AttemptErrors::Conflict, "candidate already has a result"
149
+ end
150
+ result_plan(journal, fresh_events, params, map, origin, current, admitted, fresh_commit, generation)
151
+ end
152
+ # A CAS loser may return a competing accepted same-ID reply. It
153
+ # still needs exact canonical private provenance outside callback.
154
+ read_commit = journal.ref_value
155
+ retained_events = journal.read_events(params.fetch("assignment_id"), commit: read_commit)
156
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
157
+ current = retained_candidate(retained_events, result.dig(:data, "candidate_generation"))
158
+ retained = current && verified_result(journal, retained_events, params, map, origin, current, read_commit)
159
+ unless retained && result_projection(retained) == result.fetch(:data).slice(*RESULT_FIELDS)
160
+ raise AttemptErrors::EvidenceUnavailable, "accepted result provenance differs"
161
+ end
162
+ result
163
+ end
164
+ rescue KeyError, TypeError
165
+ raise AttemptErrors::EvidenceUnavailable, "canonical result binding is incomplete"
166
+ end
167
+
168
+ def result_context(binding)
169
+ {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
170
+ attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
171
+ request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
172
+ end
173
+
174
+ def result_attempt(journal, events, params)
175
+ intent = events.find { |event| event["type"] == "intent" }
176
+ raise AttemptErrors::EvidenceUnavailable, "result attempt intent missing" unless intent
177
+ journal.send(:build_attempt, params.fetch("assignment_id"), params.fetch("attempt_id"),
178
+ intent.fetch("payload"), events, "running")
179
+ end
180
+
181
+ def verify_result_receipt(journal, events, params, map, head, receipt, reader)
182
+ unless receipt["producer"] == {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"}
183
+ raise AttemptErrors::ReceiptRejected, "result producer differs from mapped worker"
184
+ end
185
+ raise AttemptErrors::EvidenceUnavailable, "protected campaigns require their canonical owner" if receipt["campaign"]
186
+ allowed = Models::ExecutionReceipt.from_h(receipt).to_h.keys
187
+ raise AttemptErrors::ReceiptRejected, "result receipt fields differ" unless (receipt.keys - allowed).empty?
188
+ # Failed receipts can omit artifacts, but every declared artifact still
189
+ # needs canonical byte/provenance validation.
190
+ Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
191
+ Molecules::ReceiptVerifier.new(artifact_reader: reader).verify_result!(receipt,
192
+ attempt: result_attempt(journal, events, params), live_head: head, repo_root: journal.repo_root)
193
+ end
194
+
195
+ def result_plan(journal, events, params, map, origin, current, admitted, commit, generation)
196
+ receipt = admitted.fetch(:receipt)
197
+ original = Models::ExecutionReceipt.from_h(receipt)
198
+ unless original.digest == Atoms::EvidenceDigest.digest(original.digest_payload)
199
+ raise AttemptErrors::ReceiptRejected, "original result digest differs"
200
+ end
201
+ id = SecureRandom.hex(16)
202
+ binding = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
203
+ "project_id" => map.fetch("project_id"), "result_id" => id, "worker_uid" => map.fetch("worker_uid"),
204
+ "worker_actor" => map.fetch("worker_actor"), "launch_ticket" => origin.fetch("launch_ticket"),
205
+ "process_binding" => origin.fetch("process_binding"), "head" => current.fetch("head"),
206
+ "candidate_generation" => current.fetch("candidate_generation"),
207
+ "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256), "original_receipt_digest" => original.digest)
208
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
209
+ plan = admitted.fetch(:artifacts).empty? ? {events: [], blobs: {}, references: []} :
210
+ canonical.import_plan(**result_context(binding), artifacts: admitted.fetch(:artifacts),
211
+ admitted_after_event_digest: events.last.fetch("digest"))
212
+ normalized = JSON.parse(JSON.generate(receipt)).except("digest", "recorded_at")
213
+ normalized["artifacts"] = plan.fetch(:references).map { |ref| {"path" => ref.fetch("ref"), "sha256" => ref.fetch("sha256")} }
214
+ pending = journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
215
+ reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
216
+ **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)
218
+ payload = {"version" => 1, "result_id" => id, "binding" => binding,
219
+ "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256), "original_receipt_digest" => original.digest,
220
+ "receipt_digest" => verified.digest, "receipt" => verified.to_h, "artifacts" => verified.artifacts}
221
+ data = result_projection(payload)
222
+ @launch.send(:bounded_reply!, data, generation + 1)
223
+ plan.merge(events: plan.fetch(:events) + [{type: "result_submitted", payload: payload}], data: data)
224
+ end
225
+
226
+ def result_projection(payload)
227
+ payload.slice("result_id", "uploaded_receipt_sha256", "original_receipt_digest", "receipt_digest", "artifacts")
228
+ .merge(payload.fetch("binding").slice("head", "candidate_generation"), "verdict" => payload.fetch("receipt").fetch("verdict"))
229
+ end
230
+
231
+ def verified_result(journal, events, params, map, origin, current, commit, result_id: nil)
232
+ raise AttemptErrors::EvidenceUnavailable, "result chain differs" unless Models::EvidenceEvent.chain_valid?(events)
233
+ records = events.select { |event| event["type"] == "result_submitted" &&
234
+ (event.dig("payload", "binding", "candidate_generation") == current.fetch("candidate_generation") ||
235
+ (result_id && event.dig("payload", "result_id") == result_id)) }
236
+ replies = events.select { |event| event["type"] == "authority_mutation" &&
237
+ event.dig("payload", "operation") == "submit_result" &&
238
+ event.dig("payload", "data", "candidate_generation") == current.fetch("candidate_generation") }
239
+ return nil if records.empty? && replies.empty?
240
+ raise AttemptErrors::EvidenceUnavailable, "result record is missing or duplicated" unless records.length == 1 && replies.length == 1
241
+ payload = records.first.fetch("payload")
242
+ binding = payload.fetch("binding")
243
+ expected = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
244
+ "project_id" => map.fetch("project_id"), "worker_uid" => map.fetch("worker_uid"),
245
+ "worker_actor" => map.fetch("worker_actor"), "launch_ticket" => origin.fetch("launch_ticket"),
246
+ "process_binding" => origin.fetch("process_binding"), "head" => current.fetch("head"),
247
+ "candidate_generation" => current.fetch("candidate_generation"), "result_id" => payload.fetch("result_id"),
248
+ "uploaded_receipt_sha256" => payload.fetch("uploaded_receipt_sha256"),
249
+ "original_receipt_digest" => payload.fetch("original_receipt_digest"))
250
+ unless payload.keys.sort == RESULT_PAYLOAD_FIELDS.sort && payload["version"] == 1 &&
251
+ binding.keys.sort == RESULT_BINDING_FIELDS.sort && binding == expected &&
252
+ payload["result_id"].is_a?(String) && payload["result_id"].match?(/\A[0-9a-f]{32}\z/) &&
253
+ %w[uploaded_receipt_sha256 original_receipt_digest receipt_digest].all? { |key| payload[key].is_a?(String) && payload[key].match?(DIGEST) } &&
254
+ events.count { |event| event["type"] == "result_submitted" && event.dig("payload", "result_id") == payload["result_id"] } == 1 &&
255
+ payload["artifacts"] == payload.dig("receipt", "artifacts") &&
256
+ replies.first.fetch("payload").fetch("data").slice(*RESULT_FIELDS) == result_projection(payload)
257
+ raise AttemptErrors::EvidenceUnavailable, "private result provenance differs"
258
+ end
259
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
260
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
261
+ **result_context(binding), commit: commit) }
262
+ receipt = verify_result_receipt(journal, events, params, map, current.fetch("head"), payload.fetch("receipt"), reader)
263
+ raise AttemptErrors::EvidenceUnavailable, "normalized result digest differs" unless receipt.digest == payload.fetch("receipt_digest")
264
+ payload
265
+ rescue KeyError, ArgumentError, TypeError, AttemptErrors::ReceiptRejected
266
+ raise AttemptErrors::EvidenceUnavailable, "canonical result is unverifiable"
267
+ end
268
+
269
+ def purpose_peer!(events, params, map, origin, peer, role, kind:, current: nil, purpose_id: nil, record: nil)
270
+ @kernel.live!(peer)
271
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
272
+ permitted = case role
273
+ when :supervisor
274
+ @deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
275
+ when :launcher
276
+ peer["uid"] == map.fetch("launcher_uid") && @kernel.same?(peer, origin.fetch("launcher_identity"))
277
+ when :worker
278
+ kind == "result" && peer["uid"] == map.fetch("worker_uid") &&
279
+ @kernel.descendant?(peer, origin.fetch("process_binding").fetch("process_identity"))
280
+ when :reviewer
281
+ review = assigned_review(events)
282
+ %w[result review].include?(kind) && review && current && review["head"] == current["head"] &&
283
+ review["candidate_generation"] == current["candidate_generation"] && review["reviewer_uid"] == peer["uid"] &&
284
+ (kind != "review" || review["review_id"] == purpose_id) && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
285
+ when :executor
286
+ kind == "service" && record && record["executor_uid"] == peer["uid"] && record["request_id"] == purpose_id
287
+ else
288
+ false
289
+ end
290
+ raise AttemptErrors::UnauthorizedIdentity, "canonical evidence purpose is unauthorized" unless permitted
291
+ end
292
+
293
+ def fetch_plan(journal, events, params, map, peer, role, commit)
294
+ @kernel.live!(peer)
295
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
296
+ origin = retained_origin(events, params)
297
+ kind, id = params.values_at("kind", "purpose_id")
298
+ if (role == :worker && kind != "result") || (role == :reviewer && !%w[result review].include?(kind)) ||
299
+ (role == :executor && kind != "service")
300
+ raise AttemptErrors::UnauthorizedIdentity, "canonical evidence kind is unauthorized"
301
+ end
302
+ context, references = case kind
303
+ when "result"
304
+ event = events.find { |item| item["type"] == "result_submitted" && item.dig("payload", "result_id") == id }
305
+ unless event
306
+ raise AttemptErrors::EvidenceUnavailable, "result record is missing" if events.any? { |item| item.dig("payload", "operation") == "submit_result" && item.dig("payload", "data", "result_id") == id }
307
+ raise AttemptErrors::NotFound, "result purpose not found"
308
+ end
309
+ current = retained_candidate(events, event.dig("payload", "binding", "candidate_generation"))
310
+ raise AttemptErrors::EvidenceUnavailable, "result candidate missing" unless current
311
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, current: current, purpose_id: id)
312
+ result = verified_result(journal, events, params, map, origin, current, commit, result_id: id)
313
+ [result_context(result.fetch("binding")), result.fetch("artifacts")]
314
+ when "review"
315
+ accepted = events.filter_map { |item| item.dig("payload", "data") if item["type"] == "authority_mutation" &&
316
+ item.dig("payload", "operation") == "accept_review" && item.dig("payload", "data", "review_id") == id }
317
+ raise AttemptErrors::NotFound, "review purpose not found" if accepted.empty?
318
+ raise AttemptErrors::EvidenceUnavailable, "duplicate review purpose" unless accepted.length == 1
319
+ review = accepted.first
320
+ current = retained_candidate(events, review.fetch("candidate_generation"))
321
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, current: current, purpose_id: id)
322
+ ctx = {kind: kind, project_id: map.fetch("project_id"), assignment_id: params.fetch("assignment_id"),
323
+ attempt_id: params.fetch("attempt_id"), peer_uid: review.fetch("reviewer_uid"), binding: review.fetch("review_binding"),
324
+ request_id_or_event_id: id, generation: review.fetch("candidate_generation")}
325
+ verify_retained_review!(journal, events, params, map, current, review, ctx, commit)
326
+ [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
+ when "service"
336
+ record = journal.service_request(id, commit: commit)
337
+ raise AttemptErrors::NotFound, "service purpose not found" unless record
338
+ unless record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] && record["project_id"] == map["project_id"]
339
+ raise AttemptErrors::UnauthorizedIdentity, "service attempt differs"
340
+ end
341
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, purpose_id: id, record: record)
342
+ raise AttemptErrors::EvidenceUnavailable, "service completion evidence unavailable" unless record["receipt"]
343
+ ctx = ServiceEvidence.new(journal: journal).context(record, no_effect: record["state"] == "failed-settled")
344
+ [ctx, record.fetch("receipt").fetch("evidence").map { |ref| {"path" => ref.fetch("ref"), "sha256" => ref.fetch("sha256")} }]
345
+ else
346
+ purpose_peer!(events, params, map, origin, peer, role, kind: kind, purpose_id: id)
347
+ raise AttemptErrors::EvidenceUnavailable, "canonical signer or observer provenance unavailable"
348
+ end
349
+ reference = references.find { |ref| ref["path"] == "evidence/imports/#{params.fetch('artifact_id')}" }
350
+ raise AttemptErrors::NotFound, "artifact not found in exact purpose" unless reference
351
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
352
+ bytes = canonical.read({"ref" => reference.fetch("path"), "sha256" => reference.fetch("sha256")}, **context, commit: commit)
353
+ descriptor = events.find { |event| event["type"] == "evidence_import" && event.dig("payload", "artifact_id") == params["artifact_id"] }.fetch("payload")
354
+ {data: {"descriptor" => descriptor, "generation" => journal.authority_generation(events), "journal_commit" => commit},
355
+ replayed: false, transfer_parts: [bytes]}
356
+ rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
357
+ raise AttemptErrors::EvidenceUnavailable, "canonical evidence purpose is unverifiable"
358
+ end
359
+
360
+ def verify_retained_review!(journal, events, params, map, current, accepted, context, commit)
361
+ assignments = events.filter_map { |event| event.dig("payload", "data") if event["type"] == "authority_mutation" &&
362
+ event.dig("payload", "operation") == "assign_review" && event.dig("payload", "data", "review_id") == accepted["review_id"] }
363
+ review = assignments.first
364
+ unless assignments.length == 1 && current && review["reviewer_uid"] != map.fetch("worker_uid") &&
365
+ accepted["head"] == current["head"] && accepted["reviewer_uid"] == review["reviewer_uid"] &&
366
+ accepted["review_binding"] == review.slice("review_id", "head", "candidate_generation", "reviewer_uid", "reviewer_process_binding") &&
367
+ accepted.dig("review_receipt", "review", "reviewer", "actor") == review["reviewer_actor"]
368
+ raise AttemptErrors::EvidenceUnavailable, "retained review binding differs"
369
+ end
370
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
371
+ 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)
373
+ unless receipt.operation == "review" && receipt.verdict == "succeeded" && receipt.digest == accepted.fetch("receipt_digest")
374
+ raise AttemptErrors::EvidenceUnavailable, "retained review receipt differs"
375
+ end
376
+ end
377
+ end
378
+ end
379
+ end
380
+ end