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,342 @@
1
+ # frozen_string_literal: true
2
+ require "yaml"
3
+ require "ace/git/organisms/pull_request_lifecycle"
4
+ require_relative "../atoms/delivery_parameters"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Organisms
9
+ # Remote delivery is a projection of the existing attempt journal.
10
+ # Intents precede writes; an interrupted intent permits reconciliation
11
+ # reads only. Privileged merge is executed by qjx, never this worker.
12
+ class DeliveryCoordinator
13
+ def initialize(repo_root:, coordinator: nil, journal: nil, lifecycle_factory: nil, exclusion: nil, identity_resolver: nil)
14
+ @mutex = Mutex.new
15
+ @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
16
+ @repo_root = repo_root
17
+ @journal = journal || Molecules::EvidenceJournal.new(repo_root: repo_root)
18
+ @coordinator = coordinator || AttemptCoordinator.new(repo_root: repo_root, journal: @journal)
19
+ @factory = lifecycle_factory || ->(selection) { Ace::Git::Organisms::PullRequestLifecycle.new(**selection) }
20
+ @exclusion = exclusion || Molecules::LifecycleExclusion.new(repo_root: repo_root)
21
+ end
22
+
23
+ def perform(**arguments)
24
+ @mutex.synchronize { execute_request(**arguments) }
25
+ end
26
+
27
+ private
28
+
29
+ def execute_request(assignment_id:, attempt_id:, operation:, parameters: nil, title: nil, body: nil,
30
+ tests: nil, review: nil, service_request_id: nil)
31
+ @identity = @identity_resolver.resolve
32
+ unless @identity_resolver.trusted?(@identity)
33
+ raise AttemptErrors::UnauthorizedIdentity, "Only a trusted coordinator or service may accept delivery evidence"
34
+ end
35
+ raise ArgumentError, "Unsupported delivery operation" unless %w[create update ready review merge status].include?(operation)
36
+ if operation == "create" && (!title.is_a?(String) || title.strip.empty?)
37
+ raise ArgumentError, "Create requires a concrete PR title"
38
+ end
39
+ if operation == "update" && title.nil? && body.nil?
40
+ raise ArgumentError, "Update requires title or body"
41
+ end
42
+ raise ArgumentError, "PR body must be text" unless body.nil? || body.is_a?(String)
43
+ params = Atoms::DeliveryParameters.validate(parameters || assignment_parameters(assignment_id))
44
+ binding = {"assignment_id" => assignment_id, "attempt_id" => attempt_id}
45
+ attempt = @coordinator.authoritative_attempt(binding)
46
+ raise AttemptErrors::NotFound, "Delivery attempt not found" unless attempt
47
+ raise AttemptErrors::ReceiptRejected, "Delivery requires a managed attempt" unless attempt.managed?
48
+ @exclusion.with_shared(@exclusion.task_key(attempt.binding.task_id)) do
49
+ @exclusion.with_exclusive(@exclusion.assignment_key(assignment_id)) do
50
+ raise AttemptErrors::Conflict, "Assignment was pruned" if @exclusion.removed?(@exclusion.assignment_key(assignment_id))
51
+ execute(binding, operation, params, title: title, body: body, tests: tests, review: review,
52
+ service_request_id: service_request_id)
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def assignment_parameters(assignment_id)
60
+ assignment = Molecules::AssignmentManager.new.load(assignment_id)
61
+ raise AttemptErrors::NotFound, "Assignment not found" unless assignment
62
+ document = YAML.safe_load_file(assignment.source_config, permitted_classes: [Time, Date])
63
+ document.fetch("delivery")
64
+ rescue KeyError, Errno::ENOENT, Psych::Exception
65
+ raise ArgumentError, "Assignment needs explicit delivery parameters"
66
+ end
67
+
68
+ def execute(binding, operation, params, **options)
69
+ @binding = binding
70
+ @intent_context = nil
71
+ @attempt = @coordinator.authoritative_attempt(binding)
72
+ unless @attempt&.active? && @attempt.managed?
73
+ raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active"
74
+ end
75
+ @head = git!("rev-parse", "HEAD").strip
76
+ selection = {server_name: params["forge_server"], use_default: params["forge_default"], repo_root: @repo_root}
77
+ historical = events.find { |event| event.dig("payload", "stage") == "identity" }&.fetch("payload")
78
+ selection = {server_name: historical.dig("server", "name"), use_default: false, repo_root: @repo_root} if historical
79
+ @lifecycle = @factory.call(selection)
80
+ server = @lifecycle.resolved_identity
81
+ @server = server
82
+ Atoms::DeliveryParameters.validate_url!(server.fetch("url"))
83
+ provenance = params.fetch("pr_provenance")
84
+ unless Ace::Git::Atoms::ServerUrl.match?(server["url"], provenance["base_repository_url"])
85
+ raise Ace::Git::ProviderIdentityMismatchError, "Selected forge does not match delivery base"
86
+ end
87
+ if historical
88
+ unless historical["server"] == server && historical["provenance"] == provenance &&
89
+ historical["selection"] == params.slice("forge_server", "forge_default")
90
+ raise AttemptErrors::Conflict, "Delivery identity or selection changed during attempt"
91
+ end
92
+ elsif !%w[status review].include?(operation)
93
+ record("stage" => "identity", "server" => server, "provenance" => provenance,
94
+ "selection" => params.slice("forge_server", "forge_default"))
95
+ end
96
+ @provenance = provenance
97
+ pinned = Ace::Git::ResolvedServer.new(name: server.fetch("name"),
98
+ provider: server.fetch("provider").to_sym, url: server.fetch("url"))
99
+ @lifecycle = @factory.call(server_name: pinned.name, use_default: false,
100
+ repo_root: @repo_root, resolved_server: pinned)
101
+ verify_pushed_head!
102
+ return status if operation == "status"
103
+ return review_snapshot if operation == "review"
104
+ pending = unresolved_intent
105
+ if pending
106
+ result = reconcile(pending)
107
+ return result if same_effect?(pending, operation, options)
108
+ raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: pending["operation"])
109
+ end
110
+ require_evidence!(options[:tests], options[:review]) if %w[ready merge].include?(operation)
111
+ return consume_merge(options[:service_request_id]) if operation == "merge"
112
+ pr = current_pr
113
+ if operation == "create" && pr
114
+ requested = content_input(options)
115
+ metadata = @lifecycle.review_metadata_snapshot(pr.number).pull_request
116
+ verify_pr!(metadata)
117
+ unless content_matches?(requested.keys.map(&:to_s), Atoms::EvidenceDigest.digest(requested), metadata)
118
+ raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: "create")
119
+ end
120
+ return reconcile({"operation" => "create", "head" => @head,
121
+ "fields" => requested.keys.map(&:to_s), "input_digest" => Atoms::EvidenceDigest.digest(requested)})
122
+ end
123
+ raise AttemptErrors::ReceiptRejected, "Create a draft before #{operation}" if operation != "create" && !pr
124
+ verify_pr!(pr) if pr
125
+ intent = {"stage" => "intent", "operation" => operation, "head" => @head,
126
+ "input_digest" => Atoms::EvidenceDigest.digest(content_input(options)),
127
+ "fields" => content_input(options).keys.map(&:to_s),
128
+ "pr_number" => pr&.number, "tests" => options[:tests], "review" => options[:review]}
129
+ record(intent)
130
+ @intent_context = unresolved_intent
131
+ receipt = case operation
132
+ when "create"
133
+ @lifecycle.create(head_ref: provenance["head_ref"], base_ref: provenance["base_ref"],
134
+ head_repository_url: provenance["head_repository_url"], expected_head: @head,
135
+ title: options[:title], body: options[:body], draft: true)
136
+ when "update"
137
+ @lifecycle.update(pr.number, expected_head: @head, title: options[:title], body: options[:body])
138
+ when "ready" then @lifecycle.ready(pr.number, expected_head: @head)
139
+ end
140
+ verify_pr!(receipt.pull_request)
141
+ unless receipt.operation.to_s == operation && receipt.server_name == server["name"] &&
142
+ (operation != "create" || receipt.pull_request.draft == true) &&
143
+ (operation != "ready" || receipt.pull_request.draft == false)
144
+ raise Ace::Git::ProviderUnknownOutcomeError, "Delivery mutation lacks expected outcome"
145
+ end
146
+ record_result(operation, receipt.pull_request, "succeeded")
147
+ status
148
+ rescue Ace::Git::ProviderUnknownOutcomeError
149
+ # The durable intent remains unresolved; no exception text or
150
+ # provider payload is stored (both may contain sensitive data).
151
+ raise
152
+ end
153
+
154
+ def events
155
+ @journal.read_events(@binding.fetch("assignment_id")).select do |event|
156
+ event["type"] == "delivery"
157
+ end
158
+ end
159
+
160
+ def record(payload)
161
+ producer = {"actor" => @identity.actor, "role" => @identity.role, "runtime" => @identity.runtime}
162
+ @journal.record(**@binding.transform_keys(&:to_sym), type: "delivery", payload: payload.merge("producer" => producer),
163
+ guard: -> {
164
+ live = @coordinator.authoritative_attempt(@binding)
165
+ raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active" unless live&.active?
166
+ raise AttemptErrors::ReceiptRejected, "Candidate changed during delivery" unless git!("rev-parse", "HEAD").strip == @head
167
+ })
168
+ end
169
+
170
+ def unresolved_intent
171
+ history = events
172
+ settled = history.filter_map do |event|
173
+ payload = event.fetch("payload")
174
+ payload["intent_digest"] if payload["stage"] == "result" && payload["outcome"] == "succeeded"
175
+ end
176
+ pending = history.select do |event|
177
+ event.dig("payload", "stage") == "intent" && !settled.include?(event["digest"])
178
+ end
179
+ raise AttemptErrors::Conflict, "Multiple unresolved delivery intents require reconciliation" if pending.length > 1
180
+ event = pending.first
181
+ event && event.fetch("payload").merge("intent_digest" => event.fetch("digest"),
182
+ "intent_attempt_id" => event.fetch("attempt_id"))
183
+ end
184
+
185
+ def current_pr
186
+ result = events.reverse.find { |event| event.dig("payload", "stage") == "result" && event.dig("payload", "pr") }
187
+ result && @lifecycle.show(result.dig("payload", "pr", "number"))
188
+ end
189
+
190
+ def reconcile(intent)
191
+ @intent_context = intent
192
+ raise AttemptErrors::ReceiptRejected, "Unresolved delivery belongs to an older candidate" unless intent["head"] == @head
193
+ operation = intent.fetch("operation")
194
+ if operation == "create"
195
+ matches = @lifecycle.reconcile_create(**@provenance.reject { |key, _| key == "mode" }.transform_keys(&:to_sym))
196
+ raise Ace::Git::ProviderUnknownOutcomeError, "No exact draft found; create remains unresolved" if matches.empty?
197
+ raise Ace::Git::ProviderConflictingMatchesError, "Multiple exact drafts found" unless matches.length == 1
198
+ pr = matches.first
199
+ verify_pr!(pr)
200
+ raise Ace::Git::ProviderUnknownOutcomeError, "Created PR draft state is unproven" unless pr.draft == true
201
+ pr = @lifecycle.review_metadata_snapshot(pr.number).pull_request
202
+ verify_pr!(pr)
203
+ unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
204
+ raise Ace::Git::ProviderUnknownOutcomeError, "Created PR content remains unproven"
205
+ end
206
+ elsif operation == "ready"
207
+ require_evidence!(intent["tests"], intent["review"])
208
+ pr = @lifecycle.show(intent.fetch("pr_number"))
209
+ verify_pr!(pr)
210
+ raise Ace::Git::ProviderUnknownOutcomeError, "Readiness remains unresolved" unless pr.draft == false
211
+ elsif operation == "update"
212
+ pr = @lifecycle.review_metadata_snapshot(intent.fetch("pr_number")).pull_request
213
+ verify_pr!(pr)
214
+ unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
215
+ raise Ace::Git::ProviderUnknownOutcomeError, "PR update remains unresolved"
216
+ end
217
+ else
218
+ raise Ace::Git::ProviderUnknownOutcomeError, "#{operation} requires authoritative receipt reconciliation"
219
+ end
220
+ record_result(operation, pr, "succeeded")
221
+ status
222
+ end
223
+
224
+ def content_input(options)
225
+ options.slice(:title, :body).compact
226
+ end
227
+
228
+ def content_matches?(fields, digest, pr)
229
+ observed = fields.to_h { |field| [field, pr.public_send(field)] }
230
+ Atoms::EvidenceDigest.digest(observed) == digest
231
+ end
232
+
233
+ def same_effect?(intent, operation, options)
234
+ return false unless intent["operation"] == operation
235
+ case operation
236
+ when "create", "update"
237
+ intent["input_digest"] == Atoms::EvidenceDigest.digest(content_input(options))
238
+ when "ready"
239
+ %i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
240
+ when "merge"
241
+ options[:service_request_id].is_a?(String) &&
242
+ options[:service_request_id] == intent["service_request_id"] &&
243
+ %i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
244
+ else false
245
+ end
246
+ end
247
+
248
+ def require_evidence!(tests, review)
249
+ [[tests, "check"], [review, "review-approval"]].each do |ref, kind|
250
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[attempt_id receipt_digest]
251
+ raise AttemptErrors::ReceiptRejected, "Current accepted #{kind} evidence is required"
252
+ end
253
+ evidence = @coordinator.evidence(attempt_id: ref["attempt_id"], receipt_digest: ref["receipt_digest"], kind: kind)
254
+ unless evidence["head"] == @head && evidence["assignment_id"] == @binding["assignment_id"] &&
255
+ evidence["project_id"] == @attempt.binding.project_id
256
+ raise AttemptErrors::ReceiptRejected, "Delivery evidence does not match assignment, project and candidate"
257
+ end
258
+ end
259
+ end
260
+
261
+ def consume_merge(request_id)
262
+ unless request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
263
+ raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
264
+ end
265
+ request = @coordinator.service_request_status(request_id)
266
+ unless request && request["state"] == "succeeded" && request["operation"] == "merge" &&
267
+ request["assignment_id"] == @binding["assignment_id"] && request["attempt_id"] == @binding["attempt_id"] &&
268
+ request["project_id"] == @attempt.binding.project_id && request["candidate_head"] == @head &&
269
+ request["authorization"].is_a?(String) && !request["authorization"].empty?
270
+ raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
271
+ end
272
+ @coordinator.validate_service_receipt!(request, "succeeded", request["receipt"])
273
+ pr = current_pr
274
+ verify_pr!(pr)
275
+ unless request.dig("target", "resource") == pr.url && pr.state.to_s == "merged" &&
276
+ pr.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
277
+ raise AttemptErrors::ReceiptRejected, "Service receipt and merged PR identity do not agree"
278
+ end
279
+ record_result("merge", pr, "succeeded", "service_request_id" => request_id)
280
+ status
281
+ end
282
+
283
+ def review_snapshot
284
+ pr = current_pr
285
+ raise AttemptErrors::ReceiptRejected, "Create a draft before remote review" unless pr
286
+ verify_pr!(pr)
287
+ snapshot = @lifecycle.review_snapshot(pr.number)
288
+ verify_pr!(snapshot.pull_request)
289
+ snapshot
290
+ end
291
+
292
+ def verify_pr!(pr)
293
+ reference = pr && Ace::Git::Atoms::PrReference.parse(pr.url)
294
+ unless reference && reference.number == pr.number && reference.repository_url &&
295
+ Ace::Git::Atoms::ServerUrl.match?(reference.repository_url, @provenance["base_repository_url"])
296
+ raise Ace::Git::ProviderIdentityMismatchError, "PR URL does not match delivery repository and number"
297
+ end
298
+ Atoms::DeliveryParameters.validate_url!(pr.url)
299
+ unless pr && pr.server_name == @server["name"] && pr.number.is_a?(Integer) && pr.number.positive? &&
300
+ pr.head_sha == @head && pr.head_ref == @provenance["head_ref"] &&
301
+ pr.base_ref == @provenance["base_ref"] &&
302
+ Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, @provenance["head_repository_url"]) &&
303
+ Ace::Git::Atoms::ServerUrl.match?(pr.base_repository_url, @provenance["base_repository_url"])
304
+ raise Ace::Git::ProviderExpectedHeadConflictError, "PR candidate or provenance does not match delivery"
305
+ end
306
+ end
307
+
308
+ def verify_pushed_head!
309
+ output = git!("ls-remote", "--heads", @provenance["head_repository_url"], "refs/heads/#{@provenance['head_ref']}")
310
+ expected = "#{@head}\trefs/heads/#{@provenance['head_ref']}"
311
+ raise AttemptErrors::ReceiptRejected, "Pushed source does not match candidate" unless output.strip == expected
312
+ end
313
+
314
+ def record_result(operation, pr, outcome, extra = {})
315
+ record({"stage" => "result", "operation" => operation, "head" => @head, "outcome" => outcome,
316
+ "intent_digest" => @intent_context&.fetch("intent_digest", nil),
317
+ "intent_attempt_id" => @intent_context&.fetch("intent_attempt_id", nil),
318
+ "pr" => {"number" => pr.number, "url" => pr.url, "head_sha" => pr.head_sha,
319
+ "draft" => pr.draft, "state" => pr.state.to_s, "merge_commit_sha" => pr.merge_commit_sha}}.merge(extra))
320
+ end
321
+
322
+ def status
323
+ attempt = @coordinator.authoritative_attempt(@binding)
324
+ {"attempt_id" => attempt.attempt_id, "assignment_id" => @binding["assignment_id"],
325
+ "task_id" => attempt.binding.task_id, "project_id" => attempt.binding.project_id,
326
+ "actor" => attempt.binding.actor, "role" => attempt.binding.role, "runtime" => attempt.binding.runtime,
327
+ "base_head" => attempt.binding.base_head, "candidate_head" => attempt.candidate_head,
328
+ "evidence_git_ref" => @attempt.binding.evidence_git_ref, "journal_commit" => @journal.ref_value,
329
+ "delivery" => events.map do |event|
330
+ event.fetch("payload").merge("attempt_id" => event.fetch("attempt_id"), "event_digest" => event.fetch("digest"))
331
+ end}
332
+ end
333
+
334
+ def git!(*argv)
335
+ result = Ace::Git::Atoms::CommandExecutor.execute("git", "-C", @repo_root, *argv)
336
+ raise AttemptErrors::EvidenceUnavailable, "Git delivery proof unavailable" unless result[:success]
337
+ result[:output]
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/bundle"
4
+ require_relative "task_assignment_creator"
5
+ require_relative "../authority/prepared_work"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Organisms
10
+ # Captures managed preparation once. This owner neither registers nor
11
+ # launches work; callers retain its exact output across uncertain replies.
12
+ class PreparedWorkBuilder
13
+ def initialize(export_root:, task_manager: nil, executor: nil, bundle_loader: nil)
14
+ @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
15
+ @executor = executor || AssignmentExecutor.new
16
+ @bundles = bundle_loader || Ace::Bundle::Organisms::BundleLoader.new
17
+ @transfer = Authority::CandidateTransfer.new(root: export_root)
18
+ end
19
+
20
+ def call(task_ref:, project_id:, dependency_reports:)
21
+ token!(project_id)
22
+ @reads = {}
23
+ @inventories = {}
24
+ tasks = closure(task_ref)
25
+ selected = tasks.fetch(task_ref)
26
+ refuse!("selected task is not a reviewed leaf") unless
27
+ %w[pending in-progress].include?(selected.status.to_s) && Array(selected.subtasks).empty?
28
+ contexts = tasks.sort.to_h do |id, task|
29
+ refuse!("task still needs review") unless task.metadata.fetch("needs_review", false) == false
30
+ [id, capture_context(task)]
31
+ end
32
+ reports = capture_reports(dependency_reports, tasks, task_ref, project_id)
33
+ result = TaskAssignmentCreator.new(task_manager: @tasks, executor: @executor)
34
+ .call(task_refs: [task_ref], primary_task_ref: task_ref, project_id: project_id)
35
+ assignment = result.fetch(:assignment)
36
+ refuse!("managed association changed") unless assignment.task_id == task_ref && assignment.project_id == project_id
37
+ job = read(assignment.source_config)
38
+ read(assignment.assignment_file)
39
+ inventory = inventory(assignment.steps_dir)
40
+ step_files = inventory.to_h { |name| [name, read(File.join(assignment.steps_dir, name))] }
41
+ roots = step_files.filter_map do |name, bytes|
42
+ fm = Atoms::StepFileParser.parse(bytes).fetch(:frontmatter)
43
+ Atoms::StepFileParser.parse_filename(name).fetch(:number) if fm["context"] == "fork" && fm["taskref"] == task_ref
44
+ end
45
+ refuse!("selected fork root is not unique") unless roots.one? && roots.first
46
+ scope = roots.first
47
+ files = {"definition.json" => JSON.generate(assignment.to_h), "job.yaml" => job}
48
+ selected_steps = step_files.filter_map do |name, bytes|
49
+ number = Atoms::StepFileParser.parse_filename(name).fetch(:number)
50
+ refuse!("unexpected queue entry") unless number && name.end_with?(".st.md")
51
+ next unless number == scope || number.start_with?(scope + ".")
52
+ files["steps/#{name}"] = bytes
53
+ record(name, bytes).merge("number" => number)
54
+ end.sort_by { |entry| number_key(entry.fetch("number")) }
55
+ context = contexts.map do |id, captured|
56
+ spec_path = "context/#{id}/spec.md"
57
+ text_path = "context/#{id}/bundle.txt"
58
+ files[spec_path], files[text_path] = captured.values_at(:spec, :text)
59
+ selected_reports = reports.fetch(id, []).map do |report|
60
+ path = "context/#{id}/reports/#{report.fetch(:assignment_id)}/#{report.fetch(:number)}.r.md"
61
+ files[path] = report.fetch(:bytes)
62
+ {"assignment_id" => report.fetch(:assignment_id), "number" => report.fetch(:number),
63
+ "file" => record(path, report.fetch(:bytes))}
64
+ end
65
+ {"uri" => "task://#{id}", "task_id" => id, "spec" => record(spec_path, files.fetch(spec_path)),
66
+ "text" => record(text_path, files.fetch(text_path)), "reports" => selected_reports}
67
+ end
68
+ manifest = {"version" => 1, "assignment_id" => assignment.id, "project_id" => project_id,
69
+ "task_id" => task_ref, "scope" => scope, "job" => record("job.yaml", job),
70
+ "steps" => selected_steps, "context" => context}
71
+ files["manifest.json"] = Authority::PreparedWork.canonical_manifest(manifest) + "\n"
72
+ work = Authority::PreparedWork.new(files: files)
73
+ current = closure(task_ref)
74
+ refuse!("task dependency inventory changed") unless current.keys.sort == tasks.keys.sort
75
+ refuse!("selected task expanded during capture") unless Array(current.fetch(task_ref).subtasks).empty?
76
+ current.each { |id, task| refuse!("task context changed") unless capture_context(task) == contexts.fetch(id) }
77
+ @inventories.each { |path, names| refuse!("input inventory changed") unless Dir.children(path).sort == names }
78
+ @reads.each { |path, bytes| refuse!("captured input changed") unless read_bytes(path) == bytes }
79
+ @transfer.export_prepared(work: work).merge("assignment_id" => assignment.id,
80
+ "scope" => scope.freeze, "selection_sha256" => work.selection_sha256).freeze
81
+ ensure
82
+ @reads = nil
83
+ @inventories = nil
84
+ end
85
+
86
+ private
87
+
88
+ def closure(ref)
89
+ found = {}; visiting = []
90
+ walk = lambda do |id|
91
+ token!(id)
92
+ refuse!("dependency cycle: #{(visiting + [id]).join(' -> ')}") if visiting.include?(id)
93
+ return if found.key?(id)
94
+ refuse!("too many task contexts") if found.size >= 256
95
+ task = @tasks.show(id)
96
+ refuse!("missing or noncanonical task") unless task && task.id == id
97
+ found[id] = task
98
+ visiting << id
99
+ Array(task.dependencies).each { |dependency| walk.call(dependency) }
100
+ visiting.pop
101
+ end
102
+ walk.call(ref)
103
+ found
104
+ end
105
+
106
+ def capture_context(task)
107
+ spec = read(task.file_path)
108
+ bundle = @bundles.load_file(task.file_path)
109
+ errors = bundle.metadata.values_at(:error, :errors, "error", "errors").flatten.compact
110
+ refuse!("unresolvable task context") unless errors.empty?
111
+ text = bounded_text(bundle.content)
112
+ # Deep-copy owner inventories; a loader may reuse mutable objects.
113
+ {spec: spec, text: text, path: task.file_path,
114
+ inventory: Marshal.dump([bundle.files, bundle.source_files])}
115
+ end
116
+
117
+ def capture_reports(selections, tasks, selected, project)
118
+ refuse!("report selections must be explicit") unless selections.is_a?(Array) && selections.size <= 4096
119
+ seen = {}; grouped = Hash.new { |hash, key| hash[key] = [] }
120
+ selections.each do |selection|
121
+ refuse!("report selector") unless selection.is_a?(Hash) && selection.keys.sort == %w[assignment_id number task_id]
122
+ id, assignment_id, number = selection.values_at("task_id", "assignment_id", "number")
123
+ token!(id); token!(assignment_id)
124
+ refuse!("report scope") unless number.is_a?(String) && number.match?(Authority::PreparedWork::SCOPE)
125
+ refuse!("report is not a dependency") unless id != selected && tasks.key?(id)
126
+ identity = [id, assignment_id, number]
127
+ refuse!("duplicate report") if seen[identity]
128
+ seen[identity] = true
129
+ assignment = @executor.assignment_manager.load(assignment_id)
130
+ refuse!("report assignment association") unless assignment && assignment.task_id == id && assignment.project_id == project
131
+ metadata = read(assignment.assignment_file)
132
+ parsed_metadata = YAML.safe_load(metadata, permitted_classes: [Time, Date])
133
+ refuse!("report assignment changed") unless parsed_metadata["session_id"] == assignment_id &&
134
+ parsed_metadata["task_id"] == id && parsed_metadata["project_id"] == project
135
+ inventory(assignment.reports_dir)
136
+ matches = inventory(assignment.steps_dir).select do |name|
137
+ name.end_with?(".st.md") && Atoms::StepFileParser.parse_filename(name)[:number] == number
138
+ end
139
+ refuse!("report step association") unless matches.one?
140
+ step = matches.first
141
+ read(File.join(assignment.steps_dir, step))
142
+ parsed = Atoms::StepFileParser.parse_filename(step)
143
+ filename = Atoms::StepFileParser.generate_report_filename(number, parsed.fetch(:name))
144
+ grouped[id] << {assignment_id: assignment_id, number: number,
145
+ bytes: read(File.join(assignment.reports_dir, filename))}
146
+ end
147
+ grouped.each_value { |entries| entries.sort_by! { |entry| [entry.fetch(:assignment_id), number_key(entry.fetch(:number))] } }
148
+ grouped
149
+ end
150
+
151
+ def record(path, bytes)
152
+ {"filename" => path, "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
153
+ end
154
+
155
+ def read(path)
156
+ bytes = read_bytes(path)
157
+ refuse!("captured input changed") if @reads.key?(path) && @reads.fetch(path) != bytes
158
+ @reads[path] = bytes
159
+ refuse!("capture inventory bound") if @reads.size > 4096 || @reads.values.sum(&:bytesize) > Authority::PreparedWork::MAX_TOTAL
160
+ bytes
161
+ end
162
+
163
+ def inventory(path)
164
+ names = Dir.children(path).sort.freeze
165
+ refuse!("capture inventory bound") if names.size > 4096
166
+ refuse!("input inventory changed") if @inventories.key?(path) && @inventories.fetch(path) != names
167
+ @inventories[path] = names
168
+ end
169
+
170
+ def read_bytes(path)
171
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
172
+ refuse!("input is not a bounded regular file") unless file.stat.file? && file.stat.size <= Authority::PreparedWork::MAX_TEXT
173
+ bounded_text(file.read(Authority::PreparedWork::MAX_TEXT + 1))
174
+ end
175
+ rescue SystemCallError
176
+ refuse!("input unavailable")
177
+ end
178
+
179
+ def bounded_text(bytes)
180
+ refuse!("input text bound") unless bytes.is_a?(String) && bytes.bytesize.between?(1, Authority::PreparedWork::MAX_TEXT)
181
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
182
+ refuse!("input encoding") unless text.valid_encoding? && !text.include?("\0")
183
+ text.freeze
184
+ end
185
+
186
+ def number_key(number) = number.split(".").map(&:to_i)
187
+ def token!(value)
188
+ refuse!("invalid identity") unless value.is_a?(String) && value.match?(Authority::PreparedWork::TOKEN)
189
+ end
190
+ def refuse!(message) = raise(AttemptErrors::ReceiptRejected, "prepared_input_invalid: #{message}")
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../authority/service_delivery_evidence"
3
+ require "ace/git/atoms/service_pr_evidence"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Organisms
8
+ # Worker consumes only the original authority's canonical completion.
9
+ # No local journal, provider mutation, recovery write or merge retry.
10
+ class ProtectedDeliveryCoordinator
11
+ BASE_FIELDS = %w[request_id assignment_id attempt_id project_id operation service_id target candidate_head
12
+ candidate_generation state dispatch_ticket_id claim_binding claim_generation dispatch_phase policy_digest generation journal_commit].freeze
13
+ COMPLETE_FIELDS = %w[input_digest authorization executor_uid completion_digest receipt delivery_event].freeze
14
+
15
+ def initialize(client:, project_id:)
16
+ @client, @project_id = client, project_id
17
+ end
18
+
19
+ def perform(assignment_id:, attempt_id:, operation:, service_request_id:, candidate_head:, candidate_generation:,
20
+ input_digest:, target:, parameters: nil, title: nil, body: nil, tests: nil, review: nil)
21
+ unless %w[create update ready merge status].include?(operation) && [title, body, tests, review].all?(&:nil?) &&
22
+ [assignment_id, attempt_id, service_request_id].all? { |value| value.is_a?(String) && value.match?(Molecules::JournalMutation::ID) } &&
23
+ candidate_head.is_a?(String) && candidate_head.match?(/\A[0-9a-f]{40}\z/) &&
24
+ candidate_generation.is_a?(Integer) && candidate_generation.positive? &&
25
+ input_digest.is_a?(String) && input_digest.match?(/\A[0-9a-f]{64}\z/) &&
26
+ target.is_a?(Hash) && target.keys.sort == %w[artifact_digest resource] && target["resource"].is_a?(String) &&
27
+ (target["artifact_digest"].nil? || target["artifact_digest"].is_a?(String) && target["artifact_digest"].match?(/\A[0-9a-f]{64}\z/))
28
+ raise ArgumentError, "protected delivery requires exact canonical merge selectors; local delivery flags are unavailable"
29
+ end
30
+ binding = {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
31
+ "candidate_generation" => candidate_generation, "head" => candidate_head, "request_id" => service_request_id}
32
+ data = @client.call("service_status", binding).data
33
+ unless data.is_a?(Hash) && (data.keys - BASE_FIELDS - COMPLETE_FIELDS).empty? &&
34
+ Atoms::EvidenceDigest.digest(data.slice("assignment_id", "attempt_id", "request_id", "candidate_generation")) == Atoms::EvidenceDigest.digest(binding.except("head")) &&
35
+ data["project_id"] == @project_id && data["candidate_head"] == candidate_head && Atoms::EvidenceDigest.digest(data["target"]) == Atoms::EvidenceDigest.digest(target) &&
36
+ %w[create update ready merge].include?(data["operation"]) && (operation == "status" || data["operation"] == operation) && data["generation"].is_a?(Integer) && data["generation"].positive? &&
37
+ data["input_digest"] == input_digest &&
38
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
39
+ raise AttemptErrors::ReceiptRejected, "original canonical merge status differs"
40
+ end
41
+ if data["state"] != "succeeded"
42
+ return data if operation == "status"
43
+ raise AttemptErrors::EvidenceUnavailable, "original merge is not canonically succeeded; inspect its status without retry"
44
+ end
45
+ unless data.keys.sort == (BASE_FIELDS + COMPLETE_FIELDS).sort && data["input_digest"] == input_digest &&
46
+ data["executor_uid"].is_a?(Integer) && data["executor_uid"].positive? &&
47
+ data["completion_digest"].is_a?(String) && data["completion_digest"].match?(/\A[0-9a-f]{64}\z/)
48
+ raise AttemptErrors::ReceiptRejected, "original canonical completion selectors differ"
49
+ end
50
+ receipt = data.fetch("receipt")
51
+ unless receipt.is_a?(Hash) && receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
52
+ receipt["outcome"] == "succeeded" && receipt["operation"] == data["operation"] && receipt["transport"] == "unix" &&
53
+ receipt.slice("request_id", "assignment_id", "attempt_id", "project_id", "input_digest", "target", "candidate_head", "executor_uid") ==
54
+ data.slice("request_id", "assignment_id", "attempt_id", "project_id", "input_digest", "target", "candidate_head", "executor_uid") &&
55
+ receipt["evidence"].is_a?(Array) && receipt["evidence"].one?
56
+ raise AttemptErrors::ReceiptRejected, "original canonical merge receipt differs"
57
+ end
58
+ reference = receipt.fetch("evidence").first
59
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
60
+ reference["ref"].is_a?(String) && reference["ref"].match?(%r{\Aevidence/imports/[a-z0-9-]+\z}) &&
61
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/)
62
+ raise AttemptErrors::ReceiptRejected, "original canonical artifact reference differs"
63
+ end
64
+ fetched = @client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
65
+ "kind" => "service", "purpose_id" => service_request_id,
66
+ "artifact_id" => reference.fetch("ref").delete_prefix("evidence/imports/")}, download: true, purpose: :artifacts)
67
+ descriptor = fetched.data.fetch("descriptor")
68
+ original_binding = Authority::ServiceEvidence::FIELDS.to_h { |key| [key, receipt.key?(key) ? receipt.fetch(key) : data.fetch(key)] }
69
+ unless Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
70
+ descriptor["binding_digest"] == Atoms::EvidenceDigest.digest(original_binding) &&
71
+ descriptor["sha256"] == reference.fetch("sha256") && descriptor["peer_uid"] == data.fetch("executor_uid") &&
72
+ fetched.parts.is_a?(Array) && fetched.parts.one? && fetched.parts.first.is_a?(String) &&
73
+ Digest::SHA256.hexdigest(fetched.parts.first) == reference.fetch("sha256")
74
+ raise AttemptErrors::ReceiptRejected, "original canonical artifact descriptor differs"
75
+ end
76
+ proof_options = {request_id: service_request_id, input_digest: input_digest, target: target, head: candidate_head}
77
+ proof = if data.fetch("operation") == "merge"
78
+ Ace::Git::Atoms::ServiceMergeEvidence.validate(fetched.parts.first, **proof_options)
79
+ else
80
+ Ace::Git::Atoms::ServicePrEvidence.validate(fetched.parts.first, operation: data.fetch("operation"), **proof_options)
81
+ end
82
+ unless Atoms::EvidenceDigest.digest(proof.fetch(:input)) == input_digest &&
83
+ (!parameters || Atoms::EvidenceDigest.digest(Atoms::DeliveryParameters.validate(parameters)) == Atoms::EvidenceDigest.digest(proof.fetch(:input).fetch("delivery")))
84
+ raise AttemptErrors::ReceiptRejected, "canonical merge artifact accepted input differs"
85
+ end
86
+ event = data.fetch("delivery_event")
87
+ expected = Authority::ServiceDeliveryEvidence.payload(data, receipt, data.fetch("completion_digest"), proof)
88
+ unless event.is_a?(Hash) && event.keys.sort == %w[attempt_id digest payload previous_digest recorded_at type] &&
89
+ event["type"] == "delivery" && event["attempt_id"] == attempt_id && Models::EvidenceEvent.valid?(event) &&
90
+ Atoms::EvidenceDigest.digest(event.fetch("payload")) == Atoms::EvidenceDigest.digest(expected)
91
+ raise AttemptErrors::ReceiptRejected, "canonical delivery result/completion/import join differs"
92
+ end
93
+ data
94
+ rescue KeyError, TypeError, ArgumentError
95
+ raise AttemptErrors::ReceiptRejected, "protected merge evidence is malformed or unavailable"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end