ace-assign 0.64.0 → 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 (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 +19 -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 +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -1,127 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ace/herdr/organisms/protected_inbox"
4
- require_relative "../molecules/execution_scope_lineage"
5
-
6
3
  module Ace
7
4
  module Assign
8
5
  module Authority
9
6
  class Endcap
10
- INBOX_REGISTRATION_FIELDS = %w[event_id attempt_id payload_sha256 receipt_key_sha256].freeze
11
- INBOX_PAYLOAD_FIELDS = %w[version event_id attempt_id inbox_context_id registration state
12
- claim_generation binding receipt_ref signature_ref].freeze
13
- INBOX_BINDING_FIELDS = %w[project_id assignment_id attempt_id mapping_id inbox_context_id event_id
14
- registration claim_generation native_binding scope_binding_event_id native_binding_event_id
15
- scope_native_binding receipt_key_sha256 receipt_sha256 signature_sha256 submitter_uid submitter_role].freeze
16
- INBOX_REF_FIELDS = %w[artifact_id ref sha256 bytes].freeze
17
-
18
- # Shared source-owned predicate for existing scope abort/reuse/finish.
19
- # The caller holds lifecycle exclusion; this is never a wire operation.
20
- def inbox_settlement_complete!(journal:, events:, params:, map:, commit:)
21
- protected_journal!(journal)
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, "inbox settlement requires exact canonical commit"
26
- end
27
- registrations = events.select { |event| event["type"] == "inbox_binding" }
28
- project = @deployment.project(map.fetch("project_id"))
29
- project.fetch("inbox_contexts", {}).each_key do |id|
30
- context = @deployment.inbox_context(params.fetch("mapping_id"), id)
31
- next unless context.fetch("native_mapping_id") == params.fetch("mapping_id")
32
- @deployment.verify_inbox_context!(params.fetch("mapping_id"), id)
33
- actual = Ace::Herdr::Organisms::Inbox.retained_events(deliveries_dir: context.fetch("deliveries_dir"), attempt: params.fetch("attempt_id"))
34
- registered = registrations.filter_map { |event| event.dig("payload", "event_id") if event.dig("payload", "inbox_context_id") == id }
35
- unless actual.sort == registered.sort
36
- raise AttemptErrors::EvidenceUnavailable, "registered or unattributable inbox retention differs"
37
- end
38
- end
39
- registrations.each do |event|
40
- selected = params.merge("event_id" => event.dig("payload", "event_id"), "inbox_context_id" => event.dig("payload", "inbox_context_id"))
41
- box, registration, lineage = inbox_environment(events, selected, map)
42
- proof = verified_inbox_record(journal, events, selected, map, commit, box: box, registration: registration, lineage: lineage)
43
- unless proof && proof["state"] == "completed"
44
- raise AttemptErrors::EvidenceUnavailable, "current inbox settlement is incomplete"
45
- end
46
- end
47
- true
48
- rescue KeyError, TypeError, ArgumentError, Ace::Herdr::Error, Ace::Runtime::RuntimeUnavailableError
49
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox settlement is unverifiable"
50
- end
7
+ INBOX_REGISTRATION_FIELDS = %w[event_id attempt_id payload_sha256].freeze
51
8
 
52
9
  private
53
10
 
54
- def inbox_request(request)
55
- params = request.fetch("params")
56
- unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("reconcile_inbox").sort
57
- raise ArgumentError, "invalid inbox parameters"
58
- end
59
- %w[mapping_id assignment_id attempt_id event_id inbox_context_id].each { |key| result_id!(params[key]) }
60
- result_id!(request.fetch("mutation_id"))
61
- registration = params.fetch("expected_registration")
62
- unless inbox_registration?(registration) && registration.values_at("event_id", "attempt_id") ==
63
- params.values_at("event_id", "attempt_id") &&
64
- params["expected_generation"].is_a?(Integer) && params["expected_generation"] >= 0 &&
65
- %w[receipt_sha256 signature_sha256].all? { |key| params[key].is_a?(String) && DIGEST.match?(params[key]) }
66
- raise ArgumentError, "invalid inbox registration or digest"
67
- end
68
- map = @deployment.mapping(params.fetch("mapping_id"))
69
- raise AttemptErrors::UnauthorizedIdentity, "project mapping differs" unless map.fetch("project_id") == request.fetch("project_id")
70
- [params, map]
71
- end
72
-
73
11
  def inbox_registration?(value)
74
12
  value.is_a?(Hash) && value.keys.sort == INBOX_REGISTRATION_FIELDS.sort &&
75
13
  %w[event_id attempt_id].all? { |key| value[key].is_a?(String) && Molecules::JournalMutation::ID.match?(value[key]) } &&
76
- %w[payload_sha256 receipt_key_sha256].all? { |key| value[key].is_a?(String) && DIGEST.match?(value[key]) }
14
+ %w[payload_sha256].all? { |key| value[key].is_a?(String) && DIGEST.match?(value[key]) }
77
15
  end
78
16
 
79
- def inbox_selection(events, params, map, peer, role)
80
- origin = retained_origin(events, params)
81
- context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
82
- @kernel.live!(peer)
83
- service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
84
- allowed = (role == :supervisor && context.fetch("supervisor_uids").include?(peer["uid"])) ||
85
- (role == :launcher && peer["uid"] == map.fetch("launcher_uid") && @kernel.same?(peer, origin.fetch("launcher_identity")))
86
- raise AttemptErrors::UnauthorizedIdentity, "inbox requires exact launcher or mapped supervisor" unless allowed
87
- inbox_environment(events, params, map)
88
- end
89
-
90
- def inbox_environment(events, params, map)
91
- unless Models::EvidenceEvent.chain_valid?(events) && events.all? { |event| event["attempt_id"] == params.fetch("attempt_id") }
92
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox attempt chain differs"
93
- end
94
- context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
95
- @deployment.verify_inbox_context!(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
96
- bindings = events.select { |event| event["type"] == "inbox_binding" && event.dig("payload", "event_id") == params["event_id"] }
97
- raise AttemptErrors::NotFound, "inbox registration missing" if bindings.empty?
98
- registered = bindings.one? && bindings.first.fetch("payload")
99
- unless registered.is_a?(Hash) && registered.keys.sort == %w[event_id attempt_id inbox_context_id registration].sort &&
100
- registered.values_at("event_id", "attempt_id", "inbox_context_id") == params.values_at("event_id", "attempt_id", "inbox_context_id") &&
101
- inbox_registration?(registered["registration"])
102
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox context or registration differs"
103
- end
104
- if params.key?("expected_registration") && registered.fetch("registration") != params.fetch("expected_registration")
105
- raise AttemptErrors::Conflict, "expected inbox registration differs"
106
- end
107
- lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
108
- assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
109
- mapping_id: context.fetch("native_mapping_id"))
110
- raise AttemptErrors::EvidenceUnavailable, "canonical original native stage missing" unless lineage.native_event
111
- native_map = @deployment.mapping(context.fetch("native_mapping_id"))
112
- box = Ace::Herdr::Organisms::ProtectedInbox.build(context: context, mapping: native_map,
113
- native: lineage.native_event.fetch("payload"), kernel: @kernel)
114
- current = box.retained_status(event: params.fetch("event_id"))
115
- unless current.slice(*INBOX_REGISTRATION_FIELDS) == registered.fetch("registration")
116
- raise AttemptErrors::EvidenceUnavailable, "current inbox differs from canonical registration"
117
- end
118
- inbox_native_lineage!(current, lineage)
119
- [box, registered.fetch("registration"), lineage]
120
- rescue Ace::Herdr::Error, Ace::Runtime::RuntimeUnavailableError, KeyError, TypeError
121
- raise AttemptErrors::EvidenceUnavailable, "installed inbox or native lineage is unverifiable"
122
- end
123
-
124
- def inbox_native_lineage!(record, lineage, receipt: nil)
17
+ def inbox_native_lineage!(record, lineage)
125
18
  workspace = lineage.native_event.fetch("payload").fetch("workspace_id")
126
19
  original = record.fetch("origin_target")
127
20
  binding = record.fetch("binding")
@@ -135,179 +28,9 @@ module Ace
135
28
  raise AttemptErrors::EvidenceUnavailable, "inbox original native child differs"
136
29
  end
137
30
  end
138
- if receipt
139
- signed_binding = receipt["binding"]
140
- replacement = receipt["replacement_target"]
141
- unless signed_binding.is_a?(Hash) && signed_binding["session"] == workspace &&
142
- (!receipt.key?("replacement_target") || (replacement.is_a?(Hash) && replacement["session"] == workspace))
143
- raise AttemptErrors::EvidenceUnavailable, "signed inbox native workspace differs"
144
- end
145
- end
146
- true
147
- end
148
-
149
- def authorize_inbox_transfer!(request:, peer:, role:)
150
- params, map = inbox_request(request)
151
- @launch.with_assignment(params: params, map: map) do |journal, _|
152
- protected_journal!(journal)
153
- inbox_selection(attempt_events(journal, params), params, map, peer, role)
154
- end
155
31
  true
156
32
  end
157
33
 
158
- def dispatch_inbox(request:, peer:, role:, transfer:)
159
- params, map = inbox_request(request)
160
- authorize_inbox_transfer!(request: request, peer: peer, role: role)
161
- unless transfer && transfer.count == 2
162
- raise ArgumentError, "inbox requires receipt and signature"
163
- end
164
- bytes, signature = [transfer.bytes(index: 0), transfer.bytes(index: 1)]
165
- unless [bytes, signature].all? { |part| part.is_a?(String) && part.bytesize.between?(1, 16 * 1024) } &&
166
- Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256") &&
167
- Digest::SHA256.hexdigest(signature) == params.fetch("signature_sha256")
168
- raise ArgumentError, "inbox proof bytes differ"
169
- end
170
- receipt = JSON.parse(bytes.dup.force_encoding(Encoding::UTF_8))
171
- @launch.with_assignment(params: params, map: map) do |journal, _|
172
- protected_journal!(journal)
173
- commit = journal.ref_value
174
- events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
175
- box, registration, lineage = inbox_selection(events, params, map, peer, role)
176
- replay = journal.mutation_result(request.fetch("mutation_id"))
177
- if replay && replay["operation"] == "reconcile_inbox"
178
- retained = verified_inbox(journal, events, params, map, peer, role, commit)
179
- unless retained && inbox_projection(retained) == replay.fetch("data").slice(*inbox_projection(retained).keys)
180
- raise AttemptErrors::EvidenceUnavailable, "retained inbox reply differs"
181
- end
182
- end
183
- result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
184
- mutation_id: request.fetch("mutation_id"), operation: "reconcile_inbox", parameters_digest: Atoms::EvidenceDigest.digest(params),
185
- expected_generation: params.fetch("expected_generation"), with_replay: true) do |fresh, fresh_commit, _generation|
186
- box, registration, lineage = inbox_selection(fresh, params, map, peer, role)
187
- retained = verified_inbox(journal, fresh, params, map, peer, role, fresh_commit)
188
- if retained
189
- unless retained.dig("binding", "receipt_sha256") == params.fetch("receipt_sha256") &&
190
- retained.dig("binding", "signature_sha256") == params.fetch("signature_sha256")
191
- raise AttemptErrors::Conflict, "canonical inbox proof already differs"
192
- end
193
- next({data: inbox_projection(retained)})
194
- end
195
- inbox_native_lineage!(box.retained_status(event: params.fetch("event_id")), lineage, receipt: receipt)
196
- accepted = box.reconcile(event: params.fetch("event_id"), receipt: receipt, signed_bytes: bytes,
197
- signature: signature, expected_registration: registration)
198
- raise AttemptErrors::EvidenceUnavailable, "signed inbox proof refused" if accepted["reconciliation_refusal"]
199
- binding = inbox_binding(params, map, peer, role, registration, lineage, accepted)
200
- plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**inbox_context(binding),
201
- admitted_after_event_digest: fresh.last.fetch("digest"), artifacts: [bytes, signature])
202
- refs = plan.fetch(:references).each_with_index.map do |ref, index|
203
- ref.merge("artifact_id" => ref.fetch("ref").delete_prefix("evidence/imports/"), "bytes" => [bytes, signature][index].bytesize)
204
- end
205
- payload = {"version" => 1, "event_id" => params.fetch("event_id"), "attempt_id" => params.fetch("attempt_id"),
206
- "inbox_context_id" => params.fetch("inbox_context_id"), "registration" => registration,
207
- "state" => accepted.fetch("state"), "claim_generation" => accepted.fetch("claim_generation"),
208
- "binding" => binding, "receipt_ref" => refs[0], "signature_ref" => refs[1]}
209
- {data: inbox_projection(payload), blobs: plan.fetch(:blobs), events: plan.fetch(:events) +
210
- [{type: "inbox_reconciliation", payload: payload}]}
211
- end
212
- final_commit = journal.ref_value
213
- final_events = journal.read_events(params.fetch("assignment_id"), commit: final_commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
214
- retained = verified_inbox(journal, final_events, params, map, peer, role, final_commit)
215
- unless retained && inbox_projection(retained) == result.fetch(:data).slice(*inbox_projection(retained).keys)
216
- raise AttemptErrors::EvidenceUnavailable, "accepted inbox provenance differs"
217
- end
218
- result
219
- end
220
- rescue JSON::ParserError, EncodingError, Ace::Herdr::Error
221
- raise AttemptErrors::EvidenceUnavailable, "inbox proof is unverifiable"
222
- end
223
-
224
- def inbox_binding(params, map, peer, role, registration, lineage, accepted)
225
- {"project_id" => map.fetch("project_id"), "assignment_id" => params.fetch("assignment_id"),
226
- "attempt_id" => params.fetch("attempt_id"), "mapping_id" => params.fetch("mapping_id"),
227
- "inbox_context_id" => params.fetch("inbox_context_id"), "event_id" => params.fetch("event_id"),
228
- "registration" => registration, "claim_generation" => accepted.fetch("claim_generation"),
229
- "native_binding" => accepted.fetch("binding"), "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
230
- "native_binding_event_id" => lineage.native_event.fetch("digest"), "scope_native_binding" => lineage.native_event.fetch("payload"),
231
- "receipt_key_sha256" => registration.fetch("receipt_key_sha256"), "receipt_sha256" => params.fetch("receipt_sha256"),
232
- "signature_sha256" => params.fetch("signature_sha256"), "submitter_uid" => peer.fetch("uid"), "submitter_role" => role.to_s}
233
- end
234
-
235
- def inbox_context(binding)
236
- {kind: "inbox", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
237
- attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("submitter_uid"), binding: binding,
238
- request_id_or_event_id: binding.fetch("event_id"), generation: binding.fetch("claim_generation")}
239
- end
240
-
241
- def inbox_projection(payload)
242
- payload.slice("event_id", "attempt_id", "inbox_context_id", "registration", "state", "receipt_ref", "signature_ref")
243
- end
244
-
245
- def verified_inbox(journal, events, params, map, peer, role, commit)
246
- box, registration, lineage = inbox_selection(events, params, map, peer, role)
247
- verified_inbox_record(journal, events, params, map, commit, box: box, registration: registration, lineage: lineage)
248
- end
249
-
250
- def verified_inbox_record(journal, events, params, map, commit, box:, registration:, lineage:)
251
- records = events.select { |event| event["type"] == "inbox_reconciliation" && event.dig("payload", "event_id") == params.fetch("event_id") }
252
- replies = events.select { |event| event["type"] == "authority_mutation" &&
253
- event.dig("payload", "operation") == "reconcile_inbox" && event.dig("payload", "data", "event_id") == params.fetch("event_id") }
254
- unless replies.all? { |reply| records.any? { |record| inbox_projection(record.fetch("payload")) ==
255
- reply.fetch("payload").fetch("data").slice(*inbox_projection(record.fetch("payload")).keys) } }
256
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox private provenance missing"
257
- end
258
- observed = box.retained_status(event: params.fetch("event_id"))
259
- current_claim = observed.fetch("claim_generation")
260
- unless records.all? { |record| record.fetch("payload").keys.sort == INBOX_PAYLOAD_FIELDS.sort &&
261
- record.dig("payload", "claim_generation").is_a?(Integer) && record.dig("payload", "claim_generation").between?(0, current_claim) } &&
262
- records.map { |record| record.dig("payload", "claim_generation") }.uniq.length == records.length
263
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox claim history differs"
264
- end
265
- current = records.find { |record| record.dig("payload", "claim_generation") == current_claim }
266
- return nil unless current
267
- payload = current.fetch("payload")
268
- binding = payload.fetch("binding")
269
- unless payload.keys.sort == INBOX_PAYLOAD_FIELDS.sort && payload["version"] == 1 && binding.keys.sort == INBOX_BINDING_FIELDS.sort &&
270
- %w[receipt_sha256 signature_sha256].all? { |key| binding[key].is_a?(String) && DIGEST.match?(binding[key]) } &&
271
- binding["submitter_uid"].is_a?(Integer) && binding["submitter_uid"] > 0 && %w[launcher supervisor].include?(binding["submitter_role"]) &&
272
- payload.values_at("event_id", "attempt_id", "inbox_context_id") == params.values_at("event_id", "attempt_id", "inbox_context_id") &&
273
- payload["registration"] == registration && payload["claim_generation"] == binding["claim_generation"]
274
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox fields differ"
275
- end
276
- retained_peer = {"uid" => binding.fetch("submitter_uid")}
277
- expected = inbox_binding(params.merge("receipt_sha256" => binding.fetch("receipt_sha256"),
278
- "signature_sha256" => binding.fetch("signature_sha256")), map, retained_peer, binding.fetch("submitter_role").to_sym,
279
- registration, lineage, observed)
280
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox native/key context differs" unless binding == expected
281
- canonical = Molecules::CanonicalEvidence.new(journal: journal)
282
- raw = %w[receipt_ref signature_ref].map do |key|
283
- ref = payload.fetch(key)
284
- unless ref.is_a?(Hash) && ref.keys.sort == INBOX_REF_FIELDS.sort &&
285
- ref["ref"] == "evidence/imports/#{ref['artifact_id']}" && ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 16 * 1024)
286
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox reference differs"
287
- end
288
- bytes = canonical.read(ref.slice("ref", "sha256"), **inbox_context(binding), commit: commit)
289
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox byte count differs" unless bytes.bytesize == ref["bytes"]
290
- bytes
291
- end
292
- unless raw.map { |bytes| Digest::SHA256.hexdigest(bytes) } == binding.values_at("receipt_sha256", "signature_sha256")
293
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox raw digests differ"
294
- end
295
- receipt = JSON.parse(raw.first)
296
- inbox_native_lineage!(observed, lineage, receipt: receipt)
297
- verified = box.verify_reconciliation(event: params.fetch("event_id"), receipt: receipt,
298
- signed_bytes: raw.first, signature: raw.last, expected_registration: registration)
299
- signed_state = receipt["outcome"] == "consumed" ? "completed" : "queued"
300
- if verified["reconciliation_refusal"] || verified["state"] != payload["state"] || payload["state"] != signed_state
301
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox signature or settlement differs"
302
- end
303
- unless events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reconcile_inbox" &&
304
- event.fetch("payload").fetch("data").slice(*inbox_projection(payload).keys) == inbox_projection(payload) }
305
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox owner reply missing"
306
- end
307
- payload
308
- rescue KeyError, TypeError, ArgumentError, JSON::ParserError, Ace::Herdr::Error
309
- raise AttemptErrors::EvidenceUnavailable, "canonical inbox evidence is unverifiable"
310
- end
311
34
  end
312
35
  end
313
36
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+
8
+ def prepared_fetch?(request)
9
+ request["operation"] == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(request.dig("params", "kind"))
10
+ end
11
+
12
+ def dispatch_prepared_fetch(request:, peer:, role:, body: true)
13
+ params = request.fetch("params")
14
+ unless request.fetch("mutation_id").nil? && params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("evidence_fetch").sort &&
15
+ [%w[prepared_work original_prepared_work prepared_bundle],
16
+ %w[campaign_candidate original_campaign_candidate candidate_bundle]].include?(params.values_at("kind", "purpose_id", "artifact_id"))
17
+ raise ArgumentError, "prepared fetch envelope differs"
18
+ end
19
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
20
+ map = @deployment.mapping(params.fetch("mapping_id"))
21
+ raise AttemptErrors::UnauthorizedIdentity, "prepared project differs" unless map.fetch("project_id") == request.fetch("project_id")
22
+ raise AttemptErrors::UnauthorizedIdentity, "prepared fetch requires original worker" unless role == :worker
23
+ @kernel.live!(peer)
24
+ service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
25
+ journal = @launch.send(:journal_for, map)
26
+ protected_journal!(journal)
27
+ commit = journal.ref_value
28
+ selected = @launch.original_prepared_registration!(journal: journal, params: params, commit: commit) do |original_map, state|
29
+ unless peer.values_at("uid", "gid", "groups") == original_map.values_at("worker_uid", "worker_gid", "worker_groups")
30
+ raise AttemptErrors::UnauthorizedIdentity, "prepared worker principal differs"
31
+ end
32
+ binding = state.fetch("process_binding")
33
+ @kernel.live!(binding.fetch("process_identity"))
34
+ @kernel.live!(binding.fetch("native_origin").fetch("server_identity"))
35
+ worker_or_launcher!(peer, role, original_map, state)
36
+ end
37
+ original_map = selected.fetch(:map)
38
+ registration = selected.fetch(:registration)
39
+ if params.fetch("kind") == "campaign_candidate"
40
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: original_map)
41
+ execution = definition.campaign_execution
42
+ raise AttemptErrors::UnauthorizedIdentity, "original worker is not a campaign child" unless execution
43
+ descriptor, candidate_bytes = campaign_prepared_candidate!(journal: journal, commit: commit, execution: execution, map: original_map)
44
+ return true unless body
45
+ unless journal.ref_value == commit
46
+ raise AttemptErrors::Conflict, "campaign original candidate selection advanced"
47
+ end
48
+ descriptor = descriptor.merge(params.slice("mapping_id", "assignment_id", "attempt_id"),
49
+ "project_id" => original_map.fetch("project_id"),
50
+ "original_worker_identity" => selected.fetch(:state).fetch("process_binding").fetch("process_identity"),
51
+ "original_worker_scratch_root" => selected.fetch(:original_worker_scratch_root),
52
+ "original_binding_digest" => selected.fetch(:original_binding_digest))
53
+ return {data: {"descriptor" => descriptor, "generation" => journal.authority_generation(selected.fetch(:events)),
54
+ "journal_commit" => commit}, replayed: false, transfer_parts: [candidate_bytes]}
55
+ end
56
+ return true unless body
57
+ reference = registration.fetch("prepared_work")
58
+ bytes = journal.bounded_blob(registration.fetch("prepared_bundle_ref"), commit: selected.fetch(:registration_commit), max_bytes: CandidateTransfer::MAX_BYTES)
59
+ unless bytes.is_a?(String) && bytes.bytesize == registration.fetch("prepared_bundle_bytes") &&
60
+ bytes.bytesize.between?(1, CandidateTransfer::MAX_BYTES) && Digest::SHA256.hexdigest(bytes) == registration.fetch("prepared_bundle_sha256")
61
+ raise AttemptErrors::EvidenceUnavailable, "original prepared bundle differs"
62
+ end
63
+ descriptor = reference.slice("task_id", "scope", "selection_sha256", "prepared_head", "prepared_tree", "manifest_bytes", "manifest_sha256").merge(
64
+ params.slice("mapping_id", "assignment_id", "attempt_id"), "version" => 1, "kind" => "prepared_work", "purpose" => "original_prepared_work", "artifact" => "prepared_bundle",
65
+ "project_id" => original_map.fetch("project_id"), "definition_digest" => registration.fetch("definition_digest"),
66
+ "task_context_entry" => registration.fetch("task_context_entry"),
67
+ "original_worker_identity" => selected.fetch(:state).fetch("process_binding").fetch("process_identity"),
68
+ "original_worker_scratch_root" => selected.fetch(:original_worker_scratch_root),
69
+ "workspace_exclusion" => selected.fetch(:workspace_exclusion),
70
+ "registration_generation" => registration.fetch("generation"), "registration_commit" => selected.fetch(:registration_commit),
71
+ "original_binding_digest" => selected.fetch(:original_binding_digest), "ref" => registration.fetch("prepared_bundle_ref"), "bytes" => bytes.bytesize,
72
+ "sha256" => registration.fetch("prepared_bundle_sha256"))
73
+ {data: {"descriptor" => descriptor, "generation" => journal.authority_generation(selected.fetch(:events)), "journal_commit" => commit}, replayed: false, transfer_parts: [bytes]}
74
+ rescue KeyError, TypeError
75
+ raise AttemptErrors::EvidenceUnavailable, "original prepared input is unavailable"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ # The same held assignment owner authenticates the receiver and current
8
+ # candidate both before transfer and inside each canonical CAS attempt.
9
+ # Ordinary HITL uses this same original launch and project journal;
10
+ # no local assignment cache or caller-selected coordinator participates.
11
+ def with_worker_hitl!(assignment:, attempt:, project:, requester_uid:, peer: nil)
12
+ selected = @deployment.data.fetch("launch_mappings").select do |_id, map|
13
+ map.fetch("project_id") == project && map.fetch("worker_uid") == requester_uid
14
+ end
15
+ raise AttemptErrors::UnauthorizedIdentity, "HITL worker mapping is missing or ambiguous" unless selected.size == 1
16
+ mapping_id, map = selected.first
17
+ params = {"mapping_id" => mapping_id, "assignment_id" => assignment, "attempt_id" => attempt}
18
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
19
+ protected_journal!(journal)
20
+ events = attempt_events(journal, params)
21
+ origin = active_origin(events, params)
22
+ @launch.scope_open_for_effect!(events: events, params: params, map: map)
23
+ if peer
24
+ unless peer.values_at("uid", "gid", "groups") == map.values_at("worker_uid", "worker_gid", "worker_groups")
25
+ raise AttemptErrors::UnauthorizedIdentity, "HITL requester credentials differ"
26
+ end
27
+ @kernel.live!(peer)
28
+ worker_or_launcher!(peer, :worker, map, origin)
29
+ end
30
+ reverse = origin.fetch("process_binding").slice("session", "pane")
31
+ unless reverse.values.all? { |value| value.is_a?(String) && !value.empty? } && reverse.size == 2
32
+ raise AttemptErrors::EvidenceUnavailable, "HITL native reverse is unavailable"
33
+ end
34
+ yield settlement_immutable(reverse.merge("schema" => "ace.hitl.ref/v1"))
35
+ end
36
+ end
37
+
38
+ def publication_executor_identity?(project:, uid:)
39
+ @deployment.project(project).fetch("service_executor_uids").include?(uid)
40
+ end
41
+
42
+ def with_publication_hitl!(binding:, project:, peer:)
43
+ params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
44
+ map = @deployment.mapping(params.fetch("mapping_id"))
45
+ raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
46
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
47
+ protected_journal!(journal)
48
+ record = publication_record!(journal, params, map, peer, :executor)
49
+ unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
50
+ raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
51
+ end
52
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
53
+ selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
54
+ "challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
55
+ yield selection
56
+ end
57
+ end
58
+
59
+ def authorize_publication_update!(journal:, existing:, replacement:, pending:)
60
+ operation = pending.fetch(:operation)
61
+ allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
62
+ unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
63
+ raise AttemptErrors::InvalidState, "publication transition changed original binding"
64
+ end
65
+ owner = ServiceEvidence.new(journal: journal)
66
+ if operation == "publication_challenge"
67
+ unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
68
+ replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
69
+ raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
70
+ end
71
+ need = owner.publication_challenge!(replacement, pending: pending)
72
+ unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
73
+ raise AttemptErrors::InvalidState, "publication need predecessor differs"
74
+ end
75
+ elsif operation == "publication_continue"
76
+ unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
77
+ (allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
78
+ raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
79
+ end
80
+ owner.publication_challenge!(replacement, pending: pending)
81
+ events = pending.fetch(:current_events) + pending.fetch(:pending_events)
82
+ event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
83
+ unless event && event["type"] == "service_publication_issuing" &&
84
+ %w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
85
+ raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
86
+ end
87
+ else
88
+ raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
89
+ end
90
+ true
91
+ end
92
+
93
+ def publication_record!(journal, params, map, peer, role, events: nil)
94
+ record = journal.service_request(params.fetch("request_id"))
95
+ unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
96
+ %w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
97
+ %w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
98
+ record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
99
+ raise AttemptErrors::Conflict, "publication original tuple or phase differs"
100
+ end
101
+ service_executor!(peer, role, record)
102
+ service_ticket!(record, params, map, peer)
103
+ events ||= attempt_events(journal, params)
104
+ current = exact_candidate!(candidate(events), params)
105
+ origin = active_origin(events, params)
106
+ worker_or_launcher!(record.fetch("worker_process_binding"), :worker, map, origin)
107
+ approved_review!(journal, events, params, map, current)
108
+ @launch.scope_open_for_effect!(events: events, params: params, map: map)
109
+ service_policy!.authorize!(record)
110
+ ServiceEvidence.new(journal: journal).context(record)
111
+ record
112
+ end
113
+
114
+ def authorize_publication_transfer!(request, params, map, peer, role)
115
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
116
+ protected_journal!(journal)
117
+ publication_record!(journal, params, map, peer, role)
118
+ end
119
+ true
120
+ end
121
+
122
+ def dispatch_publication(request, params, map, peer, role, transfer)
123
+ authorize_publication_transfer!(request, params, map, peer, role)
124
+ challenge = request.fetch("operation") == "publication_challenge"
125
+ bytes = nil
126
+ if challenge
127
+ unless transfer && transfer.count == 1
128
+ raise ArgumentError, "publication need requires one bounded artifact"
129
+ end
130
+ bytes = transfer.bytes.dup.freeze
131
+ unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
132
+ raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
133
+ end
134
+ elsif transfer
135
+ raise ArgumentError, "publication continuation forbids upload"
136
+ end
137
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
138
+ publication_record!(journal, params, map, peer, role)
139
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
140
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
141
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
142
+ with_replay: true) do |events, _commit, generation|
143
+ record = publication_record!(journal, params, map, peer, role, events: events)
144
+ if challenge
145
+ publication_challenge_plan(journal, events, record, params, bytes, generation)
146
+ else
147
+ publication_continue_plan(journal, events, record, params, generation)
148
+ end
149
+ end
150
+ if !challenge && result.fetch(:replayed)
151
+ result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
152
+ end
153
+ result
154
+ end
155
+ end
156
+
157
+ def publication_challenge_plan(journal, events, record, params, bytes, generation)
158
+ owner = ServiceEvidence.new(journal: journal)
159
+ digest = Digest::SHA256.hexdigest(bytes)
160
+ if record["dispatch_phase"] == "otp_pending"
161
+ owner.publication_challenge!(record)
162
+ raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
163
+ return {data: service_projection(record).merge("challenge" => "retained")}
164
+ end
165
+ unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
166
+ raise AttemptErrors::InvalidState, "publication has no rejected push"
167
+ end
168
+ previous = record["publication_challenge_digest"]
169
+ owner.publication_challenge!(record) if previous
170
+ owner.publication_need!(bytes, record, previous: previous)
171
+ payload = record.slice("request_id", "input_digest", "claim_binding").merge(
172
+ "challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
173
+ time = Time.now.utc
174
+ event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
175
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
176
+ plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
177
+ artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
178
+ replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
179
+ "publication_challenge_ref" => plan.fetch(:references).fetch(0),
180
+ "publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
181
+ plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
182
+ data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
183
+ expected: record, replacement: replacement, event_type: "service_transition"}])
184
+ end
185
+
186
+ def publication_continue_plan(journal, events, record, params, generation)
187
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
188
+ unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
189
+ raise AttemptErrors::Conflict, "publication continuation challenge differs"
190
+ end
191
+ if record["dispatch_phase"] == "issuing"
192
+ return {data: service_projection(record).merge("invocation" => "already_issued")}
193
+ end
194
+ raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
195
+ payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
196
+ time = Time.now.utc
197
+ event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
198
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
199
+ replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
200
+ {events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
201
+ data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
202
+ expected: record, replacement: replacement, event_type: "service_transition"}]}
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end