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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- 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
|