ace-assign 0.70.0 → 0.71.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  4. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  5. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  6. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  7. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  8. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  9. data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
  10. data/lib/ace/assign/authority/command_execution.rb +180 -0
  11. data/lib/ace/assign/authority/endcap.rb +4 -2
  12. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  13. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  14. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  15. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  16. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  17. data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
  18. data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
  19. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  20. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  21. data/lib/ace/assign/authority/ordinary_results.rb +11 -1
  22. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  23. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  24. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  25. data/lib/ace/assign/authority/prepared_work.rb +7 -2
  26. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  27. data/lib/ace/assign/authority/retained_input.rb +53 -0
  28. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  29. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  30. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  31. data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
  32. data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
  33. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
  36. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +19 -4
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Assign
5
5
  module Authority
6
- class Endcap
6
+ module FixedServiceLifecycle
7
7
  SERVICE_IDENTITY = %w[request_id assignment_id attempt_id project_id operation input_digest target
8
8
  authorization service_id caller_uid candidate_head candidate_generation executor_uid transport policy_digest
9
9
  worker_process_binding launch_ticket reservation_generation mapping_id].freeze
@@ -17,7 +17,7 @@ module Ace
17
17
  end
18
18
 
19
19
  def service_settlement_evidence!(journal:, events:, params:, map:, commit:)
20
- protected_journal!(journal)
20
+ service_journal!(journal)
21
21
  journal.verify_commit!(commit)
22
22
  retained = journal.read_events(params.fetch("assignment_id"), commit: commit)
23
23
  .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
@@ -80,7 +80,7 @@ module Ace
80
80
  # Called only by the installed journal composition, including lower
81
81
  # service writers. It never reacquires lifecycle/authority locks.
82
82
  def authorize_service_update!(journal:, existing:, replacement:, pending:)
83
- protected_journal!(journal)
83
+ service_journal!(journal)
84
84
  if existing && existing.key?("executor_process_binding") &&
85
85
  Atoms::EvidenceDigest.digest(existing.fetch("executor_process_binding")) != Atoms::EvidenceDigest.digest(replacement["executor_process_binding"])
86
86
  raise AttemptErrors::InvalidState, "original cleanup executor cannot change"
@@ -114,28 +114,27 @@ module Ace
114
114
  end
115
115
  bytes = pending && pending.fetch(:service_inputs, {})[replacement.fetch("request_id")]
116
116
  raise AttemptErrors::UnauthorizedIdentity, "fresh service effect requires its exact original input" unless bytes.is_a?(String)
117
- map = @deployment.mapping(replacement.fetch("mapping_id"))
117
+ map = service_mapping!(replacement.fetch("mapping_id"))
118
118
  unless replacement["project_id"] == map["project_id"] && replacement["caller_uid"] == map["worker_uid"]
119
119
  raise AttemptErrors::UnauthorizedIdentity, "protected service mapping differs"
120
120
  end
121
121
  params = replacement.slice("mapping_id", "assignment_id", "attempt_id", "candidate_generation").merge("head" => replacement.fetch("candidate_head"))
122
122
  events = pending.fetch(:current_events)
123
- @launch.scope_open_for_effect!(events: events, params: params, map: map)
124
- origin = active_origin(events, params)
125
- unless replacement["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
126
- replacement["launch_ticket"] == origin.fetch("launch_ticket") && replacement["reservation_generation"] == origin.fetch("reservation_generation")
123
+ service_scope_open!(events: events, params: params, map: map)
124
+ origin = service_origin!(events, params)
125
+ unless service_origin_matches!(replacement, origin)
127
126
  raise AttemptErrors::UnauthorizedIdentity, "protected service origin differs"
128
127
  end
129
- receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
128
+ receiver = service_project!(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
130
129
  raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
131
130
  current = exact_candidate!(candidate(events), params)
132
- approved_review!(journal, events, params, map, current, service_operation: replacement.fetch("operation")) unless %w[create update].include?(replacement.fetch("operation"))
131
+ service_review!(journal, events, params, map, current, service_operation: replacement.fetch("operation")) unless %w[create update].include?(replacement.fetch("operation"))
133
132
  service_policy!.prepare!(replacement, input_bytes: bytes)
134
133
  if begin_dispatch && %w[prune-preserved-workspace publish].include?(replacement.fetch("operation"))
135
134
  executor = replacement.fetch("executor_process_binding")
136
135
  boot = replacement.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
137
136
  Molecules::ExecutionScopeLineage.validate_process_identity!(executor, boot_id: boot)
138
- credentials = @deployment.project(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
137
+ credentials = service_project!(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
139
138
  unless executor.slice("uid", "gid", "groups") == credentials.slice("gid", "groups").merge("uid" => receiver.fetch("executor_uid"))
140
139
  raise AttemptErrors::UnauthorizedIdentity, "original cleanup executor credentials differ"
141
140
  end
@@ -164,8 +163,8 @@ module Ace
164
163
  def authorize_service_transfer!(request, params, map, peer, role)
165
164
  service_policy!
166
165
  service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
167
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
168
- protected_journal!(journal)
166
+ with_service_scope(params: params, map: map) do |journal, _registration|
167
+ service_journal!(journal)
169
168
  if request.fetch("operation") == "request_service"
170
169
  existing = journal.service_request(params.fetch("request_id"))
171
170
  if existing
@@ -173,8 +172,8 @@ module Ace
173
172
  service_replay_binding!(existing, params, map)
174
173
  else
175
174
  events = attempt_events(journal, params)
176
- origin = active_origin(events, params)
177
- worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
175
+ origin = service_origin!(events, params)
176
+ service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
178
177
  service_receiver!(peer, role, map, params.fetch("service_id"))
179
178
  end
180
179
  else
@@ -191,7 +190,7 @@ module Ace
191
190
  # This immutable observation grants no service claim or dispatch permit.
192
191
  def workspace_prune_preview_context(_request, params, map, peer, role, transfer)
193
192
  raise ArgumentError, "preview context forbids upload" unless transfer.nil?
194
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
193
+ with_service_scope(params: params, map: map) do |journal, _registration|
195
194
  {data: workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: role), replayed: false}
196
195
  end
197
196
  end
@@ -203,11 +202,11 @@ module Ace
203
202
 
204
203
  def workspace_prune_preview_context_at!(journal:, params:, map:, peer:, role:)
205
204
  service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"))
206
- protected_journal!(journal)
205
+ service_journal!(journal)
207
206
  commit = journal.ref_value
208
- events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: map)
209
- origin = active_origin(events, params)
210
- worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
207
+ events = service_preview_events!(journal: journal, commit: commit, params: params, map: map)
208
+ origin = service_origin!(events, params)
209
+ service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
211
210
  receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
212
211
  service_policy!.workspace_prune_receiver!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"),
213
212
  service_id: params.fetch("service_id"), executor_uid: receiver.fetch("executor_uid"))
@@ -220,7 +219,7 @@ module Ace
220
219
  "journal_commit" => commit,
221
220
  "maintenance" => params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id")),
222
221
  "head" => selected.fetch("head"), "candidate_generation" => selected.fetch("candidate_generation"),
223
- "worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
222
+ "worker_process_binding" => service_origin_fields!(origin).fetch("worker_process_binding"),
224
223
  "caller_process_binding" => params.fetch("worker_process_binding")
225
224
  }
226
225
  raise AttemptErrors::Conflict, "preview canonical selection changed" unless journal.ref_value == commit
@@ -232,8 +231,8 @@ module Ace
232
231
  private
233
232
 
234
233
  def service_status(_request, params, map, peer, role)
235
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
236
- protected_journal!(journal)
234
+ with_service_scope(params: params, map: map) do |journal, _registration|
235
+ service_journal!(journal)
237
236
  commit = journal.ref_value
238
237
  read = role == :executor && journal.service_settlement_read(params.fetch("request_id"), commit: commit)
239
238
  record = read ? read.fetch(:record) : journal.service_request(params.fetch("request_id"), commit: commit)
@@ -261,7 +260,7 @@ module Ace
261
260
  review && review["reviewer_uid"] == peer["uid"] && review["head"] == params["head"] &&
262
261
  review["candidate_generation"] == params["candidate_generation"] && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
263
262
  when :supervisor
264
- @deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
263
+ service_project!(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
265
264
  else
266
265
  false
267
266
  end
@@ -287,8 +286,8 @@ module Ace
287
286
  authorize_service_transfer!(request, params, map, peer, role)
288
287
  raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
289
288
  bytes = transfer.bytes.dup.freeze
290
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
291
- protected_journal!(journal)
289
+ with_service_scope(params: params, map: map) do |journal, _registration|
290
+ service_journal!(journal)
292
291
  record = journal.service_request(params.fetch("request_id"))
293
292
  service_executor!(peer, role, record)
294
293
  service_ticket!(record, params, map, peer)
@@ -296,10 +295,9 @@ module Ace
296
295
  raise AttemptErrors::Conflict, "authorization input binding differs"
297
296
  end
298
297
  events = attempt_events(journal, params)
299
- @launch.scope_open_for_effect!(events: events, params: params, map: map)
300
- origin = active_origin(events, params)
301
- unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
302
- record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
298
+ service_scope_open!(events: events, params: params, map: map)
299
+ origin = service_origin!(events, params)
300
+ unless service_origin_matches!(record, origin)
303
301
  raise AttemptErrors::UnauthorizedIdentity, "authorization launch origin differs"
304
302
  end
305
303
  current = exact_candidate!(candidate(events), params)
@@ -307,7 +305,7 @@ module Ace
307
305
  raise AttemptErrors::Conflict, "authorization candidate changed"
308
306
  end
309
307
  service_receiver!(peer, role, map, record.fetch("service_id"))
310
- approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
308
+ service_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
311
309
  prepared = service_policy!.prepare!(record, input_bytes: bytes)
312
310
  data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
313
311
  "policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
@@ -324,8 +322,8 @@ module Ace
324
322
  raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
325
323
  transfer.bytes.dup.freeze
326
324
  end
327
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
328
- protected_journal!(journal)
325
+ with_service_scope(params: params, map: map) do |journal, _registration|
326
+ service_journal!(journal)
329
327
  unless request.fetch("operation") == "complete_service"
330
328
  input_record = request.fetch("operation") == "request_service" ? params : journal.service_request(params.fetch("request_id"))
331
329
  # Replay bypasses the mutation callback, never input validation.
@@ -348,8 +346,8 @@ module Ace
348
346
  service_ticket!(record, params, map, peer)
349
347
  completion_binding!(record, params, admitted) if request.fetch("operation") == "complete_service"
350
348
  elsif !retained
351
- origin = active_origin(attempt_events(journal, params), params)
352
- worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
349
+ origin = service_origin!(attempt_events(journal, params), params)
350
+ service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
353
351
  service_receiver!(peer, role, map, params.fetch("service_id"))
354
352
  end
355
353
  result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
@@ -433,7 +431,7 @@ module Ace
433
431
  end
434
432
 
435
433
  def service_receiver!(peer, role, map, service_id)
436
- project = @deployment.project(map.fetch("project_id"))
434
+ project = service_project!(map.fetch("project_id"))
437
435
  receiver = project.fetch("service_receivers").fetch(service_id)
438
436
  unless role == :executor && peer["uid"] == receiver.fetch("executor_uid") &&
439
437
  project.fetch("service_executor_uids").include?(peer["uid"]) &&
@@ -447,18 +445,17 @@ module Ace
447
445
  end
448
446
 
449
447
  def service_claim_plan(journal, events, params, map, peer, role, input_bytes)
450
- origin = active_origin(events, params)
448
+ origin = service_origin!(events, params)
451
449
  worker = params.fetch("worker_process_binding")
452
- worker_or_launcher!(worker, :worker, map, origin)
450
+ service_worker!(worker, :worker, map, origin)
453
451
  current = exact_candidate!(candidate(events), params)
454
- approved_review!(journal, events, params, map, current, service_operation: params.fetch("operation")) unless %w[create update].include?(params.fetch("operation"))
452
+ service_review!(journal, events, params, map, current, service_operation: params.fetch("operation")) unless %w[create update].include?(params.fetch("operation"))
455
453
  receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
456
454
  binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
457
455
  "authorization", "service_id").merge("project_id" => map.fetch("project_id"),
458
456
  "caller_uid" => map.fetch("worker_uid"), "mapping_id" => params.fetch("mapping_id"), "candidate_head" => current.fetch("head"),
459
- "candidate_generation" => current.fetch("candidate_generation"),
460
- "worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
461
- "launch_ticket" => origin.fetch("launch_ticket"), "reservation_generation" => origin.fetch("reservation_generation"))
457
+ "candidate_generation" => current.fetch("candidate_generation"))
458
+ .merge(service_origin_fields!(origin))
462
459
  prepared = service_policy!.prepare!(binding, input_bytes: input_bytes)
463
460
  binding = prepared.fetch(:binding).merge("policy_digest" => prepared.fetch(:policy_digest))
464
461
  unless binding["executor_uid"] == receiver.fetch("executor_uid")
@@ -488,16 +485,15 @@ module Ace
488
485
  # A repeated begin never grants a second invocation, including after
489
486
  # lease expiry or terminality. It reports only retained ticket truth.
490
487
  return {data: service_projection(record).merge("invocation" => "already_started")} unless record["dispatch_phase"] == "issued"
491
- origin = active_origin(events, params)
492
- unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
493
- record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
488
+ origin = service_origin!(events, params)
489
+ unless service_origin_matches!(record, origin)
494
490
  raise AttemptErrors::UnauthorizedIdentity, "dispatch launch origin differs"
495
491
  end
496
492
  current = exact_candidate!(candidate(events), params)
497
493
  unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
498
494
  raise AttemptErrors::Conflict, "dispatch candidate changed"
499
495
  end
500
- approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
496
+ service_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
501
497
  service_policy!.prepare!(record, input_bytes: input_bytes)
502
498
  replacement = record.merge("dispatch_phase" => "dispatch_started")
503
499
  if record.fetch("operation") == "prune-preserved-workspace"
@@ -28,6 +28,7 @@ module Ace
28
28
  end
29
29
  registered = registration(params.fetch("assignment_id"), commit: @journal.ref_value)
30
30
  raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
31
+ verify_result_owner!(request, peer, role, deadline, registered)
31
32
  digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
32
33
  @exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id"))], deadline: deadline) do
33
34
  @exclusion.with_exclusive(@exclusion.assignment_key(params.fetch("assignment_id")), deadline: deadline) do
@@ -36,8 +37,8 @@ module Ace
36
37
  @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
37
38
  mutation_id: request.fetch("mutation_id"), operation: "finish", parameters_digest: digest,
38
39
  expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
39
- replay_authorizer: -> { authorize!(request, peer, role, deadline) }) do |events, prefix, _generation|
40
- authorize!(request, peer, role, deadline)
40
+ replay_authorizer: -> { verify_result_owner!(request, peer, role, deadline, registered) }) do |events, prefix, _generation|
41
+ verify_result_owner!(request, peer, role, deadline, registered)
41
42
  unless Molecules::CanonicalAttemptState.derive(events) == "running"
42
43
  raise AttemptErrors::Conflict, "ordinary completion requires a running attempt"
43
44
  end
@@ -61,7 +62,7 @@ module Ace
61
62
  raise AttemptErrors::Conflict, "ordinary result scope has unfinished progress" unless complete
62
63
  settlement = OrdinaryEffectSettlement.new(journal: @journal).verify!(project: @project,
63
64
  assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), events: events, commit: prefix)
64
- authorize!(request, peer, role, deadline)
65
+ verify_result_owner!(request, peer, role, deadline, registered)
65
66
  plan = Organisms::AttemptCoordinator.finished_transition_plan(events: events, receipt: result.fetch("receipt"), authority: :ordinary)
66
67
  plan.merge(data: params.slice("assignment_id", "attempt_id", "result_id", "head", "receipt_digest").merge(
67
68
  "authority" => "ordinary", "scope" => scope, "state" => verdict, "settlement" => settlement, "inbox_deliveries" => delivery))
@@ -73,6 +74,15 @@ module Ace
73
74
  raise AttemptErrors::EvidenceUnavailable, "ordinary transport settlement is unavailable: #{error.message}"
74
75
  end
75
76
 
77
+ def verify_result_owner!(request, peer, role, deadline, registered)
78
+ authorize!(request, peer, role, deadline)
79
+ supervisor = @admission.identity!(peer: peer, pane: request.fetch("params").fetch("pane"), deadline: deadline)
80
+ unless supervisor.role == "coordinator" &&
81
+ supervisor.runtime_binding == supervisor_selection(registered, @journal.ref_value).fetch("binding")
82
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary completion belongs to its original supervisor"
83
+ end
84
+ end
85
+
76
86
  def with_result_transport(request, deadline)
77
87
  if @journal.mutation_result(request.fetch("mutation_id"), commit: @journal.ref_value)
78
88
  # A terminal reply is immutable. Current peer authority is still
@@ -8,6 +8,10 @@ require_relative "ordinary_attempt_start"
8
8
  require_relative "ordinary_progress"
9
9
  require_relative "ordinary_results"
10
10
  require_relative "ordinary_completion"
11
+ require_relative "ordinary_supervisor"
12
+ require_relative "ordinary_reviews"
13
+ require_relative "ordinary_merge_evidence"
14
+ require_relative "ordinary_release_artifacts"
11
15
  require_relative "../molecules/lifecycle_exclusion"
12
16
 
13
17
  module Ace
@@ -21,22 +25,37 @@ module Ace
21
25
  include OrdinaryProgress
22
26
  include OrdinaryResults
23
27
  include OrdinaryCompletion
24
- OPERATIONS = (%w[register_assignment registration_status start_attempt finish] + OrdinaryProgress::PROGRESS_OPERATIONS + OrdinaryResults::RESULT_OPERATIONS).freeze
28
+ include OrdinarySupervisor
29
+ include OrdinaryReviews
30
+ include OrdinaryMergeEvidence
31
+ include OrdinaryReleaseArtifacts
32
+ OPERATIONS = (%w[register_assignment registration_status start_attempt finish merge_evidence] + OrdinaryProgress::PROGRESS_OPERATIONS + OrdinaryResults::RESULT_OPERATIONS + OrdinarySupervisor::SUPERVISOR_OPERATIONS + OrdinaryReviews::REVIEW_OPERATIONS + OrdinaryReleaseArtifacts::RELEASE_OPERATIONS).freeze
25
33
  TRANSFER_OPERATIONS = {"register_assignment" => {direction: :upload, purpose: :candidate, roles: [:supervisor]}}
26
- .merge(OrdinaryProgress::PROGRESS_TRANSFERS).merge(OrdinaryResults::RESULT_TRANSFERS).freeze
34
+ .merge(OrdinaryProgress::PROGRESS_TRANSFERS).merge(OrdinaryResults::RESULT_TRANSFERS).merge(OrdinaryReviews::REVIEW_TRANSFERS).merge(OrdinaryReleaseArtifacts::RELEASE_TRANSFERS).freeze
27
35
  REGISTER_KEYS = %w[assignment_id definition_digest prepared_head prepared_tree manifest_sha256 expected_generation pane transfer].freeze
28
36
 
29
- def initialize(project:, admission:, journal:, exclusion:, input_root:, inbox: nil)
37
+ def initialize(project:, admission:, journal:, exclusion:, input_root:, inbox: nil, command_evidence: nil, release_authorizer: nil)
30
38
  @project, @admission, @journal, @exclusion = project, admission, journal, exclusion
31
39
  @input_root = PrivateDirectory.verify!(input_root)
32
40
  unless inbox.nil? || inbox.is_a?(Ace::Herdr::Organisms::Inbox)
33
41
  raise ArgumentError, "ordinary completion requires the installed Inbox owner"
34
42
  end
35
- @inbox = inbox
43
+ unless release_authorizer.nil? || release_authorizer.respond_to?(:call)
44
+ raise ArgumentError, "release artifact authorization requires its installed policy owner"
45
+ end
46
+ @inbox, @command_evidence, @release_authorizer = inbox, command_evidence, release_authorizer
36
47
  end
37
48
 
38
49
  def authorize_transfer!(request:, peer:, role:, deadline:)
39
50
  authorize!(request, peer, role, deadline)
51
+ if OrdinaryReleaseArtifacts::RELEASE_TRANSFERS.key?(request.fetch("operation"))
52
+ authorize_ordinary_release!(request, peer, role, deadline)
53
+ return
54
+ end
55
+ if OrdinaryReviews::REVIEW_TRANSFERS.key?(request.fetch("operation"))
56
+ authorize_ordinary_review!(request, peer, role, deadline)
57
+ return
58
+ end
40
59
  if OrdinaryResults::RESULT_TRANSFERS.key?(request.fetch("operation"))
41
60
  validate_result!(request)
42
61
  authorize_start_replay!(request, peer, role, deadline) if request.fetch("operation") == "submit_result"
@@ -54,7 +73,16 @@ module Ace
54
73
 
55
74
  def dispatch(request:, peer:, role:, deadline:, transfer: nil)
56
75
  authorize!(request, peer, role, deadline)
76
+ return ordinary_review_operation(request, peer, role, deadline, transfer) if OrdinaryReviews::REVIEW_OPERATIONS.include?(request.fetch("operation"))
57
77
  case request.fetch("operation")
78
+ when "retain_release_artifact"
79
+ retain_release_artifact(request, peer, role, deadline, transfer)
80
+ when "merge_evidence"
81
+ raise ArgumentError, "merge evidence read forbids transfer" if transfer
82
+ ordinary_merge_evidence(request, peer, role, deadline)
83
+ when "supervisor_status", "adopt_supervisor"
84
+ raise ArgumentError, "ordinary supervisor operation forbids transfer" if transfer
85
+ supervisor_operation(request, peer, role, deadline)
58
86
  when "registration_status"
59
87
  strict!(request.fetch("params"), %w[assignment_id])
60
88
  raise ArgumentError, "status mutation must be null" unless request.fetch("mutation_id").nil?
@@ -134,6 +162,7 @@ module Ace
134
162
  {events: [], blobs: {definition_path => definition, bundle_path => bytes, queue_path => queue.bytes}, data: {
135
163
  "authority" => "ordinary", "assignment_id" => assignment.id, "project_id" => @project,
136
164
  "task_id" => assignment.task_id, "registered_by" => principal, "phase" => "registered",
165
+ "supervisor_binding" => identity.runtime_binding,
137
166
  "definition_ref" => definition_path, "definition_digest" => params.fetch("definition_digest"),
138
167
  "queue_ref" => queue_path, "queue_sha256" => queue_sha,
139
168
  "prepared_work" => work.reference(head: params.fetch("prepared_head"), tree: params.fetch("prepared_tree")),
@@ -165,6 +194,13 @@ module Ace
165
194
  unless request.fetch("project_id") == @project && @admission.authorize!(request: request, peer: peer, deadline: deadline) == role
166
195
  raise AttemptErrors::UnauthorizedIdentity, "ordinary lifecycle principal differs"
167
196
  end
197
+ # Project commands share the canonical journal but never the managed
198
+ # assignment namespace. Reserve their prefix on every ordinary route,
199
+ # including transfer admission, status and registration replay.
200
+ assignment = request.fetch("params")["assignment_id"]
201
+ if assignment.is_a?(String) && assignment.start_with?("command-")
202
+ raise ArgumentError, "ordinary assignment uses the reserved project-command namespace"
203
+ end
168
204
  end
169
205
 
170
206
  def validate_registration!(request)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # The installed fixed merge consumes this same lifecycle, never a local
7
+ # receipt file. Locks precede journal CAS; no callback acquires an inverted
8
+ # lifecycle lock while holding the journal writer lock.
9
+ module OrdinaryMergeEvidence
10
+ def with_merge_scope(record:, deadline:, exclusive: false)
11
+ assignment, attempt = record.values_at("assignment_id", "attempt_id")
12
+ token!(assignment)
13
+ token!(attempt)
14
+ registered = registration(assignment, commit: @journal.ref_value)
15
+ raise AttemptErrors::EvidenceUnavailable, "merge assignment is unregistered" unless registered
16
+ @exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id"))], deadline: deadline) do
17
+ key = @exclusion.assignment_key(assignment)
18
+ if exclusive
19
+ @exclusion.with_exclusive(key, deadline: deadline) { yield }
20
+ else
21
+ @exclusion.with_shared_multi([key], deadline: deadline) { yield }
22
+ end
23
+ end
24
+ end
25
+
26
+ def verify_merge_evidence_at!(record:, commit:, deadline:)
27
+ unless record.fetch("project_id") == @project
28
+ raise AttemptErrors::ReceiptRejected, "merge evidence project differs"
29
+ end
30
+ request = record.fetch("request")
31
+ events = result_events(record, commit)
32
+ current = ordinary_candidate_event(events)&.dig("payload", "data")
33
+ raise AttemptErrors::ReceiptRejected, "merge candidate is unavailable" unless current
34
+ projection = merge_evidence_at!(events: events, commit: commit, head: request.fetch("candidate_head"),
35
+ generation: current.fetch("candidate_generation"), execution_ids: request.fetch("test_evidence").map { |reference| reference.fetch("reference") }, deadline: deadline)
36
+ unless request.fetch("review_evidence") == projection.fetch("review_evidence") && request.fetch("test_evidence") == projection.fetch("test_evidence")
37
+ raise AttemptErrors::ReceiptRejected, "merge requires exact canonical review and executed test references"
38
+ end
39
+ origin = origin_at!(events: events, commit: commit)
40
+ observed = @admission.recorded_execution!(binding: origin.fetch("runtime_binding"), head: request.fetch("candidate_head"), deadline: deadline)
41
+ unless observed.fetch(:runtime_binding) == origin.fetch("runtime_binding")
42
+ raise AttemptErrors::UnauthorizedIdentity, "merge producer native identity changed"
43
+ end
44
+ true
45
+ rescue KeyError, TypeError
46
+ raise AttemptErrors::ReceiptRejected, "canonical merge evidence is incomplete"
47
+ end
48
+
49
+ def merge_evidence_at!(events:, commit:, head:, generation:, execution_ids:, deadline:)
50
+ unless @command_evidence && @command_evidence.journal.equal?(@journal) && execution_ids.is_a?(Array) &&
51
+ execution_ids.size.between?(1, 16) && execution_ids.uniq == execution_ids
52
+ raise AttemptErrors::EvidenceUnavailable, "merge requires the installed canonical executed-test owner"
53
+ end
54
+ candidate = candidate_at!(events: events, commit: commit, head: head, generation: generation)
55
+ review = review_at!(events: events, commit: commit, head: head, generation: generation)
56
+ tests = execution_ids.map { |id| @command_evidence.test_evidence_at!(execution_id: id, commit: commit,
57
+ head: head, tree: candidate.fetch("tree"), deadline: deadline) }
58
+ {"review_evidence" => {"schema" => "ace.review.acceptance-reference/v1", "reference" => review.fetch("review_id"),
59
+ "reviewer" => review.fetch("reviewer_actor"), "verdict" => "approved", "reviewed_head" => head,
60
+ "recorded_at" => review.fetch("recorded_at"), "record_sha256" => review.fetch("acceptance_event_digest")},
61
+ "test_evidence" => tests}
62
+ end
63
+
64
+ private
65
+
66
+ def ordinary_merge_evidence(request, peer, role, deadline)
67
+ params = request.fetch("params")
68
+ strict!(params, %w[assignment_id attempt_id head candidate_generation test_execution_ids])
69
+ raise ArgumentError, "merge evidence read mutation must be null" unless request.fetch("mutation_id").nil?
70
+ with_scope(assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), deadline: deadline) do
71
+ prefix = @journal.ref_value
72
+ projection = merge_evidence_at!(events: result_events(params, prefix), commit: prefix, head: params.fetch("head"),
73
+ generation: params.fetch("candidate_generation"), execution_ids: params.fetch("test_execution_ids"), deadline: deadline)
74
+ authorize!(request, peer, role, deadline)
75
+ {data: projection.merge("journal_commit" => prefix), replayed: false}
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "release_artifact_manifest"
4
+ require_relative "candidate_transfer"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Retention is a canonical input, never a publication permit or result.
10
+ # Native/candidate/review and release policy are rechecked at every CAS.
11
+ module OrdinaryReleaseArtifacts
12
+ RELEASE_OPERATIONS = %w[retain_release_artifact].freeze
13
+ RELEASE_TRANSFERS = {"retain_release_artifact" =>
14
+ {direction: :upload, purpose: :release_artifact, roles: %i[worker supervisor]}}.freeze
15
+ RELEASE_PARAMETERS = %w[assignment_id attempt_id head candidate_generation pane expected_generation
16
+ authorization manifest transfer].freeze
17
+
18
+ # Read an accepted import at the current immutable canonical prefix only.
19
+ # Historical commit selection is not supported by the native origin reader.
20
+ # The later effect consumer must independently admit its fresh scope,
21
+ # candidate, review, authorization and fixed executor before dispatch.
22
+ def release_artifact_at!(events:, commit:, artifact_id:)
23
+ token!(artifact_id)
24
+ unless commit && Models::EvidenceEvent.chain_valid?(events)
25
+ raise AttemptErrors::EvidenceUnavailable, "release artifact history is unavailable"
26
+ end
27
+ accepted = events.select { |event| event["type"] == "authority_mutation" &&
28
+ event.dig("payload", "operation") == "retain_release_artifact" && event.dig("payload", "mutation_id") == artifact_id }
29
+ data = accepted.one? && accepted.first.dig("payload", "data")
30
+ unless data && data["artifact_id"] == artifact_id && data["input_digest"] == Atoms::EvidenceDigest.digest(data.fetch("input"))
31
+ raise AttemptErrors::EvidenceUnavailable, "release artifact acceptance is unavailable"
32
+ end
33
+ origin = data.fetch("producer_origin")
34
+ retained = @journal.read_events(origin.fetch("assignment_id"), commit: commit)
35
+ .select { |event| event["attempt_id"] == origin.fetch("attempt_id") }
36
+ unless retained == events && ordinary_origin_at!(events, commit, running: false) == origin
37
+ raise AttemptErrors::EvidenceUnavailable, "release artifact original scope differs"
38
+ end
39
+ context = release_evidence_context(data.fetch("input"), origin, data.fetch("authorization"), artifact_id)
40
+ bytes = Molecules::CanonicalEvidence.new(journal: @journal).read(data.fetch("artifact"), **context, commit: commit)
41
+ unless bytes.bytesize == data.dig("input", "publication", "artifact_bytes") &&
42
+ Digest::SHA256.hexdigest(bytes) == data.dig("input", "target", "artifact_digest")
43
+ raise AttemptErrors::EvidenceUnavailable, "release artifact retained bytes differ"
44
+ end
45
+ data.merge("bytes" => bytes)
46
+ rescue KeyError, TypeError
47
+ raise AttemptErrors::EvidenceUnavailable, "release artifact acceptance is incomplete"
48
+ end
49
+
50
+ private
51
+
52
+ def validate_ordinary_release!(request)
53
+ params = request.fetch("params")
54
+ strict!(params, RELEASE_PARAMETERS)
55
+ %w[assignment_id attempt_id authorization].each { |key| token!(params.fetch(key)) }
56
+ token!(request.fetch("mutation_id"))
57
+ unless params["head"].is_a?(String) && CandidateTransfer::SHA.match?(params["head"]) &&
58
+ %w[candidate_generation expected_generation].all? { |key| params[key].is_a?(Integer) && params[key].positive? }
59
+ raise ArgumentError, "invalid release artifact candidate selection"
60
+ end
61
+ ReleaseArtifactManifest.validate!(params.fetch("manifest"))
62
+ end
63
+
64
+ def authorize_ordinary_release!(request, peer, role, deadline)
65
+ validate_ordinary_release!(request)
66
+ params = request.fetch("params")
67
+ with_scope(assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), deadline: deadline) do
68
+ prefix = @journal.ref_value
69
+ release_context_at!(request, peer, role, deadline, result_events(params, prefix), prefix)
70
+ end
71
+ end
72
+
73
+ # Caller holds lifecycle exclusion before entering the journal writer.
74
+ # This method never acquires another lifecycle lock or trusts a path.
75
+ def release_context_at!(request, peer, role, deadline, events, commit)
76
+ authorize!(request, peer, role, deadline)
77
+ unless %i[worker supervisor].include?(role) && @release_authorizer
78
+ raise AttemptErrors::UnauthorizedIdentity, "installed release artifact authorization is unavailable"
79
+ end
80
+ params = request.fetch("params")
81
+ origin = origin_at!(events: events, commit: commit)
82
+ current = candidate_at!(events: events, commit: commit, head: params.fetch("head"), generation: params.fetch("candidate_generation"))
83
+ reference = candidate_reference_at!(events: events, commit: commit, head: params.fetch("head"), generation: params.fetch("candidate_generation"))
84
+ observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: current.fetch("head"), deadline: deadline)
85
+ unless observed.fetch(:identity).runtime_binding == origin.fetch("runtime_binding")
86
+ raise AttemptErrors::UnauthorizedIdentity, "release artifact producer native identity changed"
87
+ end
88
+ review_at!(events: events, commit: commit, head: current.fetch("head"), generation: current.fetch("candidate_generation"))
89
+ input = ReleaseArtifactManifest.publication_input(manifest: params.fetch("manifest"), candidate: current, reference: reference)
90
+ binding = params.slice("assignment_id", "attempt_id", "authorization").merge(
91
+ "operation" => "publish", "project_id" => @project, "candidate_head" => current.fetch("head"),
92
+ "caller_uid" => origin.dig("runtime_binding", "process_identity", "uid"),
93
+ "input_digest" => Atoms::EvidenceDigest.digest(input), "target" => input.fetch("target"))
94
+ unless @release_authorizer.call(binding)
95
+ raise AttemptErrors::UnauthorizedIdentity, "release artifact manifest is not authorized"
96
+ end
97
+ {input: input, binding: binding, origin: origin}
98
+ rescue SecurityError
99
+ raise AttemptErrors::UnauthorizedIdentity, "release artifact manifest authorization is unavailable"
100
+ end
101
+
102
+ def retain_release_artifact(request, peer, role, deadline, transfer)
103
+ validate_ordinary_release!(request)
104
+ unless transfer && transfer.count == 1
105
+ raise AttemptErrors::MalformedTransfer, "release artifact requires its one bounded binary transfer"
106
+ end
107
+ params = request.fetch("params")
108
+ with_scope(assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), deadline: deadline) do
109
+ prefix = @journal.ref_value
110
+ release_context_at!(request, peer, role, deadline, result_events(params, prefix), prefix)
111
+ bytes = transfer.bytes.dup.freeze
112
+ ReleaseArtifactManifest.verify!(bytes, params.fetch("manifest"))
113
+ digest = Atoms::EvidenceDigest.digest("project_id" => @project, "params" => params)
114
+ recheck = lambda do
115
+ current = @journal.ref_value
116
+ release_context_at!(request, peer, role, deadline, result_events(params, current), current)
117
+ end
118
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
119
+ mutation_id: request.fetch("mutation_id"), operation: "retain_release_artifact", parameters_digest: digest,
120
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
121
+ replay_authorizer: recheck) do |events, commit, _generation|
122
+ selected = release_context_at!(request, peer, role, deadline, events, commit)
123
+ artifact_id = request.fetch("mutation_id")
124
+ context = release_evidence_context(selected.fetch(:input), selected.fetch(:origin), params.fetch("authorization"), artifact_id)
125
+ plan = Molecules::CanonicalEvidence.new(journal: @journal).import_plan(**context,
126
+ artifacts: [bytes], admitted_after_event_digest: events.last.fetch("digest"))
127
+ plan.merge(data: {"artifact_id" => artifact_id, "input" => selected.fetch(:input),
128
+ "input_digest" => selected.fetch(:binding).fetch("input_digest"), "artifact" => plan.fetch(:references).first,
129
+ "authorization" => params.fetch("authorization"), "producer_origin" => selected.fetch(:origin)})
130
+ end
131
+ end
132
+ end
133
+
134
+ def release_evidence_context(input, origin, authorization, artifact_id)
135
+ {kind: "release-artifact", project_id: @project, assignment_id: origin.fetch("assignment_id"),
136
+ attempt_id: origin.fetch("attempt_id"), peer_uid: origin.dig("runtime_binding", "process_identity", "uid"),
137
+ binding: {"input" => input, "producer_origin" => origin, "authorization" => authorization},
138
+ request_id_or_event_id: artifact_id, generation: input.fetch("publication").fetch("candidate_reference").fetch("candidate_generation")}
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end