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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. 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