ace-assign 0.69.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +39 -10
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ module FixedPublicationLifecycle
7
+ def publication_executor_identity?(project:, uid:)
8
+ service_project!(project).fetch("service_executor_uids").include?(uid)
9
+ end
10
+
11
+ def with_publication_hitl!(binding:, project:, peer:)
12
+ params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
13
+ map = service_mapping!(params.fetch("mapping_id"))
14
+ raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
15
+ with_service_scope(params: params, map: map) do |journal, _registration|
16
+ service_journal!(journal)
17
+ record = publication_record!(journal, params, map, peer, :executor)
18
+ unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
19
+ raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
20
+ end
21
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
22
+ selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
23
+ "challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
24
+ yield selection
25
+ end
26
+ end
27
+
28
+ def authorize_publication_update!(journal:, existing:, replacement:, pending:)
29
+ operation = pending.fetch(:operation)
30
+ allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
31
+ unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
32
+ raise AttemptErrors::InvalidState, "publication transition changed original binding"
33
+ end
34
+ owner = ServiceEvidence.new(journal: journal)
35
+ if operation == "publication_challenge"
36
+ unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
37
+ replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
38
+ raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
39
+ end
40
+ need = owner.publication_challenge!(replacement, pending: pending)
41
+ unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
42
+ raise AttemptErrors::InvalidState, "publication need predecessor differs"
43
+ end
44
+ elsif operation == "publication_continue"
45
+ unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
46
+ (allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
47
+ raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
48
+ end
49
+ owner.publication_challenge!(replacement, pending: pending)
50
+ events = pending.fetch(:current_events) + pending.fetch(:pending_events)
51
+ event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
52
+ unless event && event["type"] == "service_publication_issuing" &&
53
+ %w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
54
+ raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
55
+ end
56
+ else
57
+ raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
58
+ end
59
+ true
60
+ end
61
+
62
+ def publication_record!(journal, params, map, peer, role, events: nil)
63
+ record = journal.service_request(params.fetch("request_id"))
64
+ unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
65
+ %w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
66
+ %w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
67
+ record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
68
+ raise AttemptErrors::Conflict, "publication original tuple or phase differs"
69
+ end
70
+ service_executor!(peer, role, record)
71
+ service_ticket!(record, params, map, peer)
72
+ events ||= attempt_events(journal, params)
73
+ current = exact_candidate!(candidate(events), params)
74
+ origin = service_origin!(events, params)
75
+ service_worker!(record.fetch("worker_process_binding"), :worker, map, origin)
76
+ service_review!(journal, events, params, map, current)
77
+ service_scope_open!(events: events, params: params, map: map)
78
+ service_policy!.authorize!(record)
79
+ ServiceEvidence.new(journal: journal).context(record)
80
+ record
81
+ end
82
+
83
+ def authorize_publication_transfer!(request, params, map, peer, role)
84
+ with_service_scope(params: params, map: map) do |journal, _registration|
85
+ service_journal!(journal)
86
+ publication_record!(journal, params, map, peer, role)
87
+ end
88
+ true
89
+ end
90
+
91
+ def dispatch_publication(request, params, map, peer, role, transfer)
92
+ authorize_publication_transfer!(request, params, map, peer, role)
93
+ challenge = request.fetch("operation") == "publication_challenge"
94
+ bytes = nil
95
+ if challenge
96
+ unless transfer && transfer.count == 1
97
+ raise ArgumentError, "publication need requires one bounded artifact"
98
+ end
99
+ bytes = transfer.bytes.dup.freeze
100
+ unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
101
+ raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
102
+ end
103
+ elsif transfer
104
+ raise ArgumentError, "publication continuation forbids upload"
105
+ end
106
+ with_service_scope(params: params, map: map) do |journal, _registration|
107
+ publication_record!(journal, params, map, peer, role)
108
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
109
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
110
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
111
+ with_replay: true) do |events, _commit, generation|
112
+ record = publication_record!(journal, params, map, peer, role, events: events)
113
+ if challenge
114
+ publication_challenge_plan(journal, events, record, params, bytes, generation)
115
+ else
116
+ publication_continue_plan(journal, events, record, params, generation)
117
+ end
118
+ end
119
+ if !challenge && result.fetch(:replayed)
120
+ result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
121
+ end
122
+ result
123
+ end
124
+ end
125
+
126
+ def publication_challenge_plan(journal, events, record, params, bytes, generation)
127
+ owner = ServiceEvidence.new(journal: journal)
128
+ digest = Digest::SHA256.hexdigest(bytes)
129
+ if record["dispatch_phase"] == "otp_pending"
130
+ owner.publication_challenge!(record)
131
+ raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
132
+ return {data: service_projection(record).merge("challenge" => "retained")}
133
+ end
134
+ unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
135
+ raise AttemptErrors::InvalidState, "publication has no rejected push"
136
+ end
137
+ previous = record["publication_challenge_digest"]
138
+ owner.publication_challenge!(record) if previous
139
+ owner.publication_need!(bytes, record, previous: previous)
140
+ payload = record.slice("request_id", "input_digest", "claim_binding").merge(
141
+ "challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
142
+ time = Time.now.utc
143
+ event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
144
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
145
+ plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
146
+ artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
147
+ replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
148
+ "publication_challenge_ref" => plan.fetch(:references).fetch(0),
149
+ "publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
150
+ plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
151
+ data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
152
+ expected: record, replacement: replacement, event_type: "service_transition"}])
153
+ end
154
+
155
+ def publication_continue_plan(journal, events, record, params, generation)
156
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
157
+ unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
158
+ raise AttemptErrors::Conflict, "publication continuation challenge differs"
159
+ end
160
+ if record["dispatch_phase"] == "issuing"
161
+ return {data: service_projection(record).merge("invocation" => "already_issued")}
162
+ end
163
+ raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
164
+ payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
165
+ time = Time.now.utc
166
+ event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
167
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
168
+ replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
169
+ {events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
170
+ data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
171
+ expected: record, replacement: replacement, event_type: "service_transition"}]}
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -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"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "../molecules/protected_control_exclusion"
3
3
  require_relative "campaign_execution"
4
+ require_relative "assignment_definition"
4
5
 
5
6
  module Ace
6
7
  module Assign
@@ -117,23 +118,8 @@ module Ace
117
118
  end
118
119
 
119
120
  def validated_registration_definition!(params, map)
120
- bytes = params.fetch("definition_bytes")
121
- unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && params["definition_digest"].is_a?(String) &&
122
- Digest::SHA256.hexdigest(bytes) == params["definition_digest"]
123
- raise ArgumentError, "invalid assignment definition digest or size"
124
- end
125
- value = JSON.parse(bytes)
126
- allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
127
- unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
128
- %w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
129
- value["session_id"] == params["assignment_id"] && value["project_id"] == map["project_id"]
130
- raise ArgumentError, "invalid managed assignment definition"
131
- end
132
- CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
133
- assignment = Models::Assignment.from_h(value)
134
- raise ArgumentError, "definition is not managed" unless assignment.managed?
135
- token!(assignment.task_id)
136
- assignment
121
+ AssignmentDefinition.parse(bytes: params.fetch("definition_bytes"), digest: params["definition_digest"],
122
+ assignment_id: params["assignment_id"], project_id: map["project_id"])
137
123
  end
138
124
  end
139
125
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ module OrdinaryAttemptStart
7
+ START_KEYS = %w[assignment_id attempt_id expected_generation head pane scope].freeze
8
+
9
+ private
10
+
11
+ def start_attempt(request, peer, role, deadline)
12
+ params = request.fetch("params")
13
+ strict!(params, START_KEYS)
14
+ %w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
15
+ if params.fetch("attempt_id").start_with?("definition-")
16
+ raise ArgumentError, "ordinary attempt ID uses the registration namespace"
17
+ end
18
+ token!(request.fetch("mutation_id"))
19
+ unless params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/)
20
+ raise ArgumentError, "ordinary start HEAD is invalid"
21
+ end
22
+ unless %i[supervisor worker].include?(role) && params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation") == 0
23
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary start requires an installed worker and a new attempt"
24
+ end
25
+ commit = @journal.ref_value
26
+ registered = registration(params.fetch("assignment_id"), commit: commit)
27
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
28
+ work = registered_work(registered, commit, deadline)
29
+ scope = params.fetch("scope")
30
+ unless work.manifest.fetch("steps").any? { |step| step.fetch("number") == scope }
31
+ raise AttemptErrors::InvalidScope, "ordinary scope is not in the accepted work"
32
+ end
33
+ keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
34
+ @exclusion.with_shared_multi(keys, deadline: deadline) do
35
+ if @journal.mutation_result(request.fetch("mutation_id"))
36
+ # Work may legitimately have advanced since an acknowledged or
37
+ # lost start reply. Recover its original head, never recapture it.
38
+ identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
39
+ return @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
40
+ mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: start_digest(params, peer, identity),
41
+ expected_generation: 0, with_replay: true, deadline: deadline,
42
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do
43
+ raise AttemptErrors::EvidenceUnavailable, "accepted ordinary start disappeared"
44
+ end
45
+ end
46
+ # Only the installed admission can observe the actual native owner
47
+ # and worker checkout. It performs no launch or delivery effect.
48
+ observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
49
+ identity = observed.fetch(:identity)
50
+ principal = {"uid" => peer.fetch("uid"), "actor" => identity.actor}
51
+ digest = start_digest(params, peer, identity)
52
+ authorize!(request, peer, role, deadline)
53
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
54
+ mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: digest,
55
+ expected_generation: 0, with_replay: true, deadline: deadline,
56
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, _generation|
57
+ authorize!(request, peer, role, deadline)
58
+ unless @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline) == identity
59
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary native worker changed before start acceptance"
60
+ end
61
+ unless events.empty? && registration(params.fetch("assignment_id"), commit: prefix) == registered
62
+ raise AttemptErrors::Conflict, "ordinary start registration or attempt changed"
63
+ end
64
+ if @journal.assignment_ids(commit: prefix).any? { |assignment|
65
+ assignment != params.fetch("assignment_id") && @journal.read_events(assignment, commit: prefix).any? { |event|
66
+ event.fetch("attempt_id") == params.fetch("attempt_id") } }
67
+ raise AttemptErrors::Conflict, "ordinary attempt ID belongs to another assignment"
68
+ end
69
+ # Sibling scopes may run independently. A different request ID
70
+ # never repeats an already consumed scope or any part of it.
71
+ if @journal.derived_attempts(params.fetch("assignment_id"), commit: prefix).any? { |attempt|
72
+ Atoms::AssignmentScope.overlap?(attempt.binding.scope, scope) }
73
+ raise AttemptErrors::Conflict, "ordinary scope already has an attempt; reconcile its original execution"
74
+ end
75
+ record = current_queue_record(registered, prefix)
76
+ queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(record, prefix))
77
+ .transition(operation: :activate, scope: scope)
78
+ queue_sha = Digest::SHA256.hexdigest(queue.bytes)
79
+ queue_path = "execution/definitions/#{params.fetch('assignment_id')}-queue-#{queue_sha}.json"
80
+ {events: [
81
+ {type: "intent", payload: {"assignment_id" => params.fetch("assignment_id"), "scope" => scope,
82
+ "project_id" => @project, "task_id" => registered.fetch("task_id"), "base_head" => observed.fetch(:checkout).fetch("head")}},
83
+ {type: "process_start", payload: {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime,
84
+ "pid" => identity.process_pid, "process_identity" => identity.runtime_binding.fetch("process_identity"),
85
+ "runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout)}}
86
+ ], blobs: {queue_path => queue.bytes}, data: {
87
+ "authority" => "ordinary", "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
88
+ "project_id" => @project, "scope" => scope, "state" => "running", "started_by" => principal,
89
+ "base_head" => observed.fetch(:checkout).fetch("head"), "runtime_binding" => identity.runtime_binding,
90
+ "queue_ref" => queue_path, "queue_sha256" => queue_sha}}
91
+ end
92
+ end
93
+ end
94
+
95
+ def start_digest(params, peer, identity)
96
+ Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project,
97
+ "principal" => {"uid" => peer.fetch("uid"), "actor" => identity.actor}})
98
+ end
99
+
100
+ def authorize_start_replay!(request, peer, role, deadline)
101
+ authorize!(request, peer, role, deadline)
102
+ params = request.fetch("params")
103
+ identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
104
+ start_replay_owner!(params, identity)
105
+ end
106
+
107
+ def registered_work(registered, commit, deadline)
108
+ reference = registered.fetch("prepared_work")
109
+ bytes = @journal.bounded_blob(registered.fetch("prepared_bundle_ref"), commit: commit,
110
+ max_bytes: registered.fetch("prepared_bundle_bytes"))
111
+ work = PreparedWork.admit(bytes: bytes, head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"),
112
+ sha256: registered.fetch("prepared_bundle_sha256"), size: registered.fetch("prepared_bundle_bytes"),
113
+ root: @input_root, deadline: deadline)
114
+ unless work.reference(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree")) == reference &&
115
+ work.manifest.values_at("assignment_id", "project_id", "task_id") == registered.values_at("assignment_id", "project_id", "task_id") &&
116
+ Digest::SHA256.hexdigest(work.definition_bytes(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"))) == registered.fetch("definition_digest")
117
+ raise AttemptErrors::EvidenceUnavailable, "ordinary prepared registration bytes differ"
118
+ end
119
+ work
120
+ end
121
+
122
+ def start_replay_owner!(params, identity)
123
+ events = @journal.read_events(params.fetch("assignment_id"))
124
+ .select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
125
+ unless Models::EvidenceEvent.chain_valid?(events) && events.count { |event| event["type"] == "process_start" } == 1
126
+ raise AttemptErrors::EvidenceUnavailable, "ordinary start evidence is unavailable"
127
+ end
128
+ owner = events.find { |event| event["type"] == "process_start" }.fetch("payload")
129
+ unless owner.values_at("actor", "role", "runtime", "runtime_binding") ==
130
+ [identity.actor, identity.role, identity.runtime, identity.runtime_binding]
131
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary replay belongs to its original native worker"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end