ace-assign 0.64.0 → 0.66.1

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -12,7 +12,18 @@ module Ace
12
12
  # exclusion; the immutable commit is the current CAS input, never a
13
13
  # caller-selected alternate evidence ref.
14
14
  def service_settlement_complete!(journal:, events:, params:, map:, commit:)
15
+ service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
16
+ true
17
+ end
18
+
19
+ def service_settlement_evidence!(journal:, events:, params:, map:, commit:)
15
20
  protected_journal!(journal)
21
+ journal.verify_commit!(commit)
22
+ retained = journal.read_events(params.fetch("assignment_id"), commit: commit)
23
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
24
+ unless retained == events && Models::EvidenceEvent.chain_valid?(events)
25
+ raise AttemptErrors::EvidenceUnavailable, "service settlement requires exact canonical commit"
26
+ end
16
27
  references = events.select { |event| %w[service_claim service_transition].include?(event["type"]) }
17
28
  .map { |event| event.fetch("payload").fetch("request_id") }.uniq.sort
18
29
  records = journal.service_request_records(commit: commit).select do |record|
@@ -21,17 +32,48 @@ module Ace
21
32
  unless records.map { |record| record.fetch("request_id") }.uniq.sort == references
22
33
  raise AttemptErrors::EvidenceUnavailable, "canonical service request set is incomplete"
23
34
  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"])
35
+ pending = false
36
+ projected = records.sort_by { |record| record.fetch("request_id") }.map do |record|
37
+ unless record["project_id"] == map.fetch("project_id") && record["mapping_id"] == params.fetch("mapping_id")
27
38
  raise AttemptErrors::EvidenceUnavailable, "canonical service request is not settled for this scope"
28
39
  end
29
40
  # This existing reader verifies the accepted record digest and all
30
41
  # imported terminal/no-effect evidence against this exact commit.
31
- journal.service_request(record.fetch("request_id"), commit: commit)
42
+ verified = journal.service_request(record.fetch("request_id"), commit: commit)
43
+ unless verified == record && %w[accepted uncertain rejected succeeded failed failed-settled].include?(verified.fetch("state"))
44
+ raise AttemptErrors::EvidenceUnavailable, "canonical service record differs"
45
+ end
46
+ if %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| verified.key?(key) }
47
+ ServiceEvidence.new(journal: journal).challenge!(verified, pending: {commit: commit}, current: false)
48
+ end
49
+ unless %w[succeeded failed-settled].include?(verified.fetch("state"))
50
+ pending = true
51
+ next
52
+ end
53
+ transition = events.reverse.find { |event| %w[service_claim service_transition].include?(event["type"]) &&
54
+ event.dig("payload", "request_id") == record.fetch("request_id") }
55
+ receipt = verified.fetch("receipt")
56
+ unless transition && transition["type"] == "service_transition" &&
57
+ transition.dig("payload", "state") == verified.fetch("state") &&
58
+ transition.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(verified) &&
59
+ transition.dig("payload", "receipt_digest") == Atoms::EvidenceDigest.digest(receipt)
60
+ raise AttemptErrors::EvidenceUnavailable, "accepted service terminal transition differs"
61
+ end
62
+ {"request_id" => verified.fetch("request_id"), "state" => verified.fetch("state"),
63
+ "event_digest" => transition.fetch("digest"), "record_digest" => transition.dig("payload", "record_digest"),
64
+ "receipt_digest" => transition.dig("payload", "receipt_digest"),
65
+ "evidence_refs" => receipt.fetch("evidence").sort_by { |ref| ref.values_at("ref", "sha256") }}
32
66
  end
33
- true
34
- rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
67
+ projected.compact!
68
+ unless projected.empty?
69
+ journal.event_commits!(assignment_id: params.fetch("assignment_id"),
70
+ event_digests: projected.map { |row| row.fetch("event_digest") }, commit: commit)
71
+ end
72
+ if pending
73
+ raise AttemptErrors::ServiceSettlementPending, "authenticated canonical service requests remain unsettled"
74
+ end
75
+ settlement_immutable({"commit" => commit, "services" => projected})
76
+ rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
35
77
  raise AttemptErrors::EvidenceUnavailable, "canonical service settlement is unverifiable"
36
78
  end
37
79
 
@@ -39,8 +81,30 @@ module Ace
39
81
  # service writers. It never reacquires lifecycle/authority locks.
40
82
  def authorize_service_update!(journal:, existing:, replacement:, pending:)
41
83
  protected_journal!(journal)
84
+ if existing && existing.key?("executor_process_binding") &&
85
+ Atoms::EvidenceDigest.digest(existing.fetch("executor_process_binding")) != Atoms::EvidenceDigest.digest(replacement["executor_process_binding"])
86
+ raise AttemptErrors::InvalidState, "original cleanup executor cannot change"
87
+ end
88
+ if existing && existing.key?("operation_owner_binding") &&
89
+ Atoms::EvidenceDigest.digest(existing.fetch("operation_owner_binding")) != Atoms::EvidenceDigest.digest(replacement["operation_owner_binding"])
90
+ raise AttemptErrors::InvalidState, "original cleanup dispatch owner cannot change"
91
+ end
92
+ if existing && %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| existing[key] != replacement[key] }
93
+ unless pending && pending[:operation] == "claim_service_settlement" &&
94
+ existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
95
+ existing["dispatch_phase"] == replacement["dispatch_phase"]
96
+ raise AttemptErrors::InvalidState, "no-effect challenge requires its fixed source owner"
97
+ end
98
+ ServiceEvidence.new(journal: journal).challenge!(replacement, pending: pending)
99
+ end
42
100
  initial = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
101
+ if initial && (replacement.key?("operation_owner_binding") || replacement.key?("executor_process_binding"))
102
+ raise AttemptErrors::InvalidState, "cleanup owner identity requires fresh dispatch admission"
103
+ end
43
104
  begin_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
105
+ if existing && existing["operation"] == "publish" && pending && %w[publication_challenge publication_continue].include?(pending[:operation])
106
+ return authorize_publication_update!(journal: journal, existing: existing, replacement: replacement, pending: pending)
107
+ end
44
108
  if existing && existing["dispatch_phase"] != replacement["dispatch_phase"] && !begin_dispatch
45
109
  raise AttemptErrors::InvalidState, "invalid protected dispatch phase change"
46
110
  end
@@ -65,8 +129,22 @@ module Ace
65
129
  receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
66
130
  raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
67
131
  current = exact_candidate!(candidate(events), params)
68
- approved_review!(journal, events, params, map, current)
132
+ approved_review!(journal, events, params, map, current, service_operation: replacement.fetch("operation")) unless %w[create update].include?(replacement.fetch("operation"))
69
133
  service_policy!.prepare!(replacement, input_bytes: bytes)
134
+ if begin_dispatch && %w[prune-preserved-workspace publish].include?(replacement.fetch("operation"))
135
+ executor = replacement.fetch("executor_process_binding")
136
+ boot = replacement.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
137
+ 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)
139
+ unless executor.slice("uid", "gid", "groups") == credentials.slice("gid", "groups").merge("uid" => receiver.fetch("executor_uid"))
140
+ raise AttemptErrors::UnauthorizedIdentity, "original cleanup executor credentials differ"
141
+ end
142
+ @kernel.live!(executor)
143
+ unless replacement["operation"] != "prune-preserved-workspace" || Atoms::EvidenceDigest.digest(service_policy!.dispatch_owner_binding!(replacement)) ==
144
+ Atoms::EvidenceDigest.digest(replacement.fetch("operation_owner_binding"))
145
+ raise AttemptErrors::UnauthorizedIdentity, "fixed cleanup owner changed before dispatch acceptance"
146
+ end
147
+ end
70
148
  true
71
149
  rescue KeyError
72
150
  raise AttemptErrors::UnauthorizedIdentity, "protected service admission context is incomplete"
@@ -74,6 +152,15 @@ module Ace
74
152
 
75
153
  private
76
154
 
155
+ def settlement_immutable(value)
156
+ case value
157
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key.dup.freeze] = settlement_immutable(item) }.freeze
158
+ when Array then value.map { |item| settlement_immutable(item) }.freeze
159
+ when String then value.dup.freeze
160
+ else value.freeze
161
+ end
162
+ end
163
+
77
164
  def authorize_service_transfer!(request, params, map, peer, role)
78
165
  service_policy!
79
166
  service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
@@ -100,20 +187,71 @@ module Ace
100
187
  true
101
188
  end
102
189
 
190
+ # Receiver identity and forwarded worker identity are independent checks.
191
+ # This immutable observation grants no service claim or dispatch permit.
192
+ def workspace_prune_preview_context(_request, params, map, peer, role, transfer)
193
+ raise ArgumentError, "preview context forbids upload" unless transfer.nil?
194
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
195
+ {data: workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: role), replayed: false}
196
+ end
197
+ end
198
+
199
+ # Only source composition supplies this fixed journal/snapshot owner.
200
+ # Both ordinary admission and the root read-only view use the same
201
+ # original registration, process and candidate proof implementation.
202
+ public
203
+
204
+ def workspace_prune_preview_context_at!(journal:, params:, map:, peer:, role:)
205
+ service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"))
206
+ protected_journal!(journal)
207
+ 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)
211
+ receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
212
+ service_policy!.workspace_prune_receiver!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"),
213
+ service_id: params.fetch("service_id"), executor_uid: receiver.fetch("executor_uid"))
214
+ selected = candidate(events)
215
+ unless selected && selected["head"].is_a?(String) && selected["head"].match?(CandidateTransfer::SHA) &&
216
+ selected["candidate_generation"].is_a?(Integer) && selected["candidate_generation"].positive?
217
+ raise AttemptErrors::EvidenceUnavailable, "preview candidate is unavailable"
218
+ end
219
+ data = {
220
+ "journal_commit" => commit,
221
+ "maintenance" => params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id")),
222
+ "head" => selected.fetch("head"), "candidate_generation" => selected.fetch("candidate_generation"),
223
+ "worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
224
+ "caller_process_binding" => params.fetch("worker_process_binding")
225
+ }
226
+ raise AttemptErrors::Conflict, "preview canonical selection changed" unless journal.ref_value == commit
227
+ raise AttemptErrors::EvidenceUnavailable, "preview context exceeds bound" if JSON.generate(data).bytesize > 16_384
228
+ Atoms::EvidenceDigest.digest(data) # Reject unsupported transport values before copying.
229
+ JSON.parse(JSON.generate(data))
230
+ end
231
+
232
+ private
233
+
103
234
  def service_status(_request, params, map, peer, role)
104
235
  @launch.with_assignment(params: params, map: map) do |journal, _registration|
105
236
  protected_journal!(journal)
106
- record = journal.service_request(params.fetch("request_id"))
237
+ commit = journal.ref_value
238
+ read = role == :executor && journal.service_settlement_read(params.fetch("request_id"), commit: commit)
239
+ record = read ? read.fetch(:record) : journal.service_request(params.fetch("request_id"), commit: commit)
107
240
  unless record && record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
108
241
  record["project_id"] == map["project_id"] && record["candidate_head"] == params["head"] &&
109
242
  record["candidate_generation"] == params["candidate_generation"]
110
243
  raise AttemptErrors::UnauthorizedIdentity, "service status binding differs"
111
244
  end
112
- events = attempt_events(journal, params)
245
+ if role != :executor && %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| record.key?(key) }
246
+ ServiceEvidence.new(journal: journal).challenge!(record, pending: {commit: commit}, current: false)
247
+ end
248
+ selected = read ? read.fetch(:read_view).inventory.fetch("events").fetch(params.fetch("assignment_id")) :
249
+ journal.read_events(params.fetch("assignment_id"), commit: commit)
250
+ events = selected.select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
113
251
  @kernel.live!(peer)
114
252
  service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
115
253
  if role == :executor
116
- service_executor!(peer, role, record)
254
+ record = service_settlement_record!(journal, params, map, peer, role, record: record)
117
255
  else
118
256
  authorized = case role
119
257
  when :worker
@@ -129,8 +267,19 @@ module Ace
129
267
  end
130
268
  raise AttemptErrors::UnauthorizedIdentity, "service status purpose differs" unless authorized
131
269
  end
132
- {data: service_projection(record).merge("generation" => journal.authority_generation(events),
133
- "journal_commit" => journal.ref_value), replayed: false}
270
+ data = service_projection(record).merge("generation" => journal.authority_generation(events),
271
+ "journal_commit" => commit)
272
+ data["input_digest"] = record.fetch("input_digest") if %w[create update ready merge].include?(record["operation"])
273
+ if %w[create update ready merge].include?(record["operation"]) && record["state"] == "succeeded"
274
+ event = ServiceDeliveryEvidence.new(journal: journal).verified(record: record, events: events, commit: commit)
275
+ data.merge!(record.slice("input_digest", "authorization", "executor_uid", "completion_digest", "receipt"))
276
+ data["delivery_event"] = event
277
+ end
278
+ if role == :executor
279
+ data["settlement_context"] = read.fetch(:settlement_context) ||
280
+ ServiceEvidence.new(journal: journal).settlement_context(record, commit: commit, read_view: read.fetch(:read_view))
281
+ end
282
+ {data: data, replayed: false}
134
283
  end
135
284
  end
136
285
 
@@ -158,7 +307,7 @@ module Ace
158
307
  raise AttemptErrors::Conflict, "authorization candidate changed"
159
308
  end
160
309
  service_receiver!(peer, role, map, record.fetch("service_id"))
161
- approved_review!(journal, events, params, map, current)
310
+ approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
162
311
  prepared = service_policy!.prepare!(record, input_bytes: bytes)
163
312
  data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
164
313
  "policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
@@ -183,7 +332,7 @@ module Ace
183
332
  # Only body binding is checked here; current effect policy stays
184
333
  # inside each fresh CAS callback.
185
334
  service_policy!.input_binding(admitted, expected_digest: input_record.fetch("input_digest"),
186
- expected_target: input_record.fetch("target"))
335
+ expected_target: input_record.fetch("target"), operation: input_record.fetch("operation"))
187
336
  end
188
337
  # Admission also runs outside mutate: an exact mutation replay
189
338
  # skips its callback and cannot bypass the authenticated ticket.
@@ -234,10 +383,40 @@ module Ace
234
383
  data["claim"] = retained || result.fetch(:replayed) || result.dig(:data, "claim") == "retained" ? "retained" : "created"
235
384
  result = result.merge(data: data)
236
385
  end
386
+ if %w[request_service begin_dispatch].include?(request.fetch("operation")) &&
387
+ %w[prune-preserved-workspace publish].include?(result.fetch(:data).fetch("operation"))
388
+ selector = request.fetch("operation") == "request_service" ? "request_event_digest" : "dispatch_event_digest"
389
+ digest = cleanup_service_mutation_event!(journal, request, params, result.fetch(:data).fetch("journal_commit"))
390
+ result = result.merge(data: result.fetch(:data).merge(selector => digest))
391
+ end
237
392
  result
238
393
  end
239
394
  end
240
395
 
396
+ # Selectors refer to the actual accepted mutation, never a caller's
397
+ # pathname or the latest service record. They are derived after CAS,
398
+ # avoiding a self-reference in the persisted mutation reply.
399
+ def cleanup_service_mutation_event!(journal, request, params, commit)
400
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit)
401
+ .select { |event| event["attempt_id"] == params.fetch("attempt_id") }
402
+ unless Models::EvidenceEvent.chain_valid?(events)
403
+ raise AttemptErrors::EvidenceUnavailable, "cleanup service mutation chain is unavailable"
404
+ end
405
+ selected = events.select do |event|
406
+ event["type"] == "authority_mutation" && event.dig("payload", "mutation_id") == request.fetch("mutation_id")
407
+ end
408
+ event = selected.one? && selected.first
409
+ unless event && event.dig("payload", "operation") == request.fetch("operation") &&
410
+ event.dig("payload", "parameters_digest") == Atoms::EvidenceDigest.digest(params) &&
411
+ event.dig("payload", "assignment_id") == params.fetch("assignment_id") &&
412
+ event.dig("payload", "attempt_id") == params.fetch("attempt_id") &&
413
+ event.dig("payload", "data", "request_id") == params.fetch("request_id") &&
414
+ journal.event_commit!(assignment_id: params.fetch("assignment_id"), event_digest: event.fetch("digest"), commit: commit) == commit
415
+ raise AttemptErrors::EvidenceUnavailable, "cleanup service mutation introduction is unavailable"
416
+ end
417
+ event.fetch("digest")
418
+ end
419
+
241
420
  def service_policy!
242
421
  @service_policy || raise(ArgumentError, "full-service authority requires its installed policy owner")
243
422
  end
@@ -246,6 +425,9 @@ module Ace
246
425
  unless record && role == :executor && peer["uid"] == record.fetch("executor_uid")
247
426
  raise AttemptErrors::UnauthorizedIdentity, "completion requires the recorded executor"
248
427
  end
428
+ if record["operation"] == "publish" && record["dispatch_phase"] != "issued" && peer != record["executor_process_binding"]
429
+ raise AttemptErrors::UnauthorizedIdentity, "publication requires its original receiver birth"
430
+ end
249
431
  @kernel.live!(peer)
250
432
  true
251
433
  end
@@ -269,7 +451,7 @@ module Ace
269
451
  worker = params.fetch("worker_process_binding")
270
452
  worker_or_launcher!(worker, :worker, map, origin)
271
453
  current = exact_candidate!(candidate(events), params)
272
- approved_review!(journal, events, params, map, current)
454
+ approved_review!(journal, events, params, map, current, service_operation: params.fetch("operation")) unless %w[create update].include?(params.fetch("operation"))
273
455
  receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
274
456
  binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
275
457
  "authorization", "service_id").merge("project_id" => map.fetch("project_id"),
@@ -315,9 +497,15 @@ module Ace
315
497
  unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
316
498
  raise AttemptErrors::Conflict, "dispatch candidate changed"
317
499
  end
318
- approved_review!(journal, events, params, map, current)
500
+ approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
319
501
  service_policy!.prepare!(record, input_bytes: input_bytes)
320
502
  replacement = record.merge("dispatch_phase" => "dispatch_started")
503
+ if record.fetch("operation") == "prune-preserved-workspace"
504
+ replacement["operation_owner_binding"] = service_policy!.dispatch_owner_binding!(record)
505
+ end
506
+ if %w[prune-preserved-workspace publish].include?(record.fetch("operation"))
507
+ replacement["executor_process_binding"] = JSON.parse(JSON.generate(peer))
508
+ end
321
509
  {data: service_projection(replacement).merge("invocation" => "permitted"),
322
510
  service_inputs: {record.fetch("request_id") => input_bytes},
323
511
  service_updates: [{request_id: record.fetch("request_id"), expected: record,
@@ -355,6 +543,12 @@ module Ace
355
543
  return {data: service_projection(record)}
356
544
  end
357
545
  receipt = admitted.fetch(:receipt)
546
+ if record["operation"] == "publish"
547
+ unless admitted.fetch(:artifacts).size == 1
548
+ raise AttemptErrors::ReceiptRejected, "publication requires its one exact result"
549
+ end
550
+ ServiceEvidence.new(journal: journal).publication_result!(admitted.fetch(:artifacts).first, record, state: receipt.fetch("outcome"))
551
+ end
358
552
  owner = ServiceEvidence.new(journal: journal)
359
553
  canonical = Molecules::CanonicalEvidence.new(journal: journal)
360
554
  plan = canonical.import_plan(**owner.context(record), artifacts: admitted.fetch(:artifacts),
@@ -362,6 +556,11 @@ module Ace
362
556
  normalized = JSON.parse(JSON.generate(receipt)).merge("evidence" => plan.fetch(:references))
363
557
  replacement = record.merge("state" => receipt.fetch("outcome"), "receipt" => normalized, "completion_digest" => digest)
364
558
  replacement["failed_at"] = Time.now.utc.iso8601(9) if receipt["outcome"] == "failed"
559
+ if %w[create update ready merge].include?(replacement["operation"]) && replacement["state"] == "succeeded"
560
+ event = ServiceDeliveryEvidence.new(journal: journal).completion_event(record: replacement,
561
+ receipt: normalized, completion_digest: digest, artifacts: admitted.fetch(:artifacts))
562
+ plan = plan.merge(events: plan.fetch(:events) + [event])
563
+ end
365
564
  plan.merge(data: service_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
366
565
  expected: record, replacement: replacement, event_type: "service_transition"}])
367
566
  end
@@ -379,7 +578,7 @@ module Ace
379
578
  unless receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
380
579
  Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.all? { |field| receipt[field] == record[field] } &&
381
580
  %w[succeeded failed].include?(receipt["outcome"]) &&
382
- (receipt["outcome"] != "succeeded" || record["dispatch_phase"] == "dispatch_started")
581
+ (receipt["outcome"] != "succeeded" || record["dispatch_phase"] == "dispatch_started" || (record["operation"] == "publish" && record["dispatch_phase"] == "issuing"))
383
582
  raise AttemptErrors::ReceiptRejected, "completion must attest the exact dispatched request"
384
583
  end
385
584
  digest
@@ -387,7 +586,11 @@ module Ace
387
586
 
388
587
  def service_projection(record)
389
588
  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")
589
+ "candidate_head", "candidate_generation", "state", "dispatch_ticket_id", "claim_binding", "claim_generation", "dispatch_phase", "policy_digest", "operation_owner_binding", "executor_process_binding", *ServicePublicationEvidence::PUBLICATION_FIELDS).tap do |projection|
590
+ if record["operation"] == "publish" && %w[succeeded failed].include?(record["state"])
591
+ projection.merge!(record.slice("receipt", "completion_digest", "authorization", "input_digest"))
592
+ end
593
+ end
391
594
  end
392
595
  end
393
596
  end