ace-assign 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +19 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +88 -9
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|