ace-assign 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require_relative "candidate_transfer"
7
7
  require_relative "receipt_transfer"
8
8
  require_relative "service_evidence"
9
+ require_relative "service_delivery_evidence"
9
10
  require_relative "../molecules/canonical_evidence"
10
11
 
11
12
  module Ace
@@ -14,43 +15,81 @@ module Ace
14
15
  # Business admission shares the launch origin, lifecycle exclusion and
15
16
  # journal CAS. Network transfer is completed outside those locks.
16
17
  class Endcap
17
- OPERATIONS = %w[submit_candidate export_candidate assign_review accept_review request_service begin_dispatch complete_service service_authorization service_status submit_result evidence_fetch reconcile_inbox].freeze
18
+ OPERATIONS = %w[submit_candidate export_candidate request_review review_status assign_review cancel_review accept_review request_service begin_dispatch complete_service publication_challenge publication_continue claim_service_settlement complete_no_effect service_authorization service_status workspace_prune_preview_context submit_result evidence_fetch bind_inbox finish recover campaign_record_round campaign_export_result].freeze
18
19
  TRANSFER_OPERATIONS = {
19
- "reconcile_inbox" => {direction: :upload, purpose: :inbox_proof, roles: %i[launcher supervisor]},
20
+ "campaign_export_result" => {direction: :download, purpose: :artifacts, roles: %i[worker launcher supervisor]},
21
+ "campaign_record_round" => {direction: :upload, purpose: :receipt_artifacts, roles: %i[launcher supervisor]},
20
22
  "submit_result" => {direction: :upload, purpose: :receipt_artifacts, roles: [:worker]},
21
- "evidence_fetch" => {direction: :download, purpose: :artifacts, roles: %i[worker reviewer executor launcher supervisor]},
23
+ "evidence_fetch" => {direction: :download, purpose: :artifacts, roles: %i[worker reviewer executor launcher supervisor signer]},
22
24
  "submit_candidate" => {direction: :upload, purpose: :candidate, roles: %i[worker launcher]},
23
25
  "export_candidate" => {direction: :download, purpose: :candidate, roles: %i[reviewer executor]},
24
26
  "accept_review" => {direction: :upload, purpose: :receipt_artifacts, roles: [:reviewer]},
25
27
  "request_service" => {direction: :upload, purpose: :service_input, roles: [:executor]},
26
28
  "begin_dispatch" => {direction: :upload, purpose: :service_input, roles: [:executor]},
27
29
  "service_authorization" => {direction: :upload, purpose: :service_input, roles: [:executor]},
28
- "complete_service" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]}
30
+ "complete_service" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]},
31
+ "publication_challenge" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]},
32
+ "complete_no_effect" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]}
29
33
  }.freeze
30
34
  PARAMETERS = {
31
- "reconcile_inbox" => %w[mapping_id assignment_id attempt_id expected_generation event_id inbox_context_id expected_registration receipt_sha256 signature_sha256 transfer],
35
+ "campaign_export_result" => %w[mapping_id assignment_id attempt_id candidate_generation head],
36
+ "campaign_record_round" => %w[mapping_id assignment_id attempt_id candidate_generation head input_sha256 transfer],
32
37
  "submit_result" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head receipt_sha256 transfer],
33
38
  "evidence_fetch" => %w[mapping_id assignment_id attempt_id kind purpose_id artifact_id],
34
39
  "submit_candidate" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head transfer],
35
40
  "export_candidate" => %w[mapping_id assignment_id attempt_id candidate_generation head purpose_id],
41
+ "request_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head],
36
42
  "assign_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head reviewer_uid reviewer_process_binding],
43
+ "cancel_review" => %w[mapping_id assignment_id attempt_id head candidate_generation review_event_id expected_generation],
37
44
  "accept_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head purpose_id receipt_sha256 transfer],
38
45
  "request_service" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head request_id operation input_digest target authorization service_id worker_process_binding transfer],
39
46
  "begin_dispatch" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head request_id claim_binding transfer],
47
+ "workspace_prune_preview_context" => %w[mapping_id assignment_id attempt_id service_id worker_process_binding],
40
48
  "service_status" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id],
41
49
  "service_authorization" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest transfer],
42
- "complete_service" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding receipt_sha256 transfer]
50
+ "complete_service" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding receipt_sha256 transfer],
51
+ "claim_service_settlement" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id expected_generation],
52
+ "publication_challenge" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest expected_generation receipt_sha256 transfer],
53
+ "publication_continue" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest expected_generation challenge_digest],
54
+ "complete_no_effect" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding reconciliation_challenge receipt_sha256 transfer]
43
55
  }.freeze
44
56
 
45
- def initialize(deployment:, launch:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, service_policy: nil)
57
+ def initialize(deployment:, launch:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, service_policy: nil, inbox_context_clients: nil, deployment_history: nil)
46
58
  @deployment, @launch, @kernel, @service_policy = deployment, launch, kernel, service_policy
59
+ @inbox_context_clients = inbox_context_clients
60
+ @deployment_history = deployment_history
47
61
  launch.attach_result_owner(self) if launch.respond_to?(:attach_result_owner)
48
62
  end
49
63
 
64
+ # Internal registration consumer only: caller holds the original parent
65
+ # lifecycle exclusion. Read one authenticated prefix; never accept a
66
+ # child's revisions as evidence of the parent's current candidate.
67
+ def campaign_parent_candidate!(journal:, commit:, params:, map:)
68
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: map)
69
+ original = active_origin(events, params)
70
+ current = candidate(events)
71
+ unless current && original.fetch("scope") == params.fetch("scope") &&
72
+ original.fetch("base_head") == params.fetch("base") &&
73
+ current.fetch("head") == params.fetch("head") &&
74
+ current.fetch("candidate_generation") == params.fetch("candidate_generation") && journal.ref_value == commit
75
+ raise AttemptErrors::Conflict, "campaign parent candidate changed"
76
+ end
77
+ projection = params.slice("assignment_id", "attempt_id").merge(
78
+ "scope" => original.fetch("scope"), "base" => original.fetch("base_head"),
79
+ "head" => current.fetch("head"), "candidate_generation" => current.fetch("candidate_generation"))
80
+ projection.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
81
+ rescue KeyError, TypeError
82
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent candidate is unavailable"
83
+ end
84
+
50
85
  def authorize_transfer!(request:, peer:, role:)
51
- return authorize_inbox_transfer!(request: request, peer: peer, role: role) if request.fetch("operation") == "reconcile_inbox"
86
+ return dispatch_campaign_export(request: request, peer: peer, role: role, body: false) if request.fetch("operation") == "campaign_export_result"
87
+ return authorize_campaign_round!(request: request, peer: peer, role: role) if request.fetch("operation") == "campaign_record_round"
88
+ return dispatch_prepared_fetch(request: request, peer: peer, role: role, body: false) if prepared_fetch?(request)
52
89
  return authorize_result_transfer!(request: request, peer: peer, role: role) if %w[submit_result evidence_fetch].include?(request.fetch("operation"))
53
90
  params, map = validate_request(request)
91
+ return authorize_service_settlement!(request, params, map, peer, role) if request.fetch("operation") == "complete_no_effect"
92
+ return authorize_publication_transfer!(request, params, map, peer, role) if request.fetch("operation") == "publication_challenge"
54
93
  return authorize_service_transfer!(request, params, map, peer, role) if %w[request_service begin_dispatch complete_service service_authorization].include?(request.fetch("operation"))
55
94
  @launch.with_assignment(params: params, map: map) do |journal, _registration|
56
95
  protected_journal!(journal)
@@ -58,7 +97,7 @@ module Ace
58
97
  origin = active_origin(events, params)
59
98
  if request.fetch("operation") == "submit_candidate"
60
99
  worker_or_launcher!(peer, role, map, origin)
61
- candidate_generation!(candidate(events), params)
100
+ candidate_generation!(candidate(events), params) unless candidate_submission_replay!(journal, events, request, params)
62
101
  else
63
102
  export_admission!(journal, events, params, map, origin, peer, role)
64
103
  end
@@ -67,9 +106,23 @@ module Ace
67
106
  end
68
107
 
69
108
  def dispatch(request:, peer:, role:, transfer: nil)
70
- return dispatch_inbox(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "reconcile_inbox"
109
+ return dispatch_campaign_export(request: request, peer: peer, role: role) if request.fetch("operation") == "campaign_export_result"
110
+ return dispatch_campaign_round(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "campaign_record_round"
111
+ if prepared_fetch?(request)
112
+ raise ArgumentError, "prepared fetch forbids upload" unless transfer.nil?
113
+ return dispatch_prepared_fetch(request: request, peer: peer, role: role)
114
+ end
115
+ return dispatch_bind_inbox(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "bind_inbox"
116
+ return dispatch_finish(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "finish"
117
+ return dispatch_recover(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "recover"
71
118
  return dispatch_result(request: request, peer: peer, role: role, transfer: transfer) if %w[submit_result evidence_fetch].include?(request.fetch("operation"))
119
+ return review_status(request, peer, role, transfer) if request.fetch("operation") == "review_status"
72
120
  params, map = validate_request(request)
121
+ return request_review(request, params, map, peer, role, transfer) if request.fetch("operation") == "request_review"
122
+ return cancel_review(request, params, map, peer, role, transfer) if request.fetch("operation") == "cancel_review"
123
+ return dispatch_publication(request, params, map, peer, role, transfer) if %w[publication_challenge publication_continue].include?(request.fetch("operation"))
124
+ return dispatch_service_settlement(request, params, map, peer, role, transfer) if %w[claim_service_settlement complete_no_effect].include?(request.fetch("operation"))
125
+ return workspace_prune_preview_context(request, params, map, peer, role, transfer) if request.fetch("operation") == "workspace_prune_preview_context"
73
126
  return service_status(request, params, map, peer, role) if request.fetch("operation") == "service_status"
74
127
  return dispatch_service(request, params, map, peer, role, transfer) if %w[request_service begin_dispatch complete_service service_authorization].include?(request.fetch("operation"))
75
128
  admitted = nil
@@ -107,7 +160,9 @@ module Ace
107
160
  worker_or_launcher!(peer, role, map, origin,
108
161
  launcher_only: request.fetch("operation") == "assign_review")
109
162
  end
110
- journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
163
+ delegated_review_request!(journal, events, request, params, peer, role) if request.fetch("operation") == "assign_review"
164
+ candidate_submission_replay!(journal, events, request, params, admitted: admitted) if request.fetch("operation") == "submit_candidate"
165
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
111
166
  mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
112
167
  parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
113
168
  with_replay: true) do |events, _commit, _generation|
@@ -125,6 +180,15 @@ module Ace
125
180
  when "assign_review"
126
181
  worker_or_launcher!(peer, role, map, origin, launcher_only: true)
127
182
  current = exact_candidate!(candidate(events), params)
183
+ delegated = delegated_review_request!(journal, events, request, params, peer, role)
184
+ reservation = active_review_reservation(events, current)
185
+ if delegated
186
+ unless reservation && reservation.fetch("digest") == delegated.fetch("digest") && !active_review_event(events, current)
187
+ raise AttemptErrors::Conflict, "delegated review reservation is no longer assignable"
188
+ end
189
+ elsif reservation
190
+ raise AttemptErrors::Conflict, "candidate review reservation is active"
191
+ end
128
192
  reviewer = params.fetch("reviewer_process_binding")
129
193
  uid = params.fetch("reviewer_uid")
130
194
  project = @deployment.project(map.fetch("project_id"))
@@ -142,6 +206,11 @@ module Ace
142
206
  accept_review_plan(journal, events, params, map, current, review, admitted)
143
207
  end
144
208
  end
209
+ if request.fetch("operation") == "assign_review"
210
+ review_event_reply(journal, result, request, params, "assignment_event_id")
211
+ else
212
+ result
213
+ end
145
214
  end
146
215
  end
147
216
 
@@ -162,11 +231,11 @@ module Ace
162
231
  %w[mapping_id assignment_id attempt_id].each do |key|
163
232
  raise ArgumentError, "invalid Endcap identifier" unless params[key].is_a?(String) && params[key].match?(Molecules::JournalMutation::ID)
164
233
  end
165
- unless params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
166
- params["candidate_generation"].is_a?(Integer) && params["candidate_generation"] >= 0
234
+ unless operation == "workspace_prune_preview_context" || (params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
235
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"] >= 0)
167
236
  raise ArgumentError, "invalid candidate binding"
168
237
  end
169
- if %w[service_authorization service_status].include?(operation) && !request.fetch("mutation_id").nil?
238
+ if %w[service_authorization service_status workspace_prune_preview_context].include?(operation) && !request.fetch("mutation_id").nil?
170
239
  raise ArgumentError, "authorization read must not supply a mutation ID"
171
240
  end
172
241
  map = @deployment.mapping(params.fetch("mapping_id"))
@@ -209,6 +278,34 @@ module Ace
209
278
  event&.dig("payload", "data")
210
279
  end
211
280
 
281
+ # Header replay admission still requires the live original caller above.
282
+ # TransferCodec and CandidateTransfer verify the exact body before dispatch;
283
+ # the descriptor is part of the original immutable parameter digest.
284
+ def candidate_submission_replay!(journal, events, request, params, admitted: nil)
285
+ replay = journal.mutation_result(request.fetch("mutation_id"))
286
+ return false unless replay
287
+ unless replay["operation"] == "submit_candidate" && replay["assignment_id"] == params.fetch("assignment_id") &&
288
+ replay["attempt_id"] == params.fetch("attempt_id") && replay["parameters_digest"] == Atoms::EvidenceDigest.digest(params)
289
+ raise AttemptErrors::Conflict, "candidate replay input differs"
290
+ end
291
+ data = replay.fetch("data")
292
+ retained = retained_candidate(events, data.fetch("candidate_generation"))
293
+ unless retained == data && data.fetch("candidate_generation") == params.fetch("candidate_generation") + 1 &&
294
+ data.fetch("head") == params.fetch("head")
295
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay provenance differs"
296
+ end
297
+ bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes: 64 * 1024 * 1024)
298
+ unless bytes.bytesize == data.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
299
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay bundle differs"
300
+ end
301
+ if admitted && admitted.values_at("head", "tree", "sha256", "bytes") != data.values_at("head", "tree", "sha256", "bytes")
302
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay normalized bundle differs"
303
+ end
304
+ true
305
+ rescue KeyError, TypeError
306
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay evidence is malformed"
307
+ end
308
+
212
309
  def candidate_generation!(current, params)
213
310
  unless (current && current.fetch("candidate_generation") || 0) == params.fetch("candidate_generation")
214
311
  raise AttemptErrors::Conflict, "candidate generation changed"
@@ -225,7 +322,7 @@ module Ace
225
322
  current = exact_candidate!(candidate(events), params)
226
323
  @kernel.live!(peer)
227
324
  if role == :reviewer
228
- assignment = assigned_review(events)
325
+ assignment = active_review_event(events, current)&.dig("payload", "data")
229
326
  unless assignment && assignment["review_id"] == params["purpose_id"] && assignment["head"] == current["head"] &&
230
327
  assignment["candidate_generation"] == current["candidate_generation"] && assignment["reviewer_uid"] == peer["uid"] &&
231
328
  @kernel.descendant?(peer, assignment.fetch("reviewer_process_binding"))
@@ -251,10 +348,10 @@ module Ace
251
348
  # A retained approval grants permission only while its original imported
252
349
  # bytes, independent reviewer and exact candidate still verify. An old
253
350
  # successful mutation reply is not a substitute for canonical evidence.
254
- def approved_review!(journal, events, params, map, current)
351
+ def approved_review!(journal, events, params, map, current, commit: journal.ref_value, service_operation: nil)
255
352
  accepted = events.reverse.find { |event| event["type"] == "authority_mutation" &&
256
353
  event.dig("payload", "operation") == "accept_review" }&.dig("payload", "data")
257
- review = assigned_review(events)
354
+ review = active_review_event(events, current)&.dig("payload", "data")
258
355
  unless accepted && review && accepted["head"] == current["head"] &&
259
356
  accepted["candidate_generation"] == current["candidate_generation"] &&
260
357
  accepted["review_id"] == review["review_id"] && accepted["reviewer_uid"] == review["reviewer_uid"] &&
@@ -267,7 +364,6 @@ module Ace
267
364
  binding: accepted.fetch("review_binding"), request_id_or_event_id: review.fetch("review_id"),
268
365
  generation: current.fetch("candidate_generation")}
269
366
  canonical = Molecules::CanonicalEvidence.new(journal: journal)
270
- commit = journal.ref_value
271
367
  reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
272
368
  **context, commit: commit) }
273
369
  intent = events.find { |event| event["type"] == "intent" }
@@ -280,6 +376,9 @@ module Ace
280
376
  receipt.to_h.dig("review", "reviewer", "actor") == review.fetch("reviewer_actor")
281
377
  raise AttemptErrors::ReceiptRejected, "canonical review receipt differs"
282
378
  end
379
+ if %w[ready merge].include?(service_operation)
380
+ campaign_service_checks!(journal: journal, commit: commit, events: events, params: params, map: map, current: current)
381
+ end
283
382
  accepted
284
383
  rescue KeyError
285
384
  raise AttemptErrors::ReceiptRejected, "canonical review binding is incomplete"
@@ -336,7 +435,21 @@ module Ace
336
435
  end
337
436
 
338
437
  require_relative "endcap_services"
438
+ require_relative "endcap_service_settlement"
339
439
 
440
+ require_relative "endcap_campaign_results"
441
+ require_relative "endcap_campaign_rounds"
340
442
  require_relative "endcap_results"
341
443
 
342
444
  require_relative "endcap_inboxes"
445
+
446
+ require_relative "endcap_contexts"
447
+ require_relative "endcap_prepared_work"
448
+
449
+ require_relative "endcap_reviews"
450
+
451
+ require_relative "endcap_review_requests"
452
+
453
+ require_relative "endcap_attempts"
454
+
455
+ require_relative "endcap_publication"
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ BIND_INBOX_PARAMETERS = %w[mapping_id assignment_id attempt_id expected_generation event_id inbox_context_id].freeze
8
+ FINISH_PARAMETERS = %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head result_id].freeze
9
+ RECOVERY_PARAMETERS = %w[mapping_id assignment_id attempt_id expected_generation].freeze
10
+
11
+ # Called only by the canonical terminal owner at its authenticated
12
+ # introduction prefix, using the original deployment mapping.
13
+ def finished_review_evidence!(journal:, events:, params:, map:, commit:)
14
+ current = exact_candidate!(retained_candidate(events, params.fetch("candidate_generation")), params)
15
+ accepted = approved_review!(journal, events, params, map, current, commit: commit)
16
+ result = verified_result(journal, events, params, map, retained_origin(events, params), current, commit)
17
+ raise AttemptErrors::EvidenceUnavailable, "finished canonical result is missing" unless result
18
+ campaign_finished_result!(journal: journal, commit: commit, events: events, params: params, map: map,
19
+ result: result, accepted: accepted)
20
+ @launch.campaign_children_settled!(journal: journal, commit: commit, params: params, map: map)
21
+ accepted
22
+ end
23
+
24
+ private
25
+
26
+ def dispatch_recover(request:, peer:, role:, transfer: nil)
27
+ raise ArgumentError, "Recovery forbids transfer" unless transfer.nil?
28
+ params, map = attempt_consumer_request!(request, RECOVERY_PARAMETERS)
29
+ selected_commit = nil
30
+ journal = @launch.journals.fetch(map.fetch("project_id"))
31
+ readonly = @launch.with_assignment(params: params, map: map) do |selected, _|
32
+ protected_journal!(selected)
33
+ selected_commit = selected.ref_value
34
+ events = @launch.preview_attempt_events!(journal: selected, commit: selected_commit, params: params, map: map)
35
+ finish_identity!(events, params, map, peer, role)
36
+ accepted = selected.mutation_result(request.fetch("mutation_id"), commit: selected_commit)
37
+ if accepted
38
+ verify_recovery_reply!(selected, accepted, request, params, map)
39
+ next({data: accepted.fetch("data").merge("journal_commit" => accepted.fetch("journal_commit")), replayed: true})
40
+ end
41
+ state = Molecules::CanonicalAttemptState.derive(events)
42
+ next unless %w[succeeded failed stopped].include?(state)
43
+ terminal = @launch.completion_terminal!(journal: selected, commit: selected_commit, params: params, map: map)
44
+ {data: {"attempt_id" => params.fetch("attempt_id"), "state" => terminal.fetch("canonical_state"),
45
+ "decision" => "restart-required", "reason" => "terminal_attempt", "generation" => terminal.fetch("generation"),
46
+ "journal_commit" => selected_commit}, replayed: false}
47
+ end
48
+ return readonly if readonly
49
+ recover_under_exclusion!(request, params, map, peer, role, selected_commit)
50
+ end
51
+
52
+ def recover_under_exclusion!(request, params, map, peer, role, selected_commit)
53
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, _|
54
+ unless journal.ref_value == selected_commit
55
+ raise AttemptErrors::Conflict, "Recovery canonical selection advanced"
56
+ end
57
+ events = @launch.preview_attempt_events!(journal: journal, commit: selected_commit, params: params, map: map)
58
+ finish_identity!(events, params, map, peer, role)
59
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
60
+ mutation_id: request.fetch("mutation_id"), operation: "recover", parameters_digest: Atoms::EvidenceDigest.digest(params),
61
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |fresh, prefix, _generation|
62
+ unless prefix == selected_commit
63
+ raise AttemptErrors::Conflict, "Recovery canonical selection advanced"
64
+ end
65
+ origin = finish_identity!(fresh, params, map, peer, role)
66
+ state = Molecules::CanonicalAttemptState.derive(fresh)
67
+ effects_pending = recovery_pending_settlement do
68
+ service_settlement_evidence!(journal: journal, events: fresh, params: params, map: map, commit: prefix)
69
+ end
70
+ bound = %w[bound issued].include?(origin["phase"])
71
+ observation = if !effects_pending && state == "running" && bound
72
+ @launch.recovery_observation!(journal: journal, events: fresh, params: params, map: map, commit: prefix)
73
+ else
74
+ {"liveness" => "unknown"}
75
+ end
76
+ decision, reason = Organisms::AttemptCoordinator.protected_recovery_decision(state: state,
77
+ effects_pending: effects_pending, bound: bound,
78
+ live: observation["liveness"] == "live", checkpoint: true)
79
+ plan = Organisms::AttemptCoordinator.protected_recovery_plan(events: fresh, decision: decision, reason: reason, observation: observation)
80
+ plan.except(:state).merge(data: {"attempt_id" => params.fetch("attempt_id"), "state" => plan.fetch(:state),
81
+ "decision" => decision, "reason" => reason})
82
+ end
83
+ end
84
+ end
85
+
86
+ def recovery_pending_settlement
87
+ yield
88
+ false
89
+ rescue AttemptErrors::ServiceSettlementPending
90
+ true
91
+ end
92
+
93
+ def verify_recovery_reply!(journal, accepted, request, params, map)
94
+ unless accepted["operation"] == "recover" && accepted["parameters_digest"] == Atoms::EvidenceDigest.digest(params) &&
95
+ accepted.values_at("assignment_id", "attempt_id") == params.values_at("assignment_id", "attempt_id")
96
+ raise AttemptErrors::Conflict, "Recovery mutation is already bound to different input"
97
+ end
98
+ prefix = accepted.fetch("journal_commit")
99
+ events = @launch.preview_attempt_events!(journal: journal, commit: prefix, params: params, map: map)
100
+ index = events.index { |event| event.dig("payload", "mutation_id") == request.fetch("mutation_id") && event.dig("payload", "operation") == "recover" }
101
+ raise AttemptErrors::EvidenceUnavailable, "Recovery acceptance is missing" unless index && index.positive?
102
+ before = events.take(index)
103
+ transition = before.last["type"] == "transition" && before.last.dig("payload", "to") == "uncertain" ? before.pop : nil
104
+ observation = before.last
105
+ data = accepted.fetch("data")
106
+ reasons = %w[live_owner unresolved_effect unresolved_inbox launch_unbound scope_unverifiable attempt_uncertain checkpoint_unavailable]
107
+ unless data.is_a?(Hash) && data.keys.sort == %w[attempt_id state decision reason generation].sort &&
108
+ data["attempt_id"] == params.fetch("attempt_id") && data["generation"].is_a?(Integer) &&
109
+ data["generation"] == journal.authority_generation(events) && reasons.include?(data["reason"]) &&
110
+ ((data["decision"] == "adopt" && data["reason"] == "live_owner" && data["state"] == "running") ||
111
+ (data["decision"] == "reconcile-required" && data["reason"] != "live_owner")) &&
112
+ events[index]["type"] == "authority_mutation" && index == events.length - 1
113
+ raise AttemptErrors::EvidenceUnavailable, "Recovery reply schema or accepted prefix differs"
114
+ end
115
+ unless observation && observation["type"] == "recovery_observation" && observation.dig("payload", "decision") == data["decision"] &&
116
+ observation.dig("payload", "recovery_reason") == data["reason"] && observation.dig("payload", "observation").is_a?(Hash) &&
117
+ (!transition || transition.fetch("payload") == {"from" => "running", "to" => "uncertain", "reason" => data.fetch("reason")}) &&
118
+ Molecules::CanonicalAttemptState.derive(events) == data.fetch("state")
119
+ raise AttemptErrors::EvidenceUnavailable, "Recovery observation acceptance differs"
120
+ end
121
+ binding = retained_origin(events, params).fetch("process_binding", nil)
122
+ observed = observation.dig("payload", "observation")
123
+ if observed["liveness"] == "live" && (!binding || observed["runtime_binding"] != binding || observed["identity"] != binding.fetch("process_identity"))
124
+ raise AttemptErrors::EvidenceUnavailable, "Recovery original live binding differs"
125
+ end
126
+ true
127
+ end
128
+
129
+ def dispatch_finish(request:, peer:, role:, transfer: nil)
130
+ raise ArgumentError, "Result finish forbids transfer" unless transfer.nil?
131
+ params, map = attempt_consumer_request!(request, FINISH_PARAMETERS)
132
+ unless params["candidate_generation"].is_a?(Integer) && params["candidate_generation"].positive? &&
133
+ params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
134
+ params["result_id"].is_a?(String) && params["result_id"].match?(/\A[0-9a-f]{32}\z/)
135
+ raise ArgumentError, "Result finish selectors differ"
136
+ end
137
+ journal = @launch.journals.fetch(map.fetch("project_id"))
138
+ replay = @launch.with_assignment(params: params, map: map) do |selected, _|
139
+ commit = selected.ref_value
140
+ accepted = selected.mutation_result(request.fetch("mutation_id"), commit: commit)
141
+ next unless accepted
142
+ unless accepted["operation"] == "finish" && accepted["parameters_digest"] == Atoms::EvidenceDigest.digest(params) &&
143
+ accepted.values_at("assignment_id", "attempt_id") == params.values_at("assignment_id", "attempt_id")
144
+ raise AttemptErrors::Conflict, "Finish mutation is already bound to different input"
145
+ end
146
+ events = @launch.preview_attempt_events!(journal: selected, commit: commit, params: params, map: map)
147
+ finish_identity!(events, params, map, peer, role)
148
+ @launch.completion_terminal!(journal: selected, commit: commit, params: params, map: map)
149
+ {data: accepted.fetch("data").merge("journal_commit" => accepted.fetch("journal_commit")), replayed: true}
150
+ end
151
+ return replay if replay
152
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |selected, _|
153
+ protected_journal!(selected)
154
+ commit = selected.ref_value
155
+ events = @launch.preview_attempt_events!(journal: selected, commit: commit, params: params, map: map)
156
+ finish_identity!(events, params, map, peer, role)
157
+ replay = selected.mutation_result(request.fetch("mutation_id"), commit: commit)
158
+ if replay && replay["operation"] == "finish"
159
+ @launch.completion_terminal!(journal: selected, commit: commit, params: params, map: map)
160
+ end
161
+ with_campaign_finish!(journal: selected, commit: commit, events: events, params: params, map: map) do
162
+ selected.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
163
+ mutation_id: request.fetch("mutation_id"), operation: "finish", parameters_digest: Atoms::EvidenceDigest.digest(params),
164
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |fresh, prefix, _generation|
165
+ origin = finish_identity!(fresh, params, map, peer, role)
166
+ unless Molecules::CanonicalAttemptState.derive(fresh) == "running" && %w[bound issued].include?(origin["phase"])
167
+ raise AttemptErrors::Conflict, "Result finish requires an active bound attempt"
168
+ end
169
+ current = exact_candidate!(candidate(fresh), params)
170
+ result = verified_result(selected, fresh, params, map, origin, current, prefix, result_id: params.fetch("result_id"))
171
+ unless result && result.fetch("result_id") == params.fetch("result_id")
172
+ raise AttemptErrors::EvidenceUnavailable, "Result finish requires its exact submitted receipt"
173
+ end
174
+ if result.dig("receipt", "verdict") == "succeeded"
175
+ accepted = approved_review!(selected, fresh, params, map, current, commit: prefix)
176
+ campaign_finished_result!(journal: selected, commit: prefix, events: fresh, params: params, map: map,
177
+ result: result, accepted: accepted)
178
+ end
179
+ @launch.campaign_children_settled!(journal: selected, commit: prefix, params: params, map: map)
180
+ @launch.completion_scope!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
181
+ service_settlement_evidence!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
182
+ plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"))
183
+ plan.merge(data: params.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
184
+ "receipt_digest" => result.fetch("receipt_digest"), "state" => result.dig("receipt", "verdict")))
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ def finish_identity!(events, params, map, peer, role)
191
+ unless %i[launcher supervisor].include?(role)
192
+ raise AttemptErrors::UnauthorizedIdentity, "Result finish requires mapped completion owner"
193
+ end
194
+ origin = retained_origin(events, params)
195
+ purpose_peer!(events, params, map, origin, peer, role, kind: "result")
196
+ origin
197
+ end
198
+
199
+ def attempt_consumer_request!(request, fields)
200
+ params = request.fetch("params")
201
+ unless params.is_a?(Hash) && params.keys.sort == fields.sort
202
+ raise ArgumentError, "attempt consumer parameters differ"
203
+ end
204
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
205
+ result_id!(request.fetch("mutation_id"))
206
+ unless params["expected_generation"].is_a?(Integer) && params["expected_generation"] >= 0
207
+ raise ArgumentError, "attempt consumer generation differs"
208
+ end
209
+ map = @deployment.mapping(params.fetch("mapping_id"))
210
+ unless map.fetch("project_id") == request.fetch("project_id")
211
+ raise AttemptErrors::UnauthorizedIdentity, "attempt consumer project differs"
212
+ end
213
+ [params, map]
214
+ end
215
+
216
+ # Original registration comes from the admitted fixed Inbox owner, not
217
+ # from peer fields or a current filesystem receipt. Bind and seal share
218
+ # the same canonical slot/task/assignment exclusion and CAS.
219
+ def dispatch_bind_inbox(request:, peer:, role:, transfer: nil)
220
+ raise ArgumentError, "Inbox binding forbids transfer" unless transfer.nil?
221
+ params, map = attempt_consumer_request!(request, BIND_INBOX_PARAMETERS)
222
+ %w[event_id inbox_context_id].each { |key| result_id!(params.fetch(key)) }
223
+ with_inbox_context(params, map, mutation_id: request.fetch("mutation_id")) do |session|
224
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, _|
225
+ protected_journal!(journal)
226
+ commit = journal.ref_value
227
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: map)
228
+ binding = bind_inbox_selection!(events, params, map, peer, role, session)
229
+ replay = journal.mutation_result(request.fetch("mutation_id"), commit: commit)
230
+ verify_inbox_binding_reply!(events, replay, binding) if replay && replay["operation"] == "bind_inbox"
231
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
232
+ mutation_id: request.fetch("mutation_id"), operation: "bind_inbox", parameters_digest: Atoms::EvidenceDigest.digest(params),
233
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |fresh, _prefix, _generation|
234
+ @launch.scope_open_for_effect!(events: fresh, params: params, map: map)
235
+ selected = bind_inbox_selection!(fresh, params, map, peer, role, session)
236
+ existing = fresh.select { |event| event["type"] == "inbox_binding" && event.dig("payload", "event_id") == params.fetch("event_id") }
237
+ unless existing.empty? || (existing.one? && existing.first.fetch("payload") == selected)
238
+ raise AttemptErrors::Conflict, "Inbox registration is already bound differently"
239
+ end
240
+ {events: existing.empty? ? [{type: "inbox_binding", payload: selected}] : [], blobs: {}, data: selected}
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ def bind_inbox_selection!(events, params, map, peer, role, session)
247
+ unless Molecules::CanonicalAttemptState.derive(events) == "running"
248
+ raise AttemptErrors::EvidenceUnavailable, "Inbox binding requires an active original worker"
249
+ end
250
+ submitting_worker!(events, params, map, peer, role)
251
+ context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
252
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
253
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: context.fetch("native_mapping_id"))
254
+ original = params.slice("assignment_id", "mapping_id", "inbox_context_id").merge("project_id" => map.fetch("project_id"))
255
+ record = session.request("status_context", {"event_id" => params.fetch("event_id"),
256
+ "attempt_id" => params.fetch("attempt_id"), "original" => original}).fetch("record")
257
+ registration = record.slice(*INBOX_REGISTRATION_FIELDS)
258
+ unless inbox_registration?(registration) && registration.values_at("event_id", "attempt_id") == params.values_at("event_id", "attempt_id")
259
+ raise AttemptErrors::EvidenceUnavailable, "Inbox original registration differs"
260
+ end
261
+ inbox_native_lineage!(record, lineage)
262
+ params.slice("event_id", "attempt_id", "inbox_context_id").merge("registration" => registration)
263
+ end
264
+
265
+ def verify_inbox_binding_reply!(events, reply, selected)
266
+ bindings = events.select { |event| event["type"] == "inbox_binding" && event.fetch("payload") == selected }
267
+ unless bindings.one? && reply.fetch("data").except("generation") == selected
268
+ raise AttemptErrors::EvidenceUnavailable, "Inbox binding replay provenance differs"
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end