ace-assign 0.63.0 → 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 +51 -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/molecules/runtime_control_surface_runner.rb +4 -1
- 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 +119 -6
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class Endcap
|
|
7
|
+
SERVICE_IDENTITY = %w[request_id assignment_id attempt_id project_id operation input_digest target
|
|
8
|
+
authorization service_id caller_uid candidate_head candidate_generation executor_uid transport policy_digest
|
|
9
|
+
worker_process_binding launch_ticket reservation_generation mapping_id].freeze
|
|
10
|
+
|
|
11
|
+
# Source-owned terminal admission. Caller already owns lifecycle/journal
|
|
12
|
+
# exclusion; the immutable commit is the current CAS input, never a
|
|
13
|
+
# caller-selected alternate evidence ref.
|
|
14
|
+
def service_settlement_complete!(journal:, events:, params:, map:, commit:)
|
|
15
|
+
protected_journal!(journal)
|
|
16
|
+
references = events.select { |event| %w[service_claim service_transition].include?(event["type"]) }
|
|
17
|
+
.map { |event| event.fetch("payload").fetch("request_id") }.uniq.sort
|
|
18
|
+
records = journal.service_request_records(commit: commit).select do |record|
|
|
19
|
+
record["assignment_id"] == params.fetch("assignment_id") && record["attempt_id"] == params.fetch("attempt_id")
|
|
20
|
+
end
|
|
21
|
+
unless records.map { |record| record.fetch("request_id") }.uniq.sort == references
|
|
22
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service request set is incomplete"
|
|
23
|
+
end
|
|
24
|
+
records.each do |record|
|
|
25
|
+
unless record["project_id"] == map.fetch("project_id") && record["mapping_id"] == params.fetch("mapping_id") &&
|
|
26
|
+
%w[succeeded failed-settled].include?(record["state"])
|
|
27
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service request is not settled for this scope"
|
|
28
|
+
end
|
|
29
|
+
# This existing reader verifies the accepted record digest and all
|
|
30
|
+
# imported terminal/no-effect evidence against this exact commit.
|
|
31
|
+
journal.service_request(record.fetch("request_id"), commit: commit)
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
|
|
35
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service settlement is unverifiable"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Called only by the installed journal composition, including lower
|
|
39
|
+
# service writers. It never reacquires lifecycle/authority locks.
|
|
40
|
+
def authorize_service_update!(journal:, existing:, replacement:, pending:)
|
|
41
|
+
protected_journal!(journal)
|
|
42
|
+
initial = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
|
|
43
|
+
begin_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
|
|
44
|
+
if existing && existing["dispatch_phase"] != replacement["dispatch_phase"] && !begin_dispatch
|
|
45
|
+
raise AttemptErrors::InvalidState, "invalid protected dispatch phase change"
|
|
46
|
+
end
|
|
47
|
+
return true unless initial || begin_dispatch
|
|
48
|
+
unless replacement["state"] == "uncertain" && (initial ? replacement["dispatch_phase"] == "issued" : true)
|
|
49
|
+
raise AttemptErrors::InvalidState, "fresh protected effect requires an uncertain dispatch ticket"
|
|
50
|
+
end
|
|
51
|
+
bytes = pending && pending.fetch(:service_inputs, {})[replacement.fetch("request_id")]
|
|
52
|
+
raise AttemptErrors::UnauthorizedIdentity, "fresh service effect requires its exact original input" unless bytes.is_a?(String)
|
|
53
|
+
map = @deployment.mapping(replacement.fetch("mapping_id"))
|
|
54
|
+
unless replacement["project_id"] == map["project_id"] && replacement["caller_uid"] == map["worker_uid"]
|
|
55
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service mapping differs"
|
|
56
|
+
end
|
|
57
|
+
params = replacement.slice("mapping_id", "assignment_id", "attempt_id", "candidate_generation").merge("head" => replacement.fetch("candidate_head"))
|
|
58
|
+
events = pending.fetch(:current_events)
|
|
59
|
+
@launch.scope_open_for_effect!(events: events, params: params, map: map)
|
|
60
|
+
origin = active_origin(events, params)
|
|
61
|
+
unless replacement["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
62
|
+
replacement["launch_ticket"] == origin.fetch("launch_ticket") && replacement["reservation_generation"] == origin.fetch("reservation_generation")
|
|
63
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service origin differs"
|
|
64
|
+
end
|
|
65
|
+
receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
|
|
66
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
|
|
67
|
+
current = exact_candidate!(candidate(events), params)
|
|
68
|
+
approved_review!(journal, events, params, map, current)
|
|
69
|
+
service_policy!.prepare!(replacement, input_bytes: bytes)
|
|
70
|
+
true
|
|
71
|
+
rescue KeyError
|
|
72
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service admission context is incomplete"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def authorize_service_transfer!(request, params, map, peer, role)
|
|
78
|
+
service_policy!
|
|
79
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
|
|
80
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
81
|
+
protected_journal!(journal)
|
|
82
|
+
if request.fetch("operation") == "request_service"
|
|
83
|
+
existing = journal.service_request(params.fetch("request_id"))
|
|
84
|
+
if existing
|
|
85
|
+
service_executor!(peer, role, existing)
|
|
86
|
+
service_replay_binding!(existing, params, map)
|
|
87
|
+
else
|
|
88
|
+
events = attempt_events(journal, params)
|
|
89
|
+
origin = active_origin(events, params)
|
|
90
|
+
worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
91
|
+
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
95
|
+
raise AttemptErrors::NotFound, "service request is unavailable" unless record
|
|
96
|
+
service_executor!(peer, role, record)
|
|
97
|
+
service_ticket!(record, params, map, peer)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def service_status(_request, params, map, peer, role)
|
|
104
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
105
|
+
protected_journal!(journal)
|
|
106
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
107
|
+
unless record && record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
|
|
108
|
+
record["project_id"] == map["project_id"] && record["candidate_head"] == params["head"] &&
|
|
109
|
+
record["candidate_generation"] == params["candidate_generation"]
|
|
110
|
+
raise AttemptErrors::UnauthorizedIdentity, "service status binding differs"
|
|
111
|
+
end
|
|
112
|
+
events = attempt_events(journal, params)
|
|
113
|
+
@kernel.live!(peer)
|
|
114
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
|
|
115
|
+
if role == :executor
|
|
116
|
+
service_executor!(peer, role, record)
|
|
117
|
+
else
|
|
118
|
+
authorized = case role
|
|
119
|
+
when :worker
|
|
120
|
+
peer["uid"] == record["caller_uid"] && @kernel.descendant?(peer, record.fetch("worker_process_binding"))
|
|
121
|
+
when :reviewer
|
|
122
|
+
review = assigned_review(events)
|
|
123
|
+
review && review["reviewer_uid"] == peer["uid"] && review["head"] == params["head"] &&
|
|
124
|
+
review["candidate_generation"] == params["candidate_generation"] && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
|
|
125
|
+
when :supervisor
|
|
126
|
+
@deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
|
|
127
|
+
else
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
raise AttemptErrors::UnauthorizedIdentity, "service status purpose differs" unless authorized
|
|
131
|
+
end
|
|
132
|
+
{data: service_projection(record).merge("generation" => journal.authority_generation(events),
|
|
133
|
+
"journal_commit" => journal.ref_value), replayed: false}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def service_authorization(request, params, map, peer, role, transfer)
|
|
138
|
+
authorize_service_transfer!(request, params, map, peer, role)
|
|
139
|
+
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
140
|
+
bytes = transfer.bytes.dup.freeze
|
|
141
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
142
|
+
protected_journal!(journal)
|
|
143
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
144
|
+
service_executor!(peer, role, record)
|
|
145
|
+
service_ticket!(record, params, map, peer)
|
|
146
|
+
unless record.fetch("input_digest") == params.fetch("input_digest")
|
|
147
|
+
raise AttemptErrors::Conflict, "authorization input binding differs"
|
|
148
|
+
end
|
|
149
|
+
events = attempt_events(journal, params)
|
|
150
|
+
@launch.scope_open_for_effect!(events: events, params: params, map: map)
|
|
151
|
+
origin = active_origin(events, params)
|
|
152
|
+
unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
153
|
+
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
154
|
+
raise AttemptErrors::UnauthorizedIdentity, "authorization launch origin differs"
|
|
155
|
+
end
|
|
156
|
+
current = exact_candidate!(candidate(events), params)
|
|
157
|
+
unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
|
|
158
|
+
raise AttemptErrors::Conflict, "authorization candidate changed"
|
|
159
|
+
end
|
|
160
|
+
service_receiver!(peer, role, map, record.fetch("service_id"))
|
|
161
|
+
approved_review!(journal, events, params, map, current)
|
|
162
|
+
prepared = service_policy!.prepare!(record, input_bytes: bytes)
|
|
163
|
+
data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
|
|
164
|
+
"policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
|
|
165
|
+
{data: data, replayed: false}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def dispatch_service(request, params, map, peer, role, transfer)
|
|
170
|
+
return service_authorization(request, params, map, peer, role, transfer) if request.fetch("operation") == "service_authorization"
|
|
171
|
+
authorize_service_transfer!(request, params, map, peer, role)
|
|
172
|
+
admitted = if request.fetch("operation") == "complete_service"
|
|
173
|
+
ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"), artifact_field: "evidence", reference_key: "ref")
|
|
174
|
+
else
|
|
175
|
+
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
176
|
+
transfer.bytes.dup.freeze
|
|
177
|
+
end
|
|
178
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
179
|
+
protected_journal!(journal)
|
|
180
|
+
unless request.fetch("operation") == "complete_service"
|
|
181
|
+
input_record = request.fetch("operation") == "request_service" ? params : journal.service_request(params.fetch("request_id"))
|
|
182
|
+
# Replay bypasses the mutation callback, never input validation.
|
|
183
|
+
# Only body binding is checked here; current effect policy stays
|
|
184
|
+
# inside each fresh CAS callback.
|
|
185
|
+
service_policy!.input_binding(admitted, expected_digest: input_record.fetch("input_digest"),
|
|
186
|
+
expected_target: input_record.fetch("target"))
|
|
187
|
+
end
|
|
188
|
+
# Admission also runs outside mutate: an exact mutation replay
|
|
189
|
+
# skips its callback and cannot bypass the authenticated ticket.
|
|
190
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
|
|
191
|
+
retained = request.fetch("operation") == "request_service" && journal.service_request(params.fetch("request_id"))
|
|
192
|
+
if retained
|
|
193
|
+
service_executor!(peer, role, retained)
|
|
194
|
+
service_replay_binding!(retained, params, map)
|
|
195
|
+
end
|
|
196
|
+
if !retained && request.fetch("operation") != "request_service"
|
|
197
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
198
|
+
service_executor!(peer, role, record)
|
|
199
|
+
service_ticket!(record, params, map, peer)
|
|
200
|
+
completion_binding!(record, params, admitted) if request.fetch("operation") == "complete_service"
|
|
201
|
+
elsif !retained
|
|
202
|
+
origin = active_origin(attempt_events(journal, params), params)
|
|
203
|
+
worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
204
|
+
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
205
|
+
end
|
|
206
|
+
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
207
|
+
mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
|
|
208
|
+
parameters_digest: Atoms::EvidenceDigest.digest(params),
|
|
209
|
+
expected_generation: request.fetch("operation") == "complete_service" ? nil : params.fetch("expected_generation"),
|
|
210
|
+
generation_mode: request.fetch("operation") == "complete_service" ? :recorded_completion : :expected, with_replay: true) do |events, _commit, _generation|
|
|
211
|
+
if request.fetch("operation") == "request_service"
|
|
212
|
+
existing = journal.service_request(params.fetch("request_id"))
|
|
213
|
+
if existing
|
|
214
|
+
service_executor!(peer, role, existing)
|
|
215
|
+
service_replay_binding!(existing, params, map)
|
|
216
|
+
{data: service_projection(existing).merge("claim" => "retained")}
|
|
217
|
+
else
|
|
218
|
+
service_claim_plan(journal, events, params, map, peer, role, admitted)
|
|
219
|
+
end
|
|
220
|
+
elsif request.fetch("operation") == "begin_dispatch"
|
|
221
|
+
service_begin_plan(journal, events, params, map, peer, role, admitted)
|
|
222
|
+
else
|
|
223
|
+
service_completion_plan(journal, events, params, map, peer, role, admitted)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
if request.fetch("operation") == "begin_dispatch" && result.fetch(:replayed)
|
|
227
|
+
result = result.merge(data: result.fetch(:data).merge("invocation" => "already_started"))
|
|
228
|
+
end
|
|
229
|
+
if request.fetch("operation") == "request_service"
|
|
230
|
+
# Acceptance metadata belongs to this exact mutation, while
|
|
231
|
+
# retained service state may have advanced since acceptance.
|
|
232
|
+
current = journal.service_request(params.fetch("request_id"))
|
|
233
|
+
data = service_projection(current).merge(result.fetch(:data).slice("generation", "journal_commit"))
|
|
234
|
+
data["claim"] = retained || result.fetch(:replayed) || result.dig(:data, "claim") == "retained" ? "retained" : "created"
|
|
235
|
+
result = result.merge(data: data)
|
|
236
|
+
end
|
|
237
|
+
result
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def service_policy!
|
|
242
|
+
@service_policy || raise(ArgumentError, "full-service authority requires its installed policy owner")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def service_executor!(peer, role, record)
|
|
246
|
+
unless record && role == :executor && peer["uid"] == record.fetch("executor_uid")
|
|
247
|
+
raise AttemptErrors::UnauthorizedIdentity, "completion requires the recorded executor"
|
|
248
|
+
end
|
|
249
|
+
@kernel.live!(peer)
|
|
250
|
+
true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def service_receiver!(peer, role, map, service_id)
|
|
254
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
255
|
+
receiver = project.fetch("service_receivers").fetch(service_id)
|
|
256
|
+
unless role == :executor && peer["uid"] == receiver.fetch("executor_uid") &&
|
|
257
|
+
project.fetch("service_executor_uids").include?(peer["uid"]) &&
|
|
258
|
+
!project.fetch("worker_uids").include?(peer["uid"])
|
|
259
|
+
raise AttemptErrors::UnauthorizedIdentity, "service requires its fixed independent receiver"
|
|
260
|
+
end
|
|
261
|
+
@kernel.live!(peer)
|
|
262
|
+
receiver
|
|
263
|
+
rescue KeyError
|
|
264
|
+
raise AttemptErrors::UnauthorizedIdentity, "fixed service receiver is unavailable"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def service_claim_plan(journal, events, params, map, peer, role, input_bytes)
|
|
268
|
+
origin = active_origin(events, params)
|
|
269
|
+
worker = params.fetch("worker_process_binding")
|
|
270
|
+
worker_or_launcher!(worker, :worker, map, origin)
|
|
271
|
+
current = exact_candidate!(candidate(events), params)
|
|
272
|
+
approved_review!(journal, events, params, map, current)
|
|
273
|
+
receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
274
|
+
binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
|
|
275
|
+
"authorization", "service_id").merge("project_id" => map.fetch("project_id"),
|
|
276
|
+
"caller_uid" => map.fetch("worker_uid"), "mapping_id" => params.fetch("mapping_id"), "candidate_head" => current.fetch("head"),
|
|
277
|
+
"candidate_generation" => current.fetch("candidate_generation"),
|
|
278
|
+
"worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
|
|
279
|
+
"launch_ticket" => origin.fetch("launch_ticket"), "reservation_generation" => origin.fetch("reservation_generation"))
|
|
280
|
+
prepared = service_policy!.prepare!(binding, input_bytes: input_bytes)
|
|
281
|
+
binding = prepared.fetch(:binding).merge("policy_digest" => prepared.fetch(:policy_digest))
|
|
282
|
+
unless binding["executor_uid"] == receiver.fetch("executor_uid")
|
|
283
|
+
raise AttemptErrors::UnauthorizedIdentity, "policy and fixed receiver disagree"
|
|
284
|
+
end
|
|
285
|
+
existing = journal.service_request(binding.fetch("request_id"))
|
|
286
|
+
if existing
|
|
287
|
+
unless existing.slice(*SERVICE_IDENTITY) == binding.slice(*SERVICE_IDENTITY)
|
|
288
|
+
raise AttemptErrors::Conflict, "service request immutable identity differs"
|
|
289
|
+
end
|
|
290
|
+
return {data: service_projection(existing).merge("claim" => "retained")}
|
|
291
|
+
end
|
|
292
|
+
ticket = SecureRandom.hex(16)
|
|
293
|
+
record = binding.merge("dispatch_ticket_id" => ticket,
|
|
294
|
+
"claim_binding" => Atoms::EvidenceDigest.digest(binding.merge("dispatch_ticket_id" => ticket)),
|
|
295
|
+
"claim_generation" => 1, "dispatch_phase" => "issued", "state" => "uncertain",
|
|
296
|
+
"claimed_at" => Time.now.utc.iso8601(9))
|
|
297
|
+
{data: service_projection(record).merge("claim" => "created"), service_inputs: {record.fetch("request_id") => input_bytes}, service_updates: [{request_id: record.fetch("request_id"),
|
|
298
|
+
expected: nil, replacement: record, event_type: "service_claim"}]}
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def service_begin_plan(journal, events, params, map, peer, role, input_bytes)
|
|
302
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
303
|
+
raise AttemptErrors::NotFound, "service request is unavailable" unless record
|
|
304
|
+
service_receiver!(peer, role, map, record.fetch("service_id"))
|
|
305
|
+
service_ticket!(record, params, map, peer)
|
|
306
|
+
# A repeated begin never grants a second invocation, including after
|
|
307
|
+
# lease expiry or terminality. It reports only retained ticket truth.
|
|
308
|
+
return {data: service_projection(record).merge("invocation" => "already_started")} unless record["dispatch_phase"] == "issued"
|
|
309
|
+
origin = active_origin(events, params)
|
|
310
|
+
unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
311
|
+
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
312
|
+
raise AttemptErrors::UnauthorizedIdentity, "dispatch launch origin differs"
|
|
313
|
+
end
|
|
314
|
+
current = exact_candidate!(candidate(events), params)
|
|
315
|
+
unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
|
|
316
|
+
raise AttemptErrors::Conflict, "dispatch candidate changed"
|
|
317
|
+
end
|
|
318
|
+
approved_review!(journal, events, params, map, current)
|
|
319
|
+
service_policy!.prepare!(record, input_bytes: input_bytes)
|
|
320
|
+
replacement = record.merge("dispatch_phase" => "dispatch_started")
|
|
321
|
+
{data: service_projection(replacement).merge("invocation" => "permitted"),
|
|
322
|
+
service_inputs: {record.fetch("request_id") => input_bytes},
|
|
323
|
+
service_updates: [{request_id: record.fetch("request_id"), expected: record,
|
|
324
|
+
replacement: replacement, event_type: "service_transition"}]}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def service_ticket!(record, params, map, peer)
|
|
328
|
+
unless record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
|
|
329
|
+
record["project_id"] == map["project_id"] && record["executor_uid"] == peer["uid"] &&
|
|
330
|
+
record["claim_binding"] == params["claim_binding"]
|
|
331
|
+
raise AttemptErrors::UnauthorizedIdentity, "dispatch ticket binding differs"
|
|
332
|
+
end
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def service_replay_binding!(record, params, map)
|
|
337
|
+
fields = %w[request_id assignment_id attempt_id operation input_digest target authorization service_id mapping_id]
|
|
338
|
+
unless fields.all? { |field| record[field] == params[field] } && record["project_id"] == map["project_id"] &&
|
|
339
|
+
record["caller_uid"] == map["worker_uid"] && params.dig("worker_process_binding", "uid") == map["worker_uid"]
|
|
340
|
+
raise AttemptErrors::Conflict, "service replay immutable identity differs"
|
|
341
|
+
end
|
|
342
|
+
@kernel.live!(params.fetch("worker_process_binding"))
|
|
343
|
+
unless @kernel.descendant?(params.fetch("worker_process_binding"), record.fetch("worker_process_binding"))
|
|
344
|
+
raise AttemptErrors::UnauthorizedIdentity, "service replay requires recorded native worker ownership"
|
|
345
|
+
end
|
|
346
|
+
true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def service_completion_plan(journal, events, params, map, peer, role, admitted)
|
|
350
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
351
|
+
service_executor!(peer, role, record)
|
|
352
|
+
service_ticket!(record, params, map, peer)
|
|
353
|
+
digest = completion_binding!(record, params, admitted)
|
|
354
|
+
if record["completion_digest"]
|
|
355
|
+
return {data: service_projection(record)}
|
|
356
|
+
end
|
|
357
|
+
receipt = admitted.fetch(:receipt)
|
|
358
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
359
|
+
canonical = Molecules::CanonicalEvidence.new(journal: journal)
|
|
360
|
+
plan = canonical.import_plan(**owner.context(record), artifacts: admitted.fetch(:artifacts),
|
|
361
|
+
admitted_after_event_digest: events.last.fetch("digest"))
|
|
362
|
+
normalized = JSON.parse(JSON.generate(receipt)).merge("evidence" => plan.fetch(:references))
|
|
363
|
+
replacement = record.merge("state" => receipt.fetch("outcome"), "receipt" => normalized, "completion_digest" => digest)
|
|
364
|
+
replacement["failed_at"] = Time.now.utc.iso8601(9) if receipt["outcome"] == "failed"
|
|
365
|
+
plan.merge(data: service_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
|
|
366
|
+
expected: record, replacement: replacement, event_type: "service_transition"}])
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def completion_binding!(record, params, admitted)
|
|
370
|
+
unless record["candidate_head"] == params["head"] && record["candidate_generation"] == params["candidate_generation"]
|
|
371
|
+
raise AttemptErrors::Conflict, "completion candidate binding differs"
|
|
372
|
+
end
|
|
373
|
+
digest = Atoms::EvidenceDigest.digest("receipt_sha256" => admitted.fetch(:receipt_sha256),
|
|
374
|
+
"artifacts" => admitted.fetch(:artifacts).map { |bytes| Digest::SHA256.hexdigest(bytes) })
|
|
375
|
+
if record["completion_digest"] && record["completion_digest"] != digest
|
|
376
|
+
raise AttemptErrors::Conflict, "service completion content differs"
|
|
377
|
+
end
|
|
378
|
+
receipt = admitted.fetch(:receipt)
|
|
379
|
+
unless receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
|
|
380
|
+
Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.all? { |field| receipt[field] == record[field] } &&
|
|
381
|
+
%w[succeeded failed].include?(receipt["outcome"]) &&
|
|
382
|
+
(receipt["outcome"] != "succeeded" || record["dispatch_phase"] == "dispatch_started")
|
|
383
|
+
raise AttemptErrors::ReceiptRejected, "completion must attest the exact dispatched request"
|
|
384
|
+
end
|
|
385
|
+
digest
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def service_projection(record)
|
|
389
|
+
record.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation", "service_id", "target",
|
|
390
|
+
"candidate_head", "candidate_generation", "state", "dispatch_ticket_id", "claim_binding", "claim_generation", "dispatch_phase", "policy_digest")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "private_directory"
|
|
5
|
+
require_relative "../molecules/canonical_evidence"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Receiver-side collection from its own fixed private handler staging.
|
|
11
|
+
# These bytes are transport input, not accepted evidence. The authority
|
|
12
|
+
# must import them through CanonicalEvidence in its terminal mutation.
|
|
13
|
+
module EvidenceTransfer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def read_staged(root:, evidence:)
|
|
17
|
+
root = PrivateDirectory.verify!(root)
|
|
18
|
+
limits = Molecules::CanonicalEvidence
|
|
19
|
+
unless evidence.is_a?(Array) && evidence.length.between?(1, limits::MAX_ARTIFACTS)
|
|
20
|
+
reject!("Invalid staged evidence count")
|
|
21
|
+
end
|
|
22
|
+
total = 0
|
|
23
|
+
evidence.map do |reference|
|
|
24
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
|
|
25
|
+
reference["ref"].is_a?(String) && reference["ref"].match?(%r{\A[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*\z}) &&
|
|
26
|
+
reference["ref"].bytesize <= 256 &&
|
|
27
|
+
reference["ref"].split("/").none? { |part| %w[. ..].include?(part) } &&
|
|
28
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
29
|
+
reject!("Invalid staged evidence binding")
|
|
30
|
+
end
|
|
31
|
+
path = root
|
|
32
|
+
parts = reference.fetch("ref").split("/")
|
|
33
|
+
parts[0...-1].each do |part|
|
|
34
|
+
path = File.join(path, part)
|
|
35
|
+
stat = File.lstat(path)
|
|
36
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.uid && (stat.mode & 0022).zero?
|
|
37
|
+
reject!("Staged evidence ancestry is not private")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
path = File.join(path, parts.last)
|
|
41
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
42
|
+
stat = file.stat
|
|
43
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 &&
|
|
44
|
+
(stat.mode & 0022).zero? && stat.size <= limits::MAX_ARTIFACT_BYTES
|
|
45
|
+
reject!("Staged evidence is not a bounded private regular file")
|
|
46
|
+
end
|
|
47
|
+
bytes = (file.read(limits::MAX_ARTIFACT_BYTES + 1) || "").b
|
|
48
|
+
total += bytes.bytesize
|
|
49
|
+
unless bytes.bytesize <= limits::MAX_ARTIFACT_BYTES && total <= limits::MAX_TOTAL_BYTES &&
|
|
50
|
+
Digest::SHA256.hexdigest(bytes) == reference["sha256"]
|
|
51
|
+
reject!("Staged evidence bytes differ or exceed limits")
|
|
52
|
+
end
|
|
53
|
+
bytes
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
reject!("Staged evidence is unavailable or substituted")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reject!(message)
|
|
61
|
+
raise AttemptErrors::ReceiptRejected, message
|
|
62
|
+
end
|
|
63
|
+
private_class_method :reject!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|