ace-assign 0.63.1 → 0.66.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ SERVICE_IDENTITY = %w[request_id assignment_id attempt_id project_id operation input_digest target
8
+ authorization service_id caller_uid candidate_head candidate_generation executor_uid transport policy_digest
9
+ worker_process_binding launch_ticket reservation_generation mapping_id].freeze
10
+
11
+ # Source-owned terminal admission. Caller already owns lifecycle/journal
12
+ # exclusion; the immutable commit is the current CAS input, never a
13
+ # caller-selected alternate evidence ref.
14
+ def service_settlement_complete!(journal:, events:, params:, map:, commit:)
15
+ 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:)
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
27
+ references = events.select { |event| %w[service_claim service_transition].include?(event["type"]) }
28
+ .map { |event| event.fetch("payload").fetch("request_id") }.uniq.sort
29
+ records = journal.service_request_records(commit: commit).select do |record|
30
+ record["assignment_id"] == params.fetch("assignment_id") && record["attempt_id"] == params.fetch("attempt_id")
31
+ end
32
+ unless records.map { |record| record.fetch("request_id") }.uniq.sort == references
33
+ raise AttemptErrors::EvidenceUnavailable, "canonical service request set is incomplete"
34
+ end
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")
38
+ raise AttemptErrors::EvidenceUnavailable, "canonical service request is not settled for this scope"
39
+ end
40
+ # This existing reader verifies the accepted record digest and all
41
+ # imported terminal/no-effect evidence against this exact 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") }}
66
+ end
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
77
+ raise AttemptErrors::EvidenceUnavailable, "canonical service settlement is unverifiable"
78
+ end
79
+
80
+ # Called only by the installed journal composition, including lower
81
+ # service writers. It never reacquires lifecycle/authority locks.
82
+ def authorize_service_update!(journal:, existing:, replacement:, pending:)
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
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
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
108
+ if existing && existing["dispatch_phase"] != replacement["dispatch_phase"] && !begin_dispatch
109
+ raise AttemptErrors::InvalidState, "invalid protected dispatch phase change"
110
+ end
111
+ return true unless initial || begin_dispatch
112
+ unless replacement["state"] == "uncertain" && (initial ? replacement["dispatch_phase"] == "issued" : true)
113
+ raise AttemptErrors::InvalidState, "fresh protected effect requires an uncertain dispatch ticket"
114
+ end
115
+ bytes = pending && pending.fetch(:service_inputs, {})[replacement.fetch("request_id")]
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"))
118
+ unless replacement["project_id"] == map["project_id"] && replacement["caller_uid"] == map["worker_uid"]
119
+ raise AttemptErrors::UnauthorizedIdentity, "protected service mapping differs"
120
+ end
121
+ params = replacement.slice("mapping_id", "assignment_id", "attempt_id", "candidate_generation").merge("head" => replacement.fetch("candidate_head"))
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")
127
+ raise AttemptErrors::UnauthorizedIdentity, "protected service origin differs"
128
+ end
129
+ receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
130
+ raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
131
+ 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"))
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
148
+ true
149
+ rescue KeyError
150
+ raise AttemptErrors::UnauthorizedIdentity, "protected service admission context is incomplete"
151
+ end
152
+
153
+ private
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
+
164
+ def authorize_service_transfer!(request, params, map, peer, role)
165
+ service_policy!
166
+ 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)
169
+ if request.fetch("operation") == "request_service"
170
+ existing = journal.service_request(params.fetch("request_id"))
171
+ if existing
172
+ service_executor!(peer, role, existing)
173
+ service_replay_binding!(existing, params, map)
174
+ else
175
+ events = attempt_events(journal, params)
176
+ origin = active_origin(events, params)
177
+ worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
178
+ service_receiver!(peer, role, map, params.fetch("service_id"))
179
+ end
180
+ else
181
+ record = journal.service_request(params.fetch("request_id"))
182
+ raise AttemptErrors::NotFound, "service request is unavailable" unless record
183
+ service_executor!(peer, role, record)
184
+ service_ticket!(record, params, map, peer)
185
+ end
186
+ end
187
+ true
188
+ end
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
+
234
+ def service_status(_request, params, map, peer, role)
235
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
236
+ protected_journal!(journal)
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)
240
+ unless record && record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
241
+ record["project_id"] == map["project_id"] && record["candidate_head"] == params["head"] &&
242
+ record["candidate_generation"] == params["candidate_generation"]
243
+ raise AttemptErrors::UnauthorizedIdentity, "service status binding differs"
244
+ end
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") }
251
+ @kernel.live!(peer)
252
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
253
+ if role == :executor
254
+ record = service_settlement_record!(journal, params, map, peer, role, record: record)
255
+ else
256
+ authorized = case role
257
+ when :worker
258
+ peer["uid"] == record["caller_uid"] && @kernel.descendant?(peer, record.fetch("worker_process_binding"))
259
+ when :reviewer
260
+ review = assigned_review(events)
261
+ review && review["reviewer_uid"] == peer["uid"] && review["head"] == params["head"] &&
262
+ review["candidate_generation"] == params["candidate_generation"] && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
263
+ when :supervisor
264
+ @deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
265
+ else
266
+ false
267
+ end
268
+ raise AttemptErrors::UnauthorizedIdentity, "service status purpose differs" unless authorized
269
+ end
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}
283
+ end
284
+ end
285
+
286
+ def service_authorization(request, params, map, peer, role, transfer)
287
+ authorize_service_transfer!(request, params, map, peer, role)
288
+ raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
289
+ bytes = transfer.bytes.dup.freeze
290
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
291
+ protected_journal!(journal)
292
+ record = journal.service_request(params.fetch("request_id"))
293
+ service_executor!(peer, role, record)
294
+ service_ticket!(record, params, map, peer)
295
+ unless record.fetch("input_digest") == params.fetch("input_digest")
296
+ raise AttemptErrors::Conflict, "authorization input binding differs"
297
+ end
298
+ 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")
303
+ raise AttemptErrors::UnauthorizedIdentity, "authorization launch origin differs"
304
+ end
305
+ current = exact_candidate!(candidate(events), params)
306
+ unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
307
+ raise AttemptErrors::Conflict, "authorization candidate changed"
308
+ end
309
+ 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"))
311
+ prepared = service_policy!.prepare!(record, input_bytes: bytes)
312
+ data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
313
+ "policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
314
+ {data: data, replayed: false}
315
+ end
316
+ end
317
+
318
+ def dispatch_service(request, params, map, peer, role, transfer)
319
+ return service_authorization(request, params, map, peer, role, transfer) if request.fetch("operation") == "service_authorization"
320
+ authorize_service_transfer!(request, params, map, peer, role)
321
+ admitted = if request.fetch("operation") == "complete_service"
322
+ ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"), artifact_field: "evidence", reference_key: "ref")
323
+ else
324
+ raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
325
+ transfer.bytes.dup.freeze
326
+ end
327
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
328
+ protected_journal!(journal)
329
+ unless request.fetch("operation") == "complete_service"
330
+ input_record = request.fetch("operation") == "request_service" ? params : journal.service_request(params.fetch("request_id"))
331
+ # Replay bypasses the mutation callback, never input validation.
332
+ # Only body binding is checked here; current effect policy stays
333
+ # inside each fresh CAS callback.
334
+ service_policy!.input_binding(admitted, expected_digest: input_record.fetch("input_digest"),
335
+ expected_target: input_record.fetch("target"), operation: input_record.fetch("operation"))
336
+ end
337
+ # Admission also runs outside mutate: an exact mutation replay
338
+ # skips its callback and cannot bypass the authenticated ticket.
339
+ service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
340
+ retained = request.fetch("operation") == "request_service" && journal.service_request(params.fetch("request_id"))
341
+ if retained
342
+ service_executor!(peer, role, retained)
343
+ service_replay_binding!(retained, params, map)
344
+ end
345
+ if !retained && request.fetch("operation") != "request_service"
346
+ record = journal.service_request(params.fetch("request_id"))
347
+ service_executor!(peer, role, record)
348
+ service_ticket!(record, params, map, peer)
349
+ completion_binding!(record, params, admitted) if request.fetch("operation") == "complete_service"
350
+ elsif !retained
351
+ origin = active_origin(attempt_events(journal, params), params)
352
+ worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
353
+ service_receiver!(peer, role, map, params.fetch("service_id"))
354
+ end
355
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
356
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
357
+ parameters_digest: Atoms::EvidenceDigest.digest(params),
358
+ expected_generation: request.fetch("operation") == "complete_service" ? nil : params.fetch("expected_generation"),
359
+ generation_mode: request.fetch("operation") == "complete_service" ? :recorded_completion : :expected, with_replay: true) do |events, _commit, _generation|
360
+ if request.fetch("operation") == "request_service"
361
+ existing = journal.service_request(params.fetch("request_id"))
362
+ if existing
363
+ service_executor!(peer, role, existing)
364
+ service_replay_binding!(existing, params, map)
365
+ {data: service_projection(existing).merge("claim" => "retained")}
366
+ else
367
+ service_claim_plan(journal, events, params, map, peer, role, admitted)
368
+ end
369
+ elsif request.fetch("operation") == "begin_dispatch"
370
+ service_begin_plan(journal, events, params, map, peer, role, admitted)
371
+ else
372
+ service_completion_plan(journal, events, params, map, peer, role, admitted)
373
+ end
374
+ end
375
+ if request.fetch("operation") == "begin_dispatch" && result.fetch(:replayed)
376
+ result = result.merge(data: result.fetch(:data).merge("invocation" => "already_started"))
377
+ end
378
+ if request.fetch("operation") == "request_service"
379
+ # Acceptance metadata belongs to this exact mutation, while
380
+ # retained service state may have advanced since acceptance.
381
+ current = journal.service_request(params.fetch("request_id"))
382
+ data = service_projection(current).merge(result.fetch(:data).slice("generation", "journal_commit"))
383
+ data["claim"] = retained || result.fetch(:replayed) || result.dig(:data, "claim") == "retained" ? "retained" : "created"
384
+ result = result.merge(data: data)
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
392
+ result
393
+ end
394
+ end
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
+
420
+ def service_policy!
421
+ @service_policy || raise(ArgumentError, "full-service authority requires its installed policy owner")
422
+ end
423
+
424
+ def service_executor!(peer, role, record)
425
+ unless record && role == :executor && peer["uid"] == record.fetch("executor_uid")
426
+ raise AttemptErrors::UnauthorizedIdentity, "completion requires the recorded executor"
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
431
+ @kernel.live!(peer)
432
+ true
433
+ end
434
+
435
+ def service_receiver!(peer, role, map, service_id)
436
+ project = @deployment.project(map.fetch("project_id"))
437
+ receiver = project.fetch("service_receivers").fetch(service_id)
438
+ unless role == :executor && peer["uid"] == receiver.fetch("executor_uid") &&
439
+ project.fetch("service_executor_uids").include?(peer["uid"]) &&
440
+ !project.fetch("worker_uids").include?(peer["uid"])
441
+ raise AttemptErrors::UnauthorizedIdentity, "service requires its fixed independent receiver"
442
+ end
443
+ @kernel.live!(peer)
444
+ receiver
445
+ rescue KeyError
446
+ raise AttemptErrors::UnauthorizedIdentity, "fixed service receiver is unavailable"
447
+ end
448
+
449
+ def service_claim_plan(journal, events, params, map, peer, role, input_bytes)
450
+ origin = active_origin(events, params)
451
+ worker = params.fetch("worker_process_binding")
452
+ worker_or_launcher!(worker, :worker, map, origin)
453
+ 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"))
455
+ receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
456
+ binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
457
+ "authorization", "service_id").merge("project_id" => map.fetch("project_id"),
458
+ "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"))
462
+ prepared = service_policy!.prepare!(binding, input_bytes: input_bytes)
463
+ binding = prepared.fetch(:binding).merge("policy_digest" => prepared.fetch(:policy_digest))
464
+ unless binding["executor_uid"] == receiver.fetch("executor_uid")
465
+ raise AttemptErrors::UnauthorizedIdentity, "policy and fixed receiver disagree"
466
+ end
467
+ existing = journal.service_request(binding.fetch("request_id"))
468
+ if existing
469
+ unless existing.slice(*SERVICE_IDENTITY) == binding.slice(*SERVICE_IDENTITY)
470
+ raise AttemptErrors::Conflict, "service request immutable identity differs"
471
+ end
472
+ return {data: service_projection(existing).merge("claim" => "retained")}
473
+ end
474
+ ticket = SecureRandom.hex(16)
475
+ record = binding.merge("dispatch_ticket_id" => ticket,
476
+ "claim_binding" => Atoms::EvidenceDigest.digest(binding.merge("dispatch_ticket_id" => ticket)),
477
+ "claim_generation" => 1, "dispatch_phase" => "issued", "state" => "uncertain",
478
+ "claimed_at" => Time.now.utc.iso8601(9))
479
+ {data: service_projection(record).merge("claim" => "created"), service_inputs: {record.fetch("request_id") => input_bytes}, service_updates: [{request_id: record.fetch("request_id"),
480
+ expected: nil, replacement: record, event_type: "service_claim"}]}
481
+ end
482
+
483
+ def service_begin_plan(journal, events, params, map, peer, role, input_bytes)
484
+ record = journal.service_request(params.fetch("request_id"))
485
+ raise AttemptErrors::NotFound, "service request is unavailable" unless record
486
+ service_receiver!(peer, role, map, record.fetch("service_id"))
487
+ service_ticket!(record, params, map, peer)
488
+ # A repeated begin never grants a second invocation, including after
489
+ # lease expiry or terminality. It reports only retained ticket truth.
490
+ 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")
494
+ raise AttemptErrors::UnauthorizedIdentity, "dispatch launch origin differs"
495
+ end
496
+ current = exact_candidate!(candidate(events), params)
497
+ unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
498
+ raise AttemptErrors::Conflict, "dispatch candidate changed"
499
+ end
500
+ approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
501
+ service_policy!.prepare!(record, input_bytes: input_bytes)
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
509
+ {data: service_projection(replacement).merge("invocation" => "permitted"),
510
+ service_inputs: {record.fetch("request_id") => input_bytes},
511
+ service_updates: [{request_id: record.fetch("request_id"), expected: record,
512
+ replacement: replacement, event_type: "service_transition"}]}
513
+ end
514
+
515
+ def service_ticket!(record, params, map, peer)
516
+ unless record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
517
+ record["project_id"] == map["project_id"] && record["executor_uid"] == peer["uid"] &&
518
+ record["claim_binding"] == params["claim_binding"]
519
+ raise AttemptErrors::UnauthorizedIdentity, "dispatch ticket binding differs"
520
+ end
521
+ true
522
+ end
523
+
524
+ def service_replay_binding!(record, params, map)
525
+ fields = %w[request_id assignment_id attempt_id operation input_digest target authorization service_id mapping_id]
526
+ unless fields.all? { |field| record[field] == params[field] } && record["project_id"] == map["project_id"] &&
527
+ record["caller_uid"] == map["worker_uid"] && params.dig("worker_process_binding", "uid") == map["worker_uid"]
528
+ raise AttemptErrors::Conflict, "service replay immutable identity differs"
529
+ end
530
+ @kernel.live!(params.fetch("worker_process_binding"))
531
+ unless @kernel.descendant?(params.fetch("worker_process_binding"), record.fetch("worker_process_binding"))
532
+ raise AttemptErrors::UnauthorizedIdentity, "service replay requires recorded native worker ownership"
533
+ end
534
+ true
535
+ end
536
+
537
+ def service_completion_plan(journal, events, params, map, peer, role, admitted)
538
+ record = journal.service_request(params.fetch("request_id"))
539
+ service_executor!(peer, role, record)
540
+ service_ticket!(record, params, map, peer)
541
+ digest = completion_binding!(record, params, admitted)
542
+ if record["completion_digest"]
543
+ return {data: service_projection(record)}
544
+ end
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
552
+ owner = ServiceEvidence.new(journal: journal)
553
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
554
+ plan = canonical.import_plan(**owner.context(record), artifacts: admitted.fetch(:artifacts),
555
+ admitted_after_event_digest: events.last.fetch("digest"))
556
+ normalized = JSON.parse(JSON.generate(receipt)).merge("evidence" => plan.fetch(:references))
557
+ replacement = record.merge("state" => receipt.fetch("outcome"), "receipt" => normalized, "completion_digest" => digest)
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
564
+ plan.merge(data: service_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
565
+ expected: record, replacement: replacement, event_type: "service_transition"}])
566
+ end
567
+
568
+ def completion_binding!(record, params, admitted)
569
+ unless record["candidate_head"] == params["head"] && record["candidate_generation"] == params["candidate_generation"]
570
+ raise AttemptErrors::Conflict, "completion candidate binding differs"
571
+ end
572
+ digest = Atoms::EvidenceDigest.digest("receipt_sha256" => admitted.fetch(:receipt_sha256),
573
+ "artifacts" => admitted.fetch(:artifacts).map { |bytes| Digest::SHA256.hexdigest(bytes) })
574
+ if record["completion_digest"] && record["completion_digest"] != digest
575
+ raise AttemptErrors::Conflict, "service completion content differs"
576
+ end
577
+ receipt = admitted.fetch(:receipt)
578
+ unless receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
579
+ Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.all? { |field| receipt[field] == record[field] } &&
580
+ %w[succeeded failed].include?(receipt["outcome"]) &&
581
+ (receipt["outcome"] != "succeeded" || record["dispatch_phase"] == "dispatch_started" || (record["operation"] == "publish" && record["dispatch_phase"] == "issuing"))
582
+ raise AttemptErrors::ReceiptRejected, "completion must attest the exact dispatched request"
583
+ end
584
+ digest
585
+ end
586
+
587
+ def service_projection(record)
588
+ record.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation", "service_id", "target",
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
594
+ end
595
+ end
596
+ end
597
+ end
598
+ end