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,266 @@
1
+ # frozen_string_literal: true
2
+ require_relative "campaign_round_transfer"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ class Endcap
8
+ private
9
+
10
+ def campaign_round_request!(request)
11
+ params = request.fetch("params")
12
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("campaign_record_round").sort &&
13
+ params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
14
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"].positive? &&
15
+ params["input_sha256"].is_a?(String) && params["input_sha256"].match?(DIGEST)
16
+ raise ArgumentError, "campaign round request differs"
17
+ end
18
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
19
+ result_id!(request.fetch("mutation_id"))
20
+ map = @deployment.mapping(params.fetch("mapping_id"))
21
+ raise AttemptErrors::UnauthorizedIdentity, "campaign project differs" unless map["project_id"] == request.fetch("project_id")
22
+ [params, map]
23
+ end
24
+
25
+ def authorize_campaign_round!(request:, peer:, role:)
26
+ params, map = campaign_round_request!(request)
27
+ @launch.with_assignment(params: params, map: map) do |journal, registration|
28
+ protected_journal!(journal)
29
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
30
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
31
+ events = @launch.preview_attempt_events!(journal: journal, commit: journal.ref_value, params: params, map: original_map)
32
+ finish_identity!(events, params, original_map, peer, role)
33
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: journal.ref_value, params: params, map: original_map)
34
+ CampaignExecution.validate_parent!(definition.review_campaign)
35
+ end
36
+ true
37
+ end
38
+
39
+ def with_campaign_receipt!(journal:, commit:, events:, params:, map:, receipt:, result:, historical:)
40
+ unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/v1" &&
41
+ receipt["campaign"].is_a?(Hash) && receipt["campaign"].keys.sort == %w[id result] &&
42
+ receipt["operation"] == "review" && receipt["verdict"] == "succeeded"
43
+ raise AttemptErrors::ReceiptRejected, "parent campaign receipt differs"
44
+ end
45
+ context = @launch.send(:control_registration_context!, params, map, journal, commit: commit)
46
+ original_map = context.fetch(:map)
47
+ descriptor = context.fetch(:descriptor)
48
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
49
+ commit: commit, params: params, map: original_map).review_campaign)
50
+ unless receipt.dig("campaign", "id") == selected.fetch("campaign_id") && result["campaign_id"] == selected.fetch("campaign_id")
51
+ raise AttemptErrors::ReceiptRejected, "parent campaign identity differs"
52
+ end
53
+ origin = retained_origin(events, params)
54
+ parent = params.merge("scope" => origin.fetch("scope"), "base" => origin.fetch("base_head"), "head" => receipt.fetch("head"))
55
+ current = exact_candidate!(retained_candidate(events, params.fetch("candidate_generation")), parent)
56
+ project = descriptor.project(original_map.fetch("project_id"))
57
+ root = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
58
+ FileUtils.mkdir_p(root, mode: 0o700)
59
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
60
+ CandidateTransfer.new(root: root).with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"),
61
+ size: current.fetch("bytes"), head: current.fetch("head"), tree: current.fetch("tree"), base: parent.fetch("base")) do |candidate_reader|
62
+ manager = @launch.send(:campaign_manager_for!, context,
63
+ **campaign_execution_dependencies!(journal: journal, commit: commit, parent_params: parent, parent_map: original_map),
64
+ candidate_reader: candidate_reader)
65
+ binding = selected.slice("subject", "contract_identity", "policy").transform_keys(&:to_sym).merge(
66
+ result: result, head: receipt.fetch("head"), base: parent.fetch("base"),
67
+ producer: receipt.fetch("producer").fetch("actor"), reviewer: receipt.fetch("review").fetch("reviewer").fetch("actor"))
68
+ if historical
69
+ manager.verify_retained_result!(**binding)
70
+ yield
71
+ else
72
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
73
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
74
+ manager.with_verified_result!(**binding, consumer_profiles: profiles) do |_verified, snapshot|
75
+ unless result == snapshot
76
+ raise AttemptErrors::ReceiptRejected, "exported campaign result bytes differ from verified owner"
77
+ end
78
+ held.verify_unchanged!
79
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
80
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
81
+ raise AttemptErrors::Conflict, "campaign consumer policy descriptor advanced"
82
+ end
83
+ value = yield
84
+ held.verify_unchanged!
85
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
86
+ raise AttemptErrors::Conflict, "campaign consumer policy descriptor advanced"
87
+ end
88
+ value
89
+ end
90
+ end
91
+ end
92
+ end
93
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError, ArgumentError
94
+ raise AttemptErrors::ReceiptRejected, "canonical parent campaign evidence is unavailable"
95
+ end
96
+
97
+ def with_campaign_submission!(journal:, commit:, events:, params:, map:, admitted:, replay:)
98
+ receipt = admitted.fetch(:receipt)
99
+ return yield unless receipt["campaign"]
100
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
101
+ unless definition.review_campaign
102
+ raise AttemptErrors::EvidenceUnavailable, "protected campaigns require their canonical owner"
103
+ end
104
+ reference = receipt.fetch("campaign").fetch("result")
105
+ index = receipt.fetch("artifacts").index(reference)
106
+ raise AttemptErrors::ReceiptRejected, "campaign result is not an uploaded artifact" unless index
107
+ result = JSON.parse(admitted.fetch(:artifacts).fetch(index), create_additions: false,
108
+ max_nesting: 64, allow_duplicate_key: false, allow_comments: false)
109
+ with_campaign_receipt!(journal: journal, commit: commit, events: events, params: params, map: map,
110
+ receipt: receipt, result: result, historical: !replay.nil?) { yield }
111
+ rescue JSON::ParserError
112
+ raise AttemptErrors::ReceiptRejected, "campaign result JSON is malformed"
113
+ end
114
+
115
+ # Service callers already retain with_assignment's original control SH
116
+ # and authority mutex through CAS. Round writers take the same keys EX
117
+ # before R1, so this read cannot invert with an admitted round writer.
118
+ def campaign_service_checks!(journal:, commit:, events:, params:, map:, current:)
119
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
120
+ return true unless definition.review_campaign
121
+ parent = params.merge("head" => current.fetch("head"), "candidate_generation" => current.fetch("candidate_generation"))
122
+ result = verified_result(journal, events, parent, map, retained_origin(events, parent), current, commit)
123
+ unless result&.dig("receipt", "campaign") && result.dig("receipt", "verdict") == "succeeded"
124
+ raise AttemptErrors::ReceiptRejected, "ready or merge requires its canonical parent campaign result and executed checks"
125
+ end
126
+ with_campaign_finish!(journal: journal, commit: commit, events: events,
127
+ params: parent.merge("result_id" => result.fetch("result_id")), map: map) { true }
128
+ end
129
+
130
+ def with_campaign_finish!(journal:, commit:, events:, params:, map:)
131
+ record = events.find { |event| event["type"] == "result_submitted" && event.dig("payload", "result_id") == params["result_id"] }
132
+ return yield unless record&.dig("payload", "receipt", "campaign")
133
+ current = exact_candidate!(candidate(events), params)
134
+ payload = verified_result(journal, events, params, map, retained_origin(events, params), current, commit,
135
+ result_id: params.fetch("result_id"))
136
+ reference = payload.fetch("receipt").fetch("campaign").fetch("result")
137
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
138
+ bytes = canonical.read({"ref" => reference.fetch("path"), "sha256" => reference.fetch("sha256")},
139
+ **result_context(payload.fetch("binding")), commit: commit)
140
+ result = JSON.parse(bytes, create_additions: false, max_nesting: 64, allow_duplicate_key: false, allow_comments: false)
141
+ with_campaign_receipt!(journal: journal, commit: commit, events: events, params: params, map: map,
142
+ receipt: payload.fetch("receipt"), result: result, historical: false) { yield }
143
+ rescue JSON::ParserError
144
+ raise AttemptErrors::ReceiptRejected, "canonical campaign result JSON is malformed"
145
+ end
146
+
147
+ def dispatch_campaign_export(request:, peer:, role:, body: true)
148
+ params = request.fetch("params")
149
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("campaign_export_result").sort &&
150
+ params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
151
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"].positive? && request["mutation_id"].nil?
152
+ raise ArgumentError, "campaign result selectors differ"
153
+ end
154
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
155
+ map = @deployment.mapping(params.fetch("mapping_id"))
156
+ raise AttemptErrors::UnauthorizedIdentity, "campaign project differs" unless map["project_id"] == request.fetch("project_id")
157
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, registration|
158
+ protected_journal!(journal)
159
+ commit = journal.ref_value
160
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
161
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
162
+ context = @launch.send(:control_registration_context!, params, original_map, journal, commit: commit)
163
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: original_map)
164
+ origin = active_origin(events, params)
165
+ role == :worker ? worker_or_launcher!(peer, role, original_map, origin) : finish_identity!(events, params, original_map, peer, role)
166
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
167
+ commit: commit, params: params, map: original_map).review_campaign)
168
+ return true unless body
169
+ parent = params.merge("scope" => origin.fetch("scope"), "base" => origin.fetch("base_head"))
170
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
171
+ current = exact_candidate!(candidate(events), params)
172
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
173
+ project = descriptor.project(original_map.fetch("project_id"))
174
+ root = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
175
+ FileUtils.mkdir_p(root, mode: 0o700)
176
+ CandidateTransfer.new(root: root).with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"),
177
+ size: current.fetch("bytes"), head: current.fetch("head"), tree: current.fetch("tree"), base: parent.fetch("base")) do |candidate_reader|
178
+ manager = @launch.send(:campaign_manager_for!, context,
179
+ **campaign_execution_dependencies!(journal: journal, commit: commit, parent_params: parent, parent_map: original_map),
180
+ candidate_reader: candidate_reader)
181
+ result = manager.accepted_result_snapshot(selected.fetch("campaign_id"))
182
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
183
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
184
+ manager.with_verified_result!(result: result, **selected.slice("subject", "contract_identity", "policy").transform_keys(&:to_sym),
185
+ head: params.fetch("head"), base: parent.fetch("base"), producer: result.fetch("producer"), reviewer: result.fetch("reviewer"),
186
+ consumer_profiles: profiles) do
187
+ held.verify_unchanged!
188
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
189
+ raise AttemptErrors::Conflict, "campaign export policy descriptor advanced"
190
+ end
191
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
192
+ bytes = JSON.generate(result).b
193
+ raise AttemptErrors::EvidenceUnavailable, "campaign result exceeds artifact bound" if bytes.bytesize > 64 * 1024
194
+ {data: {"campaign_id" => selected.fetch("campaign_id"), "result_identity" => result.fetch("result_identity"),
195
+ "sha256" => Digest::SHA256.hexdigest(bytes), "bytes" => bytes.bytesize, "head" => params.fetch("head"),
196
+ "candidate_generation" => params.fetch("candidate_generation"), "journal_commit" => commit},
197
+ replayed: false, transfer_parts: [bytes]}
198
+ end
199
+ end
200
+ end
201
+ end
202
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError
203
+ raise AttemptErrors::EvidenceUnavailable, "canonical campaign result is unavailable"
204
+ end
205
+
206
+ def dispatch_campaign_round(request:, peer:, role:, transfer:)
207
+ params, map = campaign_round_request!(request)
208
+ admitted = CampaignRoundTransfer.decode(input: transfer, sha256: params.fetch("input_sha256"))
209
+ input = admitted.fetch(:round)
210
+ unless input["attempt_id"] == request.fetch("mutation_id") && input["head"] == params.fetch("head")
211
+ raise AttemptErrors::Conflict, "campaign round stable request or head differs"
212
+ end
213
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, registration|
214
+ protected_journal!(journal)
215
+ commit = journal.ref_value
216
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
217
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
218
+ context = @launch.send(:control_registration_context!, params, original_map, journal, commit: commit)
219
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: original_map)
220
+ origin = finish_identity!(events, params, original_map, peer, role)
221
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
222
+ commit: commit, params: params, map: original_map).review_campaign)
223
+ parent_params = params.merge("scope" => origin.fetch("scope"), "base" => input.fetch("base"))
224
+ project = descriptor.project(original_map.fetch("project_id"))
225
+ paths = CampaignRoundTransfer.materialize(admitted: admitted, repository: project.fetch("campaign_repository"),
226
+ request_id: request.fetch("mutation_id"))
227
+ dependencies = campaign_execution_dependencies!(journal: journal, commit: commit,
228
+ parent_params: parent_params, parent_map: original_map)
229
+ current = exact_candidate!(retained_candidate(events, params.fetch("candidate_generation")), params)
230
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
231
+ quarantine = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
232
+ FileUtils.mkdir_p(quarantine, mode: 0o700)
233
+ transfer_owner = CandidateTransfer.new(root: quarantine)
234
+ transfer_owner.with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"), size: current.fetch("bytes"),
235
+ head: current.fetch("head"), tree: current.fetch("tree"), base: input.fetch("base")) do |candidate_reader|
236
+ manager = @launch.send(:campaign_manager_for!, context, **dependencies, artifact_paths: paths, candidate_reader: candidate_reader)
237
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
238
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
239
+ # The existing R1 store checks exact replay before fresh policy,
240
+ # and binds the complete supplied frame in its input_digest.
241
+ data = manager.record_round(selected.fetch("campaign_id"), input, source_digest: admitted.fetch(:sha256),
242
+ expected_campaign: selected.reject { |key, _| key == "version" }, consumer_profiles: profiles,
243
+ before_record: -> {
244
+ held.verify_unchanged!
245
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent_params, map: original_map)
246
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
247
+ raise AttemptErrors::Conflict, "campaign policy descriptor advanced"
248
+ end
249
+ })
250
+ held.verify_unchanged!
251
+ unless journal.ref_value == commit && @launch.send(:protected_descriptor_sha256!) == deployment_sha
252
+ raise AttemptErrors::Conflict, "campaign round canonical selection advanced"
253
+ end
254
+ reply = data.slice("campaign_id", "recorded_complete", "completed_rounds", "accepted", "result_identity", "replayed")
255
+ .merge("request_id" => request.fetch("mutation_id"), "round_id" => input.fetch("round_id"))
256
+ {data: reply, replayed: data["replayed"] == true}
257
+ end
258
+ end
259
+ end
260
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError
261
+ raise AttemptErrors::EvidenceUnavailable, "canonical campaign round is unavailable"
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require "ace/herdr/molecules/inbox_context_client"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ class Endcap
8
+ private
9
+
10
+ def with_inbox_context(params, map, mutation_id:)
11
+ context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
12
+ @deployment.verify_inbox_context!(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
13
+ client = @inbox_context_clients&.fetch(params.fetch("inbox_context_id"))
14
+ client ||= Ace::Herdr::Molecules::InboxContextClient.selected(context_id: params.fetch("inbox_context_id"),
15
+ socket_path: context.fetch("control_socket_path"), owner_credentials: context.fetch("owner_credentials"), kernel: @kernel)
16
+ yield client
17
+ rescue Ace::Runtime::RuntimeUnavailableError, Ace::Herdr::Error
18
+ raise AttemptErrors::EvidenceUnavailable, "protected inbox context is unavailable"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ INBOX_REGISTRATION_FIELDS = %w[event_id attempt_id payload_sha256].freeze
8
+
9
+ private
10
+
11
+ def inbox_registration?(value)
12
+ value.is_a?(Hash) && value.keys.sort == INBOX_REGISTRATION_FIELDS.sort &&
13
+ %w[event_id attempt_id].all? { |key| value[key].is_a?(String) && Molecules::JournalMutation::ID.match?(value[key]) } &&
14
+ %w[payload_sha256].all? { |key| value[key].is_a?(String) && DIGEST.match?(value[key]) }
15
+ end
16
+
17
+ def inbox_native_lineage!(record, lineage)
18
+ workspace = lineage.native_event.fetch("payload").fetch("workspace_id")
19
+ original = record.fetch("origin_target")
20
+ binding = record.fetch("binding")
21
+ unless original.is_a?(Hash) && binding.is_a?(Hash) &&
22
+ original["session"] == workspace && binding["session"] == workspace
23
+ raise AttemptErrors::EvidenceUnavailable, "inbox original native workspace differs"
24
+ end
25
+ if lineage.child_event
26
+ child = lineage.child_event.fetch("payload").fetch("original_process_binding")
27
+ unless original.values_at("session", "pane", "terminal_id") == child.values_at("session", "pane", "terminal_id")
28
+ raise AttemptErrors::EvidenceUnavailable, "inbox original native child differs"
29
+ end
30
+ end
31
+ true
32
+ end
33
+
34
+ end
35
+ end
36
+ end
37
+ 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