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
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Fixed source readers reuse the complete canonical inventory and its
8
+ # original registration/definition proof; this is never a live grant.
9
+ def preview_attempt_events!(journal:, commit:, params:, map:)
10
+ preview_attempt_entry!(journal: journal, commit: commit, params: params, map: map).fetch(:events)
11
+ end
12
+
13
+ def preview_attempt_definition!(journal:, commit:, params:, map:)
14
+ entry = preview_attempt_entry!(journal: journal, commit: commit, params: params, map: map)
15
+ inventory_definition!(journal, commit, entry)
16
+ end
17
+
18
+ private
19
+
20
+ def preview_attempt_entry!(journal:, commit:, params:, map:)
21
+ entries = inventory_index!(journal, commit, params.fetch("mapping_id"), map).select do |entry|
22
+ entry.fetch(:selector) == params.slice("assignment_id", "attempt_id")
23
+ end
24
+ raise AttemptErrors::EvidenceUnavailable, "preview original registration is unavailable or ambiguous" unless entries.one?
25
+ entry = entries.first
26
+ inventory_definition!(journal, commit, entry)
27
+ entry
28
+ end
29
+
30
+ # Discovery authenticates the current mapped principal, then reads one
31
+ # immutable canonical prefix. It neither pins the original launcher nor
32
+ # grants permission to mutate that launch.
33
+ def assignment_inventory!(request:, peer:, role:)
34
+ params = request.fetch("params")
35
+ strict!(params, %w[mapping_id journal_commit after limit])
36
+ raise ArgumentError, "Inventory mutation ID must be null" unless request.fetch("mutation_id").nil?
37
+ unless %i[launcher supervisor].include?(role)
38
+ raise AttemptErrors::UnauthorizedIdentity, "Inventory requires mapped launcher or supervisor"
39
+ end
40
+ map = steering_principal!(params, peer, role)
41
+ limit = params.fetch("limit")
42
+ raise ArgumentError, "Inventory limit is invalid" unless limit.is_a?(Integer) && limit.between?(1, 50)
43
+ after = params.fetch("after")
44
+ unless after.nil?
45
+ strict!(after, %w[assignment_id attempt_id])
46
+ token!(after.fetch("assignment_id"))
47
+ token!(after.fetch("attempt_id")) unless after.fetch("attempt_id").nil?
48
+ raise ArgumentError, "Inventory continuation requires selected commit" if params.fetch("journal_commit").nil?
49
+ end
50
+ journal = journal_for(map)
51
+ canonical_commit = journal.ref_value
52
+ commit = params.fetch("journal_commit") || canonical_commit
53
+ journal.verify_canonical_prefix!(commit: commit, canonical_commit: canonical_commit)
54
+ indexed = inventory_index!(journal, commit, params.fetch("mapping_id"), map)
55
+ offset = 0
56
+ if after
57
+ position = indexed.index { |entry| entry.fetch(:selector) == after }
58
+ raise AttemptErrors::NotFound, "Inventory cursor does not select a visible row" unless position
59
+ offset = position + 1
60
+ end
61
+ data = {"project_id" => map.fetch("project_id"), "mapping_id" => params.fetch("mapping_id"),
62
+ "journal_commit" => commit, "items" => [], "next_after" => nil}
63
+ indexed.drop(offset).first(limit).each do |entry|
64
+ row = inventory_row!(journal, commit, entry)
65
+ proposed = data.merge("items" => data.fetch("items") + [row],
66
+ "next_after" => offset + data.fetch("items").size + 1 < indexed.size ? entry.fetch(:selector) : nil)
67
+ # Size the actual fixed public envelope, retaining whole rows and
68
+ # an honest cursor without truncating or omitting canonical facts.
69
+ frame = {"status" => "ok", "data" => proposed, "transport" => {"replayed" => false}}
70
+ if JSON.generate(frame).bytesize + 1 > 16_384
71
+ raise AttemptErrors::BoundedResultUnavailable, "Inventory row exceeds bounded response" if data.fetch("items").empty?
72
+ break
73
+ end
74
+ data = proposed
75
+ end
76
+ {data: data, replayed: false}
77
+ end
78
+
79
+ def inventory_index!(journal, commit, mapping_id, map)
80
+ authenticated = journal.canonical_event_inventory!(commit: commit)
81
+ raise AttemptErrors::EvidenceUnavailable, "Inventory authenticated snapshot differs" unless authenticated.fetch("commit") == commit
82
+ snapshots = authenticated.fetch("events")
83
+ introductions = authenticated.fetch("introductions")
84
+ entries = snapshots.flat_map do |assignment_id, events|
85
+ registrations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "register_assignment" }
86
+ next [] if registrations.empty?
87
+ attempts = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
88
+ if attempts.empty?
89
+ registration = registrations.last.fetch("payload").fetch("data")
90
+ next [] unless registration["mapping_id"] == mapping_id
91
+ authenticate_inventory_registration!(journal, assignment_id, events, registration, mapping_id, map, commit, introductions.fetch(assignment_id))
92
+ next [{selector: {"assignment_id" => assignment_id, "attempt_id" => nil}, registration: registration, map: map}]
93
+ end
94
+ selected = attempts.select { |event| event.dig("payload", "data", "mapping_id") == mapping_id }
95
+ next [] if selected.empty?
96
+ prefixes = introductions.fetch(assignment_id)
97
+ selected.map do |reserve|
98
+ attempt_id = reserve.fetch("attempt_id")
99
+ chain = events.select { |event| event["attempt_id"] == attempt_id }
100
+ registration = definition(journal, assignment_id, commit: prefixes.fetch(reserve.fetch("digest")))
101
+ unless registration && registration.values_at("assignment_id", "project_id", "mapping_id") == [assignment_id, map.fetch("project_id"), mapping_id]
102
+ raise AttemptErrors::EvidenceUnavailable, "Inventory reservation registration differs"
103
+ end
104
+ registration_events = journal.read_events(assignment_id, commit: prefixes.fetch(reserve.fetch("digest")))
105
+ authenticate_inventory_registration!(journal, assignment_id, registration_events, registration, mapping_id, map, prefixes.fetch(reserve.fetch("digest")), prefixes)
106
+ {selector: {"assignment_id" => assignment_id, "attempt_id" => attempt_id},
107
+ registration: registration, events: chain, map: map, introductions: prefixes}
108
+ end
109
+ end
110
+ selectors = entries.map { |entry| entry.fetch(:selector).values_at("assignment_id", "attempt_id") }
111
+ raise AttemptErrors::EvidenceUnavailable, "Inventory row identity is ambiguous" unless selectors.uniq.size == selectors.size
112
+ entries.sort_by { |entry| [entry.fetch(:selector).fetch("assignment_id").b, entry.fetch(:selector).fetch("attempt_id").to_s.b] }
113
+ rescue KeyError, TypeError, NoMethodError
114
+ raise AttemptErrors::EvidenceUnavailable, "Inventory canonical index is malformed"
115
+ end
116
+
117
+ def authenticate_inventory_registration!(journal, assignment_id, events, registration, mapping_id, map, commit, introductions)
118
+ unless registration.is_a?(Hash) && registration.values_at("assignment_id", "project_id", "mapping_id", "phase") ==
119
+ [assignment_id, map.fetch("project_id"), mapping_id, "registered"]
120
+ raise AttemptErrors::EvidenceUnavailable, "Inventory accepted registration association differs"
121
+ end
122
+ chain = events.select { |event| event["attempt_id"] == "definition-#{assignment_id}" }
123
+ journal.authority_generation(chain)
124
+ accepted = chain.select { |event| event["type"] == "authority_mutation" &&
125
+ event.dig("payload", "operation") == "register_assignment" && event.dig("payload", "data") == registration }
126
+ raise AttemptErrors::EvidenceUnavailable, "Inventory registration provenance is ambiguous" unless accepted.one?
127
+ event = accepted.first
128
+ prefix = introductions.fetch(event.fetch("digest"))
129
+ original_chain = journal.read_events(assignment_id, commit: prefix).select { |item| item["attempt_id"] == "definition-#{assignment_id}" }
130
+ unless original_chain.last == event && registration["generation"].is_a?(Integer) &&
131
+ registration["generation"] == journal.authority_generation(original_chain)
132
+ raise AttemptErrors::EvidenceUnavailable, "Inventory registration generation/introduction differs"
133
+ end
134
+ end
135
+
136
+ def inventory_definition!(journal, commit, entry)
137
+ selector, registration = entry.values_at(:selector, :registration)
138
+ bytes = journal.blob(registration.fetch("definition_ref"), commit: commit)
139
+ unless bytes.is_a?(String) && bytes.bytesize <= 32_768 &&
140
+ Digest::SHA256.hexdigest(bytes) == registration.fetch("definition_digest")
141
+ raise AttemptErrors::EvidenceUnavailable, "Inventory definition content differs"
142
+ end
143
+ value = JSON.parse(bytes)
144
+ prepared = value.fetch("prepared_work")
145
+ unless registration.fetch("prepared_work") == prepared && prepared.fetch("task_id") == registration.fetch("task_id") &&
146
+ registration.fetch("selection_sha256") == prepared.fetch("selection_sha256") &&
147
+ registration.fetch("prepared_bundle_sha256").is_a?(String) && registration.fetch("prepared_bundle_sha256").match?(PreparedWork::SHA) &&
148
+ registration.fetch("prepared_bundle_bytes").is_a?(Integer) && registration.fetch("prepared_bundle_bytes").between?(1, PreparedWork::MAX_TOTAL) &&
149
+ registration.fetch("prepared_bundle_ref") == "execution/prepared/#{selector.fetch('assignment_id')}-#{registration.fetch('prepared_bundle_sha256')}.bundle"
150
+ raise AttemptErrors::EvidenceUnavailable, "Inventory prepared input identity differs"
151
+ end
152
+ assignment = Models::Assignment.from_h(value)
153
+ unless assignment.managed? && assignment.id == selector.fetch("assignment_id") &&
154
+ assignment.project_id == registration.fetch("project_id") && assignment.task_id == registration.fetch("task_id") &&
155
+ registration.fetch("definition_generation").is_a?(Integer) && registration.fetch("definition_generation").positive?
156
+ raise AttemptErrors::EvidenceUnavailable, "Inventory definition provenance differs"
157
+ end
158
+ assignment
159
+ end
160
+
161
+ def inventory_row!(journal, commit, entry)
162
+ inventory_definition!(journal, commit, entry)
163
+ selector, registration = entry.values_at(:selector, :registration)
164
+ row = selector.merge(registration.slice("task_id", "definition_digest", "definition_generation", "prepared_bundle_ref", "prepared_bundle_bytes", "prepared_bundle_sha256", "selection_sha256"),
165
+ "scope" => nil, "reservation_mutation_id" => nil, "base_head" => nil, "reservation_generation" => nil, "generation" => nil, "canonical_state" => nil,
166
+ "original_binding_digest" => nil, "terminal_event_id" => nil, "reservation_release_event_id" => nil)
167
+ return row unless selector.fetch("attempt_id")
168
+ events = entry.fetch(:events)
169
+ map = entry.fetch(:map)
170
+ state = origin(events, **selector.merge("mapping_id" => registration.fetch("mapping_id")).transform_keys(&:to_sym))
171
+ generation = journal.authority_generation(events)
172
+ unless generation.is_a?(Integer) && generation.positive? && state.fetch("reservation_generation").is_a?(Integer) && state.fetch("reservation_generation").positive?
173
+ raise AttemptErrors::EvidenceUnavailable, "Inventory attempt generation is invalid"
174
+ end
175
+ reservations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
176
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original reservation is ambiguous" unless reservations.one?
177
+ reservation = reservations.first.fetch("payload")
178
+ mutation_id = reservation.fetch("mutation_id")
179
+ token!(mutation_id)
180
+ base_head = reservation.fetch("data").fetch("base_head")
181
+ unless base_head.is_a?(String) && base_head.match?(/\A[0-9a-f]{40}\z/) && state.fetch("base_head") == base_head
182
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original reservation base differs"
183
+ end
184
+ row.merge!("scope" => state.fetch("scope"), "reservation_mutation_id" => mutation_id, "base_head" => base_head, "reservation_generation" => state.fetch("reservation_generation"),
185
+ "generation" => generation, "canonical_state" => journal.canonical_attempt_state(events))
186
+ if events.any? { |event| event.dig("payload", "operation") == "record_launch" }
187
+ row["original_binding_digest"] = original_prompt_record!(events, state,
188
+ params: selector.merge("mapping_id" => registration.fetch("mapping_id"))).fetch("binding_digest")
189
+ end
190
+ terminal_events = events.select { |event| %w[receipt_accepted attempt_stopped].include?(event["type"]) }
191
+ releases = events.select { |event| event.dig("payload", "operation") == "scope_reservation_release" }
192
+ guarded_abort = events.any? { |event| event.dig("payload", "operation") == "abort_launch" }
193
+ return row if terminal_events.empty? && releases.empty? && !guarded_abort
194
+ original, original_map = inventory_original_deployment!(journal, events, registration.fetch("mapping_id"), map)
195
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: original_map.fetch("project_id"),
196
+ assignment_id: selector.fetch("assignment_id"), attempt_id: selector.fetch("attempt_id"), mapping_id: registration.fetch("mapping_id"))
197
+ introductions = entry.fetch(:introductions)
198
+ terminal_introduction = nil
199
+ terminal = if events.any? { |event| event["type"] == "attempt_stopped" }
200
+ stopped = events.find { |event| event["type"] == "attempt_stopped" }
201
+ accepted = events.select { |event| event["type"] == "authority_mutation" && event["previous_digest"] == stopped.fetch("digest") }
202
+ raise AttemptErrors::EvidenceUnavailable, "Inventory stopped acceptance is ambiguous" unless accepted.one?
203
+ terminal_introduction = introductions.fetch(accepted.first.fetch("digest"))
204
+ verify_stopped_terminal_at_prefix!(events, journal, terminal_introduction, deployment: original)
205
+ else
206
+ inventory_terminal_at_introduction!(journal, selector, events, original, original_map, registration.fetch("mapping_id"), introductions)
207
+ end
208
+ row["terminal_event_id"] = terminal.fetch("digest")
209
+ unless releases.empty?
210
+ raise AttemptErrors::EvidenceUnavailable, "Inventory release is ambiguous" unless releases.one?
211
+ release = releases.first
212
+ prefix = introductions.fetch(release.fetch("digest"))
213
+ verify_released_lineage_at_prefix!(journal, prefix, selector.fetch("assignment_id"), selector.fetch("attempt_id"), events, original, original_map, terminal_introduction: terminal_introduction)
214
+ row["reservation_release_event_id"] = release.fetch("digest")
215
+ end
216
+ row
217
+ rescue KeyError, TypeError, JSON::ParserError, NoMethodError
218
+ raise AttemptErrors::EvidenceUnavailable, "Inventory canonical row is malformed"
219
+ end
220
+
221
+ def inventory_terminal_at_introduction!(journal, selector, events, original, map, mapping_id, introductions)
222
+ terminals = events.select { |event| event["type"] == "receipt_accepted" ||
223
+ (event["type"] == "authority_mutation" && event.dig("payload", "operation") == "abort_launch") }
224
+ raise AttemptErrors::EvidenceUnavailable, "Inventory terminal provenance is ambiguous" unless terminals.one?
225
+ event = terminals.first
226
+ prefix_commit = introductions.fetch(event.fetch("digest"))
227
+ prefix = journal.read_events(selector.fetch("assignment_id"), commit: prefix_commit).select { |entry| entry["attempt_id"] == selector.fetch("attempt_id") }
228
+ terminal_length = events.index(event) + 1
229
+ if event["type"] == "receipt_accepted"
230
+ transition, acceptance = events.drop(terminal_length).take(2)
231
+ if transition && acceptance && transition["type"] == "transition" &&
232
+ acceptance["type"] == "authority_mutation" && acceptance.dig("payload", "operation") == "finish"
233
+ unless introductions.fetch(transition.fetch("digest")) == prefix_commit &&
234
+ introductions.fetch(acceptance.fetch("digest")) == prefix_commit
235
+ raise AttemptErrors::EvidenceUnavailable, "Inventory finish acceptance introduction differs"
236
+ end
237
+ terminal_length += 2
238
+ end
239
+ end
240
+ unless prefix == events.take(terminal_length)
241
+ raise AttemptErrors::EvidenceUnavailable, "Inventory terminal introduction differs"
242
+ end
243
+ lineage = Molecules::ExecutionScopeLineage.new(events: prefix, project_id: map.fetch("project_id"),
244
+ assignment_id: selector.fetch("assignment_id"), attempt_id: selector.fetch("attempt_id"), mapping_id: mapping_id)
245
+ terminal = terminal_scope_receipt!(prefix, lineage, journal, prefix_commit, deployment: original)
246
+ if prefix.any? { |entry| entry.dig("payload", "operation") == "finish" }
247
+ unless lineage.proof_event && !pending_prompt_issuers?(prefix, journal, prefix_commit) && @result_owner
248
+ raise AttemptErrors::EvidenceUnavailable, "Inventory finish closure owner is unavailable"
249
+ end
250
+ lineage.require_positive!(scope_generation: lineage.binding.fetch("scope_generation"),
251
+ scope_binding_event_id: lineage.binding_event.fetch("digest"), seal_event_id: lineage.seal_event.fetch("digest"), proof_id: lineage.proof_id)
252
+ params = selector.merge("mapping_id" => mapping_id)
253
+ if terminal.dig("payload", "receipt", "verdict") == "succeeded"
254
+ finish = prefix.find { |entry| entry.dig("payload", "operation") == "finish" }
255
+ review_params = params.merge(finish.fetch("payload").fetch("data").slice("head", "candidate_generation"))
256
+ @result_owner.finished_review_evidence!(journal: journal, events: prefix, params: review_params,
257
+ map: map, commit: prefix_commit)
258
+ end
259
+ @result_owner.service_settlement_complete!(journal: journal, events: prefix, params: params, map: map, commit: prefix_commit)
260
+ end
261
+ terminal
262
+ end
263
+
264
+ def inventory_original_deployment!(journal, events, mapping_id, current_map)
265
+ provisioning = events.select { |event| event["type"] == "scope_provisioning" }
266
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original provisioning is ambiguous" unless provisioning.one?
267
+ identity = provisioning.first.fetch("payload")
268
+ unless identity.is_a?(Hash) && identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id]
269
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original provisioning is malformed"
270
+ end
271
+ original = if identity.fetch("descriptor_sha256") == protected_descriptor_sha256!
272
+ @deployment
273
+ else
274
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original descriptor owner unavailable" unless @deployment_history
275
+ @deployment_history.descriptor!(sha256: identity.fetch("descriptor_sha256"))
276
+ end
277
+ map = original.mapping(mapping_id)
278
+ project = original.project(map.fetch("project_id"))
279
+ unless map.fetch("project_id") == current_map.fetch("project_id") &&
280
+ map.fetch("execution_scope").fetch("slot_id") == identity.fetch("slot_id") &&
281
+ original.mapping_digest(mapping_id) == identity.fetch("deployment_digest") &&
282
+ [project.fetch("journal_repository"), project.fetch("evidence_git_ref"), project.fetch("evidence_checkout_root")] == [journal.repo_root, journal.ref, journal.checkout_root]
283
+ raise AttemptErrors::EvidenceUnavailable, "Inventory original descriptor association differs"
284
+ end
285
+ [original, map]
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "ace/runtime/molecules/protected_artifact_set"
4
+ require_relative "campaign_execution"
5
+ require_relative "posix_acl"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # One accepted descriptor reference, held for the entire store/CAS guard.
11
+ # There is deliberately no ordinary review configuration cascade here.
12
+ class CampaignConsumerPolicy
13
+ class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
14
+ def initialize(acl: PosixAcl.new, **options)
15
+ super(**options)
16
+ @acl = acl
17
+ end
18
+
19
+ def verify!(path, handle, directory:)
20
+ super
21
+ unless @acl.entries(path).nil? && (!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
22
+ raise Ace::Runtime::RuntimeUnavailableError, "campaign policy ACL differs"
23
+ end
24
+ end
25
+ end
26
+ def self.reference!(reference)
27
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
28
+ reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
29
+ reference["path"].valid_encoding? && reference["path"].bytesize.between?(1, 4096) &&
30
+ !reference["path"].include?("\0") && reference["path"].start_with?("/") &&
31
+ File.expand_path(reference["path"]) == reference["path"] &&
32
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
33
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536)
34
+ raise ArgumentError, "invalid campaign consumer policy reference"
35
+ end
36
+ reference
37
+ end
38
+
39
+ def initialize(artifacts: Ace::Runtime::Molecules::ProtectedArtifactSet.new(protection: Protection.new))
40
+ @artifacts = artifacts
41
+ end
42
+
43
+ def with(reference)
44
+ begin
45
+ self.class.reference!(reference)
46
+ rescue ArgumentError
47
+ unavailable!
48
+ end
49
+ @artifacts.with do |artifacts|
50
+ profiles = read_profiles!(artifacts, reference)
51
+ verify_artifacts!(artifacts)
52
+ result = yield profiles, artifacts
53
+ verify_artifacts!(artifacts)
54
+ result
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def read_profiles!(artifacts, reference)
61
+ bytes = artifacts.read!(reference).dup.force_encoding(Encoding::UTF_8)
62
+ raise ArgumentError, "campaign consumer policy is not UTF-8" unless bytes.valid_encoding?
63
+ value = JSON.parse(bytes, create_additions: false, max_nesting: 16,
64
+ allow_duplicate_key: false, allow_comments: false)
65
+ unless value.is_a?(Hash) && value.keys.sort == %w[profiles schema] &&
66
+ value["schema"] == "ace.review.consumer-policy/v1" && value["profiles"].is_a?(Hash) &&
67
+ value["profiles"].size <= 32
68
+ raise ArgumentError, "campaign consumer policy fields differ"
69
+ end
70
+ profiles = value.fetch("profiles").to_h do |id, policy|
71
+ CampaignExecution::Contract.id!(id, "consumer profile")
72
+ [id, policy.nil? ? nil : CampaignExecution::Contract.policy!(policy)]
73
+ end
74
+ freeze_value!(profiles)
75
+ profiles
76
+ rescue ArgumentError, KeyError, TypeError, JSON::ParserError, Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
77
+ unavailable!
78
+ end
79
+
80
+ def verify_artifacts!(artifacts)
81
+ artifacts.verify_unchanged!
82
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
83
+ unavailable!
84
+ end
85
+
86
+ def unavailable!
87
+ raise AttemptErrors::EvidenceUnavailable, "current campaign consumer policy is unavailable"
88
+ end
89
+
90
+ def freeze_value!(value)
91
+ case value
92
+ when Hash then value.each { |key, child| key.freeze; freeze_value!(child) }
93
+ when Array then value.each { |child| freeze_value!(child) }
94
+ end
95
+ value.freeze
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git/atoms/pr_identifier"
4
+ require "ace/review/atoms/campaign_contract"
5
+ require_relative "../molecules/receipt_verifier"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Closed child linkage. Validation is structural; the registration owner
11
+ # must independently compare it with canonical parent/campaign state.
12
+ module CampaignExecution
13
+ Contract = Ace::Review::Atoms::CampaignContract
14
+ FIELDS = %w[version parent_assignment_id parent_attempt_id parent_scope parent_candidate_generation
15
+ subject campaign_id contract_identity policy_digest round_id phase review_scope scope_identity_digest
16
+ head base operation check_name].sort.freeze
17
+
18
+ def self.validate_parent!(value)
19
+ unless value.is_a?(Hash) && value.keys.sort == %w[campaign_id contract_identity policy subject version] &&
20
+ value["version"].is_a?(Integer) && value["version"] == 1
21
+ raise ArgumentError, "parent campaign fields differ"
22
+ end
23
+ Contract.id!(value["campaign_id"], "campaign ID")
24
+ unless value["contract_identity"].is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value["contract_identity"]) &&
25
+ Contract.subject!(value["subject"]) == value["subject"]
26
+ raise ArgumentError, "parent campaign identity differs"
27
+ end
28
+ Contract.policy!(value["policy"])
29
+ value
30
+ end
31
+
32
+ def self.validate_round!(value, parent:, round:)
33
+ validate!(value, parent: parent, policy: round.fetch("policy"))
34
+ binding = round.fetch("binding")
35
+ expected = round.slice("campaign_id", "subject", "contract_identity", "round_id").merge(
36
+ "head" => binding.fetch("head"), "base" => binding.fetch("base"),
37
+ "scope_identity_digest" => Contract.digest(binding.fetch("scope_identity").fetch(value.fetch("review_scope"))))
38
+ unless expected.all? { |key, item| value.fetch(key) == item }
39
+ raise ArgumentError, "campaign execution differs from pinned round"
40
+ end
41
+ value
42
+ rescue KeyError, TypeError
43
+ raise ArgumentError, "campaign execution round is incomplete"
44
+ end
45
+
46
+ # This does not accept a receipt; the canonical result owner still
47
+ # validates its producer, artifacts and introduction before use.
48
+ def self.validate_result!(value, receipt:, base:)
49
+ unless receipt.is_a?(Hash) && receipt["campaign"].nil? &&
50
+ receipt["operation"] == value.fetch("operation") &&
51
+ receipt["head"] == value.fetch("head") && base == value.fetch("base")
52
+ raise ArgumentError, "campaign child result differs from registered phase or candidate"
53
+ end
54
+ if receipt["verdict"] == "succeeded" && !Array(receipt["checks"]).any? { |check|
55
+ check.is_a?(Hash) && check["name"] == value.fetch("check_name") && check["verdict"] == "passed" }
56
+ raise ArgumentError, "campaign child result lacks its executed phase check"
57
+ end
58
+ value
59
+ rescue KeyError, TypeError
60
+ raise ArgumentError, "campaign child result binding is incomplete"
61
+ end
62
+
63
+ def self.validate_finished_review!(value, receipt:, accepted_review:)
64
+ executed = Array(receipt.fetch("artifacts")).map { |ref| ref.fetch("sha256") }
65
+ reviewed = Array(accepted_review.fetch("artifacts")).map { |ref| ref.fetch("sha256") }
66
+ unless (executed - reviewed).empty?
67
+ raise ArgumentError, "independent campaign review does not cover executed artifacts"
68
+ end
69
+ if value.fetch("phase") == "approval" && receipt["review"] != accepted_review.fetch("review")
70
+ raise ArgumentError, "campaign approval differs from canonical independent review"
71
+ end
72
+ true
73
+ rescue KeyError, TypeError
74
+ raise ArgumentError, "campaign independent review binding is incomplete"
75
+ end
76
+
77
+ def self.validate!(value, parent:, policy:)
78
+ unless value.is_a?(Hash) && value.keys.sort == FIELDS && value["version"].is_a?(Integer) && value["version"] == 1
79
+ raise ArgumentError, "campaign execution fields differ"
80
+ end
81
+ %w[parent_assignment_id parent_attempt_id parent_scope campaign_id round_id review_scope].each do |key|
82
+ Contract.id!(value[key], key)
83
+ end
84
+ unless value["parent_assignment_id"] == parent && value["parent_candidate_generation"].is_a?(Integer) &&
85
+ value["parent_candidate_generation"] >= 0
86
+ raise ArgumentError, "campaign execution parent differs"
87
+ end
88
+ %w[contract_identity policy_digest scope_identity_digest].each do |key|
89
+ unless value[key].is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value[key])
90
+ raise ArgumentError, "campaign execution digest differs"
91
+ end
92
+ end
93
+ %w[head base].each { |key| Contract.sha!(value[key], key) }
94
+ unless Contract.subject!(value["subject"]) == value["subject"]
95
+ raise ArgumentError, "campaign execution subject is not canonical"
96
+ end
97
+ policy = Contract.policy!(policy)
98
+ unless Contract.digest(policy) == value["policy_digest"] && policy["required_scopes"].include?(value["review_scope"])
99
+ raise ArgumentError, "campaign execution policy differs"
100
+ end
101
+ expected = case value["phase"]
102
+ when "collection" then ["review-collect", "review-execution"]
103
+ when "approval" then ["review", "review-approval"]
104
+ when "check"
105
+ check = value["check_name"]
106
+ if Molecules::ReceiptVerifier::EXTERNAL_EFFECT_OPERATIONS.include?(check)
107
+ raise ArgumentError, "external effects cannot be campaign checks"
108
+ end
109
+ raise ArgumentError, "campaign execution check is not required" unless policy["required_checks"].include?(check)
110
+ [check == "tests" ? "test" : check, check]
111
+ else raise ArgumentError, "campaign execution phase differs"
112
+ end
113
+ raise ArgumentError, "campaign execution operation differs" unless value.values_at("operation", "check_name") == expected
114
+ value
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "tmpdir"
4
+ require_relative "receipt_transfer"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ module CampaignRoundTransfer
10
+ def self.decode(input:, sha256:)
11
+ admitted = ReceiptTransfer.decode(input: input, receipt_sha256: sha256,
12
+ artifact_field: "artifacts", reference_key: "path")
13
+ text = input.bytes(index: 0).dup.force_encoding(Encoding::UTF_8)
14
+ raise ArgumentError, "round JSON encoding differs" unless text.valid_encoding? && !text.include?("\0")
15
+ envelope = JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
16
+ unless envelope.keys.sort == %w[artifacts round version] && envelope["version"].is_a?(Integer) && envelope["version"] == 1 &&
17
+ envelope["round"].is_a?(Hash) && envelope["artifacts"].all? { |ref| relative_path?(ref["path"]) }
18
+ raise ArgumentError, "round envelope differs"
19
+ end
20
+ {round: envelope.fetch("round"), references: envelope.fetch("artifacts"),
21
+ bytes: admitted.fetch(:artifacts), sha256: sha256}.freeze
22
+ rescue JSON::ParserError, KeyError, TypeError
23
+ raise ArgumentError, "round envelope is malformed"
24
+ end
25
+
26
+ def self.relative_path?(path)
27
+ path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? && path.bytesize.between?(1, 256) &&
28
+ !path.start_with?("/") && !path.include?("\\") && !path.include?("\0") &&
29
+ path.split("/", -1).none? { |component| component.empty? || %w[. ..].include?(component) }
30
+ end
31
+
32
+ # Paths are private materialization names, never source filesystem reads.
33
+ # The original R1 replay digest binds all supplied bytes independently.
34
+ def self.materialize(admitted:, repository:, request_id:)
35
+ base = File.join(repository, ".ace-local", "review", "campaign-inputs")
36
+ FileUtils.mkdir_p(base, mode: 0o700)
37
+ PrivateDirectory.verify!(base)
38
+ final = File.join(base, Digest::SHA256.hexdigest(request_id))
39
+ if File.exist?(final)
40
+ PrivateDirectory.verify!(final)
41
+ verify_materialization!(admitted, final)
42
+ else
43
+ temporary = Dir.mktmpdir("round-", base)
44
+ admitted.fetch(:references).zip(admitted.fetch(:bytes)).each do |reference, bytes|
45
+ path = File.join(temporary, reference.fetch("path"))
46
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
47
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
48
+ file.write(bytes)
49
+ file.flush
50
+ file.fsync
51
+ end
52
+ end
53
+ File.rename(temporary, final)
54
+ temporary = nil
55
+ File.open(base, File::RDONLY) { |directory| directory.fsync }
56
+ end
57
+ admitted.fetch(:references).to_h { |reference| [reference.fetch("path"), File.join(final, reference.fetch("path"))] }.freeze
58
+ ensure
59
+ FileUtils.remove_entry(temporary) if temporary && File.directory?(temporary)
60
+ end
61
+
62
+ def self.verify_materialization!(admitted, root)
63
+ expected = admitted.fetch(:references).map { |ref| ref.fetch("path") }.sort
64
+ actual = Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).reject { |path| File.directory?(path) }
65
+ .map { |path| path.delete_prefix(root + "/") }.sort
66
+ raise AttemptErrors::Conflict, "round materialization differs" unless expected == actual
67
+ admitted.fetch(:references).each do |reference|
68
+ path = File.join(root, reference.fetch("path"))
69
+ PrivateDirectory.verify!(File.dirname(path))
70
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
71
+ stat = file.stat
72
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o777) == 0o600 &&
73
+ stat.size <= 64 * 1024 && Digest::SHA256.hexdigest(file.read(64 * 1024 + 1)) == reference.fetch("sha256")
74
+ raise AttemptErrors::Conflict, "round materialization bytes differ"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end