ace-assign 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "canonical_attempt_state"
4
+
3
5
  require "digest"
4
6
  require "fileutils"
5
7
  require "json"
6
8
  require "open3"
9
+ require "pathname"
10
+ require "ace/herdr/molecules/bounded_process"
7
11
  require_relative "proposal_journal"
8
12
  require_relative "journal_mutation"
9
13
 
@@ -29,6 +33,20 @@ module Ace
29
33
  include JournalMutation
30
34
  include ProposalJournal
31
35
  CAS_ATTEMPTS = 3
36
+ HISTORY_LIMIT = 100_000
37
+ EVENT_BATCH_BYTES = 1024 * 1024
38
+
39
+ OPERATION_INVENTORY_LIMIT = 8
40
+
41
+ # A wire operation may retain one event/raw service selection and bounded immutable inventories, never admission
42
+ # or current-ref truth. Nested operations own independent selections.
43
+ def self.with_event_read_operation
44
+ previous = Thread.current[:ace_assign_event_read_operation]
45
+ Thread.current[:ace_assign_event_read_operation] = {}
46
+ yield
47
+ ensure
48
+ Thread.current[:ace_assign_event_read_operation] = previous
49
+ end
32
50
 
33
51
  # Selected by source composition, never by receipt or wire parameters.
34
52
  def evidence_mode
@@ -39,12 +57,16 @@ module Ace
39
57
  # @param ref [String, nil] Evidence ref (default from config)
40
58
  # @param checkout_root [String, nil] Isolated checkout root (default from config)
41
59
  def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
42
- evidence_reader: nil, service_authorizer: nil)
60
+ evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
43
61
  unless %i[local protected].include?(mode) &&
44
62
  (mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
45
63
  raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
46
64
  end
47
65
  @mode, @evidence_reader, @service_authorizer = mode, evidence_reader, service_authorizer
66
+ if read_boundary && !%i[call blob_batch blob history_diff blob_sizes].all? { |method| read_boundary.respond_to?(method) }
67
+ raise ArgumentError, "read-only journal boundary is malformed"
68
+ end
69
+ @read_boundary = read_boundary
48
70
  @repo_root = repo_root
49
71
  @ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
50
72
  root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
@@ -58,6 +80,71 @@ module Ace
58
80
  git_ok?("rev-parse", "--git-dir")
59
81
  end
60
82
 
83
+ # Original operator preparation only. Reuse the journal's existing lock
84
+ # and absent-ref CAS; never manufacture an event or reset existing history.
85
+ def initialize_canonical!(owner_uid:, owner_gid:, owner_groups:)
86
+ raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot initialize canonical evidence" if @read_boundary
87
+ unless [owner_uid, owner_gid].all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } && @repo_root.is_a?(String) && @checkout_root.is_a?(String) &&
88
+ [@repo_root, @checkout_root].all? { |path| Pathname.new(path).absolute? && File.expand_path(path) == path } &&
89
+ File.directory?(@repo_root) && !File.symlink?(@repo_root) &&
90
+ git_ok?("check-ref-format", @ref)
91
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection is invalid"
92
+ end
93
+ unless owner_groups.is_a?(Array) && owner_groups.all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } &&
94
+ owner_groups.uniq.size == owner_groups.size &&
95
+ [Process.uid, Process.euid, Process.gid, Process.egid] == [owner_uid, owner_uid, owner_gid, owner_gid] &&
96
+ Process.groups.sort == owner_groups.sort
97
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization process identity differs"
98
+ end
99
+ selected = File.stat(@repo_root)
100
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository owner differs" unless selected.uid == owner_uid
101
+ identity = [selected.dev, selected.ino, selected.uid, selected.gid, selected.mode]
102
+ with_lock(cleanup_on_failure: false, owner_uid: owner_uid, owner_gid: owner_gid) do
103
+ value = ref_value
104
+ unless value
105
+ if File.exist?(checkout_dir) || File.symlink?(checkout_dir)
106
+ raise AttemptErrors::EvidenceUnavailable, "uninitialized journal has foreign checkout state"
107
+ end
108
+ seed_ref
109
+ value = ref_value
110
+ end
111
+ nodes = canonical_history_nodes!(value)
112
+ seed = nodes.last.first
113
+ tree = git!("rev-parse", "#{seed}^{tree}").first
114
+ message = git!("show", "-s", "--format=%B", seed).first
115
+ unless tree == git!("hash-object", "-t", "tree", "--stdin").first && message == "seed: ace-assign execution evidence"
116
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization has foreign root history"
117
+ end
118
+ canonical_event_inventory!(commit: value)
119
+ raw_paths = bounded_history_read!(["ls-tree", "-r", "-z", "--name-only", value], "", HISTORY_DIFF_BYTES)
120
+ paths = raw_paths.empty? ? [] : raw_paths.split("\0", -1)
121
+ unless paths.empty? || paths.pop == ""
122
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is incomplete"
123
+ end
124
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is oversized" if paths.size > HISTORY_LIMIT
125
+ paths.each do |path|
126
+ parts = path.split("/", -1)
127
+ event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
128
+ request = parts.size == 3 && parts[0, 2] == %w[execution requests] && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
129
+ # Definitions, prepared packets, candidate bundles and imports
130
+ # retain the SAME mutation owner's closed immutable blob dialect.
131
+ validate_blob_path!(path) unless event || request
132
+ end
133
+ records = service_request_records(commit: value)
134
+ expected_requests = paths.select { |path| path.start_with?("execution/requests/") }.map { |path| File.basename(path, ".json") }.sort
135
+ unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
136
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
137
+ end
138
+ current = File.stat(@repo_root)
139
+ unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
140
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
141
+ end
142
+ value.dup.freeze
143
+ end
144
+ rescue SystemCallError, ArgumentError
145
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository is unavailable"
146
+ end
147
+
61
148
  # Current value of the evidence ref.
62
149
  #
63
150
  # @return [String, nil] Commit SHA or nil when the ref does not exist
@@ -70,6 +157,376 @@ module Ace
70
157
  nil
71
158
  end
72
159
 
160
+ # Read-only verification for a fixed immutable maintenance snapshot.
161
+ def verify_commit!(commit)
162
+ unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
163
+ raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is invalid"
164
+ end
165
+ kind, error, status = git("cat-file", "-t", commit)
166
+ unless status.success? && kind == "commit"
167
+ raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is unavailable: #{error}"
168
+ end
169
+ true
170
+ end
171
+
172
+ # All immutable owner queries select retained canonical evidence, not
173
+ # arbitrary Git objects or valid-looking abandoned CAS candidates.
174
+ def verify_canonical_prefix!(commit:, canonical_commit: ref_value)
175
+ verify_commit!(commit)
176
+ verify_commit!(canonical_commit)
177
+ text, _error, status = git("rev-list", "--first-parent", "--parents",
178
+ "--max-count=#{self.class::HISTORY_LIMIT + 1}", canonical_commit)
179
+ nodes = text.lines.map(&:split)
180
+ unless status.success? && nodes.size <= self.class::HISTORY_LIMIT && !nodes.empty? &&
181
+ nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
182
+ nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1 &&
183
+ nodes.any? { |node| node.first == commit }
184
+ raise AttemptErrors::EvidenceUnavailable, "Evidence prefix is not retained canonical first-parent history"
185
+ end
186
+ true
187
+ end
188
+
189
+ # Normal append commits inherit the event unchanged. Locate its one
190
+ # introduction; inherited appearances are not competing provenance.
191
+ def event_commit!(assignment_id:, event_digest:, commit:)
192
+ event_commits!(assignment_id: assignment_id, event_digests: [event_digest], commit: commit).fetch(event_digest)
193
+ end
194
+
195
+ # Operation-scoped batch of the same provenance proof. Reuse only the
196
+ # already authenticated exact wire inventory; never an owner cache.
197
+ def event_commits!(assignment_id:, event_digests:, commit:)
198
+ event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
199
+ end
200
+
201
+ # One immutable walk for an operation selecting several assignments.
202
+ # Every selected assignment retains its complete chain and exact raw
203
+ # blob proof; the result is returned, never cached on this owner.
204
+ def event_commits_for_assignments!(selectors:, commit:)
205
+ unless selectors.is_a?(Hash) && !selectors.empty? && selectors.size <= HISTORY_LIMIT &&
206
+ selectors.keys.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) } &&
207
+ selectors.values.all? { |digests| digests.is_a?(Array) && !digests.empty? &&
208
+ digests.uniq.size == digests.size && digests.all? { |digest| digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) } } &&
209
+ selectors.values.sum(&:size) <= HISTORY_LIMIT
210
+ raise AttemptErrors::EvidenceUnavailable, "historical event selectors are invalid"
211
+ end
212
+ if (inventory = retained_operation_inventory(commit))
213
+ return selectors.to_h do |id, digests|
214
+ introductions = inventory.fetch("introductions").fetch(id)
215
+ [id.dup.freeze, digests.to_h { |digest| [digest.dup.freeze, introductions.fetch(digest)] }.freeze]
216
+ end.freeze
217
+ end
218
+ nodes = canonical_history_nodes!(commit)
219
+ states = selectors.to_h do |id, digests|
220
+ [id, digests.to_h { |digest| [digest, {retained: nil, blob: nil, absent: false, introduction: nil}] }]
221
+ end
222
+ each_history_event_snapshot!(states.keys, nodes: nodes) do |node, snapshots, files|
223
+ states.each do |assignment_id, selected_states|
224
+ events = snapshots.fetch(assignment_id)
225
+ validated_attempt_chains!(events)
226
+ matches = events.select { |event| selected_states.key?(event["digest"]) }.group_by { |event| event.fetch("digest") }
227
+ selected_states.each do |digest, state|
228
+ selected = matches.fetch(digest, [])
229
+ raise AttemptErrors::EvidenceUnavailable, "historical event selector is ambiguous" if selected.size > 1
230
+ if selected.empty?
231
+ raise AttemptErrors::EvidenceUnavailable, "historical event disappeared" if state[:retained]
232
+ next
233
+ end
234
+ event = selected.first
235
+ blob = files.fetch(assignment_id).fetch(event_filename(event))
236
+ if (state[:retained] && state[:retained] != event) || (state[:blob] && state[:blob] != blob)
237
+ raise AttemptErrors::EvidenceUnavailable, "historical event changed"
238
+ end
239
+ state[:blob] ||= blob
240
+ state[:retained] ||= event
241
+ state[:introduction] ||= node.first
242
+ end
243
+ end
244
+ end
245
+ unless states.values.all? { |selected_states| selected_states.values.all? { |state| state[:introduction] } }
246
+ raise AttemptErrors::EvidenceUnavailable, "historical canonical event is unavailable"
247
+ end
248
+ states.to_h do |id, selected_states|
249
+ [id.dup.freeze, selected_states.to_h { |digest, state| [digest.dup.freeze, state.fetch(:introduction).dup.freeze] }.freeze]
250
+ end.freeze
251
+ rescue KeyError, TypeError, ArgumentError, NoMethodError
252
+ raise AttemptErrors::EvidenceUnavailable, "historical canonical event selectors are unverifiable"
253
+ end
254
+
255
+ HISTORY_DIFF_BYTES = 256 * 1024 * 1024
256
+ HISTORY_DIFF_FLAGS = %w[diff-tree --stdin --always --root -r --raw -z --no-renames
257
+ --no-ext-diff --no-textconv --no-abbrev --].freeze
258
+
259
+ # The existing history owner still validates every prefix. Git returns
260
+ # all requested headers, including unchanged commits; only immutable
261
+ # changed blob bytes are batched rather than respawned at each node.
262
+ def each_history_event_snapshot!(ids, nodes:)
263
+ inventory = ids.nil?
264
+ ids = [] if inventory
265
+ files = ids.to_h { |id| [id, {}] }
266
+ contents = ids.to_h { |id| [id, {}] }
267
+ paths = inventory ? ["execution/"] : ids.map { |id| "execution/#{id}/events/" }
268
+ nodes.reverse.each_slice(64) do |chunk|
269
+ commits = chunk.map(&:first)
270
+ raw = history_diff!(commits, paths)
271
+ changes = decode_history_diff!(commits, paths, raw, all_paths: inventory)
272
+ if inventory
273
+ changes.each_value do |entries|
274
+ entries.each do |path, *_|
275
+ id = path.split("/")[1]
276
+ next if id == "requests"
277
+ unless id && id.match?(JournalMutation::ID)
278
+ raise AttemptErrors::EvidenceUnavailable, "historical assignment path is invalid"
279
+ end
280
+ unless files.key?(id)
281
+ raise AttemptErrors::EvidenceUnavailable, "historical assignment inventory exceeds bound" if ids.size >= HISTORY_LIMIT
282
+ ids << id
283
+ files[id], contents[id] = {}, {}
284
+ end
285
+ end
286
+ entries.select! do |path, *_|
287
+ id = path.split("/")[1]
288
+ id != "requests" && path.start_with?("execution/#{id}/events/") && path.end_with?(".json")
289
+ end
290
+ end
291
+ end
292
+ oids = changes.values.flatten(1).filter_map { |change| change[3] unless change[3] == "0" * 40 }.uniq
293
+ decoded = {}
294
+ oids.each_slice(64) do |batch|
295
+ sizes = history_blob_sizes!(batch)
296
+ entries = batch.zip(sizes)
297
+ groups = []
298
+ entries.each do |entry|
299
+ groups << [] if groups.empty? || (!groups.last.empty? && groups.last.sum(&:last) + entry.last > EVENT_BATCH_BYTES)
300
+ groups.last << entry
301
+ end
302
+ groups.each do |group|
303
+ decode_event_blobs!(group, read_event_blobs!(group)).each_with_index do |bytes, index|
304
+ decoded[group.fetch(index).first] = JSON.parse(bytes)
305
+ end
306
+ end
307
+ end
308
+ chunk.each do |node|
309
+ changes.fetch(node.first).each do |path, status, old, fresh|
310
+ id = ids.find { |selected| path.start_with?("execution/#{selected}/events/") }
311
+ name = path.delete_prefix("execution/#{id}/events/")
312
+ current = files.fetch(id)[name]
313
+ unless (status == "A" && old == "0" * 40 && current.nil?) ||
314
+ (%w[M D T].include?(status) && current == old)
315
+ raise AttemptErrors::EvidenceUnavailable, "historical tree change differs from previous immutable snapshot"
316
+ end
317
+ if status == "D"
318
+ files.fetch(id).delete(name)
319
+ contents.fetch(id).delete(name)
320
+ else
321
+ files.fetch(id)[name] = fresh
322
+ contents.fetch(id)[name] = decoded.fetch(fresh)
323
+ end
324
+ end
325
+ snapshots = contents.transform_values do |selected|
326
+ events = selected.values
327
+ digests = events.map { |event| event.fetch("digest") }
328
+ raise AttemptErrors::EvidenceUnavailable, "historical event digest is ambiguous" unless digests.uniq.size == digests.size
329
+ order_by_chain(events)
330
+ end
331
+ yield node, snapshots, files
332
+ end
333
+ end
334
+ _events, actual = ids.empty? ? [{}, {}] : read_event_snapshots!(ids, commit: nodes.first.first)
335
+ raise AttemptErrors::EvidenceUnavailable, "historical reconstructed tip differs from its immutable tree" unless files == actual
336
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError
337
+ raise AttemptErrors::EvidenceUnavailable, "historical batched snapshots are unverifiable"
338
+ end
339
+
340
+ def history_diff!(commits, paths)
341
+ return @read_boundary.history_diff(commits, paths, output_limit: HISTORY_DIFF_BYTES) if @read_boundary
342
+ bounded_history_read!(HISTORY_DIFF_FLAGS + paths, commits.join("\n") + "\n", HISTORY_DIFF_BYTES)
343
+ end
344
+
345
+ def history_blob_sizes!(oids)
346
+ raw = if @read_boundary
347
+ @read_boundary.blob_sizes(oids)
348
+ else
349
+ bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
350
+ end
351
+ lines = raw.lines
352
+ raise AttemptErrors::EvidenceUnavailable, "historical blob metadata count differs" unless lines.size == oids.size
353
+ lines.zip(oids).map do |line, oid|
354
+ match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
355
+ raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
356
+ match[2].to_i
357
+ end
358
+ end
359
+
360
+ def bounded_history_read!(argv, stdin, limit)
361
+ result = Herdr::Molecules::BoundedProcess.call(["git", *argv], chdir: File.expand_path(@repo_root),
362
+ stdin_data: stdin, timeout_s: 30, output_limit: limit)
363
+ raise AttemptErrors::EvidenceUnavailable, "historical batch unavailable or exceeds bound" unless result.status.success? && !result.oversized
364
+ result.stdout.to_s.b
365
+ rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError
366
+ raise AttemptErrors::EvidenceUnavailable, "historical batch is unavailable"
367
+ end
368
+
369
+ def decode_history_diff!(commits, paths, raw, all_paths: false)
370
+ unless raw.is_a?(String) && raw.end_with?("\0")
371
+ raise AttemptErrors::EvidenceUnavailable, "historical diff frame is incomplete"
372
+ end
373
+ tokens = raw.split("\0", -1)
374
+ tokens.pop
375
+ result = commits.to_h { |commit| [commit, []] }
376
+ position = 0
377
+ commits.each do |commit|
378
+ raise AttemptErrors::EvidenceUnavailable, "historical commit header differs" unless tokens[position] == commit
379
+ position += 1
380
+ seen = {}
381
+ while tokens[position]&.start_with?(":")
382
+ metadata, path = tokens.values_at(position, position + 1)
383
+ match = /\A:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([AMDT])\z/.match(metadata)
384
+ unless match && path.is_a?(String) && paths.any? { |prefix| path.start_with?(prefix) } && !seen[path]
385
+ raise AttemptErrors::EvidenceUnavailable, "historical tree metadata differs"
386
+ end
387
+ seen[path] = true
388
+ position += 2
389
+ next unless all_paths || path.end_with?(".json")
390
+ old, fresh, status = match.values_at(3, 4, 5)
391
+ if all_paths && !path.match?(%r{\Aexecution/[^/]+/events/.*\.json\z})
392
+ # Complete discovery needs these assignment paths, but their
393
+ # non-event bodies/modes never become canonical event facts.
394
+ result.fetch(commit) << [path, status, old, fresh]
395
+ next
396
+ end
397
+ if all_paths && path.start_with?("execution/requests/")
398
+ result.fetch(commit) << [path, status, old, fresh]
399
+ next
400
+ end
401
+ modes = match.values_at(1, 2)
402
+ unless modes.all? { |mode| %w[000000 100644 100755 120000].include?(mode) } &&
403
+ (status == "A" ? old == "0" * 40 && fresh != "0" * 40 && modes.first == "000000" && modes.last != "000000" :
404
+ status == "D" ? old != "0" * 40 && fresh == "0" * 40 && modes.first != "000000" && modes.last == "000000" :
405
+ old != "0" * 40 && fresh != "0" * 40 && modes.none? { |mode| mode == "000000" })
406
+ raise AttemptErrors::EvidenceUnavailable, "historical event object type differs"
407
+ end
408
+ result.fetch(commit) << [path, status, old, fresh]
409
+ end
410
+ end
411
+ raise AttemptErrors::EvidenceUnavailable, "historical diff has extra headers or bytes" unless position == tokens.size
412
+ result
413
+ end
414
+ private :each_history_event_snapshot!, :history_diff!, :history_blob_sizes!, :bounded_history_read!, :decode_history_diff!
415
+
416
+ # Complete immutable index facts are authenticated before any caller
417
+ # filters ownership. A missing current fact cannot hide an older one.
418
+ def canonical_event_inventory!(commit:)
419
+ context = Thread.current[:ace_assign_event_read_operation]
420
+ reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
421
+ selection = operation_inventory_selection(commit) if reusable
422
+ if reusable
423
+ inventories = context[:inventories] ||= {}
424
+ if inventories.key?(selection)
425
+ # Refresh bounded insertion order, retaining only immutable facts.
426
+ inventory = inventories.delete(selection)
427
+ inventories[selection] = inventory
428
+ return inventory
429
+ end
430
+ else
431
+ context&.delete(:inventories)
432
+ end
433
+ inventory = read_canonical_inventory!(commit: commit)
434
+ if reusable
435
+ inventories.shift while inventories.size >= OPERATION_INVENTORY_LIMIT
436
+ inventories[selection] = inventory
437
+ end
438
+ inventory
439
+ end
440
+
441
+ # Query only the exact full history already authenticated in this wire
442
+ # operation. No historical selector builds or advances this snapshot.
443
+ def operation_inventory_selection(commit)
444
+ [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id, commit]
445
+ .map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
446
+ end
447
+
448
+ def retained_operation_inventory(commit)
449
+ context = Thread.current[:ace_assign_event_read_operation]
450
+ return unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
451
+ context.fetch(:inventories, {})[operation_inventory_selection(commit)]
452
+ end
453
+ private :operation_inventory_selection, :retained_operation_inventory
454
+
455
+ def read_canonical_inventory!(commit:)
456
+ nodes = canonical_history_nodes!(commit)
457
+ root_events, introductions = {}, {}
458
+ previous_chains, previous_files = {}, {}
459
+ each_history_event_snapshot!(nil, nodes: nodes) do |node, snapshots, files|
460
+ chains = snapshots.transform_values { |events| validated_attempt_chains!(events) }
461
+ snapshots.each do |id, events|
462
+ unless events.all? { |event| files.fetch(id).key?(event_filename(event)) }
463
+ raise AttemptErrors::EvidenceUnavailable, "canonical event filename differs"
464
+ end
465
+ end
466
+ previous_chains.each do |id, attempts|
467
+ attempts.each do |attempt_id, chain|
468
+ newer = chains.fetch(id, {}).fetch(attempt_id, [])
469
+ unless chain == newer.take(chain.size)
470
+ raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote an attempt"
471
+ end
472
+ end
473
+ end
474
+ previous_files.each do |id, selected_files|
475
+ unless selected_files.all? { |name, oid| files.fetch(id, {})[name] == oid }
476
+ raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote event bytes"
477
+ end
478
+ end
479
+ snapshots.each do |id, events|
480
+ introductions[id] ||= {}
481
+ events.each { |event| introductions.fetch(id)[event.fetch("digest")] ||= node.first }
482
+ end
483
+ root_events = snapshots.reject { |_id, events| events.empty? }
484
+ previous_chains = chains
485
+ previous_files = files.transform_values(&:dup)
486
+ end
487
+ introductions.select! { |id, _| root_events.key?(id) }
488
+ freeze_inventory_projection("commit" => commit, "events" => root_events, "introductions" => introductions)
489
+ rescue KeyError, TypeError, ArgumentError, NoMethodError
490
+ raise AttemptErrors::EvidenceUnavailable, "canonical inventory history is unverifiable"
491
+ end
492
+
493
+ private :read_canonical_inventory!
494
+
495
+ def canonical_history_nodes!(commit)
496
+ verify_commit!(commit)
497
+ topology, error, status = git("rev-list", "--first-parent", "--parents",
498
+ "--max-count=#{HISTORY_LIMIT + 1}", commit)
499
+ nodes = topology.lines.map(&:split)
500
+ unless status.success? && !nodes.empty? && nodes.size <= HISTORY_LIMIT &&
501
+ nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
502
+ nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1
503
+ raise AttemptErrors::EvidenceUnavailable, "canonical history topology is unsupported or incomplete: #{error}"
504
+ end
505
+ nodes
506
+ end
507
+
508
+ def validated_attempt_chains!(events)
509
+ chains = events.group_by { |event| event.fetch("attempt_id") }
510
+ unless chains.values.all? { |chain| Models::EvidenceEvent.chain_valid?(chain) }
511
+ raise AttemptErrors::EvidenceUnavailable, "historical canonical event chain is corrupt"
512
+ end
513
+ chains
514
+ end
515
+
516
+ def freeze_inventory_projection(value)
517
+ case value
518
+ when Hash
519
+ value.to_h { |key, item| [freeze_inventory_projection(key), freeze_inventory_projection(item)] }.freeze
520
+ when Array
521
+ value.map { |item| freeze_inventory_projection(item) }.freeze
522
+ when String
523
+ value.dup.freeze
524
+ else
525
+ value.freeze
526
+ end
527
+ end
528
+ private :canonical_history_nodes!, :validated_attempt_chains!, :freeze_inventory_projection
529
+
73
530
  # Append accepted events to the journal under lock + CAS.
74
531
  #
75
532
  # @param assignment_id [String] Assignment ID (journal path segment)
@@ -132,21 +589,83 @@ module Ace
132
589
  # @param assignment_id [String] Assignment ID
133
590
  # @return [Array<Hash>] Parsed events in journal order
134
591
  def read_events(assignment_id, commit: ref_value)
135
- value = commit
136
- return [] if value.nil?
592
+ context = Thread.current[:ace_assign_event_read_operation]
593
+ if commit.nil?
594
+ context&.delete(:selection)
595
+ context&.delete(:events)
596
+ return []
597
+ end
598
+ unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
599
+ context&.delete(:selection)
600
+ context&.delete(:events)
601
+ return read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
602
+ end
603
+ selection = [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id,
604
+ assignment_id, commit].map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
605
+ return context.fetch(:events) if context[:selection] == selection
606
+ context.delete(:selection)
607
+ context.delete(:events)
608
+ inventory = retained_operation_inventory(commit) if assignment_id.is_a?(String) && assignment_id.match?(JournalMutation::ID)
609
+ events = if inventory
610
+ inventory.fetch("events").fetch(assignment_id, [])
611
+ else
612
+ read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
613
+ end
614
+ frozen = freeze_inventory_projection(events)
615
+ context[:selection], context[:events] = selection, frozen
616
+ frozen
617
+ end
137
618
 
138
- paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--",
139
- "execution/#{assignment_id}/events/")
140
- raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
141
- events = paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.map do |path|
142
- content, error, read_status = git("show", "#{value}:#{path}")
143
- raise AttemptErrors::EvidenceUnavailable, "Cannot read journal event: #{error}" unless read_status.success?
144
- JSON.parse(content)
619
+ # Bounded argv/blob batches from the same immutable tree. Preserve the
620
+ # actual file OIDs alongside parsed chains for introduction proofs.
621
+ def read_event_snapshots!(assignment_ids, commit:)
622
+ unless assignment_ids.is_a?(Array) && !assignment_ids.empty? && assignment_ids.size <= HISTORY_LIMIT &&
623
+ assignment_ids.uniq.size == assignment_ids.size && assignment_ids.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) }
624
+ raise AttemptErrors::EvidenceUnavailable, "canonical assignment selectors are invalid"
625
+ end
626
+ events = assignment_ids.to_h { |id| [id, []] }
627
+ files = assignment_ids.to_h { |id| [id, {}] }
628
+ entries = []
629
+ assignment_ids.each_slice(64) do |ids|
630
+ paths, stderr, status = git("ls-tree", "-r", "-l", commit, "--", *ids.map { |id| "execution/#{id}/events/" })
631
+ raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
632
+ paths.lines.each do |line|
633
+ metadata, path = line.chomp.split("\t", 2)
634
+ next unless path&.end_with?(".json")
635
+ _mode, kind, oid, size = metadata.split
636
+ id = path.split("/")[1]
637
+ unless events.key?(id) && path.start_with?("execution/#{id}/events/") && kind == "blob" &&
638
+ oid&.match?(/\A[0-9a-f]{40}\z/) && size&.match?(/\A(?:0|[1-9][0-9]*)\z/)
639
+ raise AttemptErrors::EvidenceUnavailable, "canonical event blob selection is invalid"
640
+ end
641
+ name = path.delete_prefix("execution/#{id}/events/")
642
+ raise AttemptErrors::EvidenceUnavailable, "canonical event file is ambiguous" if files.fetch(id).key?(name)
643
+ files.fetch(id)[name] = oid
644
+ entries << [oid, size.to_i, id]
645
+ end
646
+ end
647
+ batches = []
648
+ entries.each do |entry|
649
+ batches << [] if batches.empty? || batches.last.size == 64 ||
650
+ (!batches.last.empty? && batches.last.sum { |item| item[1] } + entry[1] > EVENT_BATCH_BYTES)
651
+ batches.last << entry
145
652
  end
146
- order_by_chain(events)
147
- rescue JSON::ParserError => e
148
- raise AttemptErrors::EvidenceUnavailable, "Corrupt journal event for #{assignment_id}: #{e.message}"
653
+ batches.each do |batch|
654
+ selected = batch.map { |oid, size, _id| [oid, size] }
655
+ contents = decode_event_blobs!(selected, read_event_blobs!(selected))
656
+ batch.zip(contents).each { |(_oid, _size, id), content| events.fetch(id) << JSON.parse(content) }
657
+ end
658
+ events.each_value do |chain|
659
+ digests = chain.map { |event| event.fetch("digest") }
660
+ unless digests.uniq.size == digests.size
661
+ raise AttemptErrors::EvidenceUnavailable, "canonical event digest is ambiguous"
662
+ end
663
+ end
664
+ [events.transform_values { |chain| order_by_chain(chain) }, files]
665
+ rescue JSON::ParserError
666
+ raise AttemptErrors::EvidenceUnavailable, "Corrupt canonical journal event"
149
667
  end
668
+ private :read_event_snapshots!
150
669
 
151
670
  # Accepted receipt payloads recorded for an assignment.
152
671
  #
@@ -261,6 +780,17 @@ module Ace
261
780
  "Service settlement requires a no-effect attestation artifact"
262
781
  end
263
782
  if @mode == :protected
783
+ if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
784
+ (current["operation"] == "publish" && %w[succeeded failed].include?(state))
785
+ # The same installed evidence reader owns the entire imported
786
+ # cleanup pair; individual textual markers cannot authorize it.
787
+ contents = @evidence_reader.call(evidence_items, current, state, pending)
788
+ unless contents.is_a?(Array) && contents.size == evidence_items.size &&
789
+ contents.each_with_index.all? { |content, index| content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == evidence_items.fetch(index).fetch("sha256") }
790
+ raise AttemptErrors::ReceiptRejected, "Canonical cleanup evidence is unverifiable"
791
+ end
792
+ return true
793
+ end
264
794
  evidence_items.each do |item|
265
795
  content = @evidence_reader.call(item, current, state, pending)
266
796
  unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
@@ -292,14 +822,75 @@ module Ace
292
822
  end
293
823
 
294
824
  def service_request(request_id, commit: ref_value)
825
+ read_service_request(request_id, commit: commit, pending: {commit: commit})
826
+ end
827
+
828
+ # A read operation owns this view only until its caller returns. The
829
+ # original complete first-parent/raw-blob owner supplies every fact;
830
+ # it is never retained on the journal or reused for another operation.
831
+ ServiceReadView = Struct.new(:journal, :commit, :inventory, :projection, keyword_init: true)
832
+
833
+ def service_settlement_read(request_id, commit: ref_value)
834
+ verify_canonical_prefix!(commit: commit)
835
+ record = read_service_request(request_id, commit: commit, pending: {commit: commit}, terminal: false)
836
+ return {record: nil, settlement_context: nil, read_view: nil}.freeze unless record
837
+ id = record.fetch("assignment_id")
838
+ events = read_events(id, commit: commit)
839
+ selectors = events.map { |event| event.fetch("digest") }
840
+ introductions = event_commits!(assignment_id: id, event_digests: selectors, commit: commit)
841
+ inventory = freeze_inventory_projection("commit" => commit, "events" => {id => events}, "introductions" => {id => introductions})
842
+ view = ServiceReadView.new(journal: self, commit: commit, inventory: inventory)
843
+ if @mode == :protected && terminal_state?(record["state"])
844
+ validate_terminal_receipt!(record, record["state"], record["receipt"],
845
+ pending: {commit: commit, service_read_view: view})
846
+ end
847
+ view.freeze
848
+ {record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
849
+ end
850
+
851
+ def read_service_request(request_id, commit:, pending:, terminal: true)
852
+ context = Thread.current[:ace_assign_event_read_operation]
295
853
  validate_request_id!(request_id)
296
- value = commit
297
- return nil unless value
298
- out, stderr, status = git("show", "#{value}:#{service_request_path(request_id)}")
854
+ reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
855
+ selection = if reusable
856
+ (operation_inventory_selection(commit) + [@evidence_reader&.object_id, @service_authorizer&.object_id, request_id])
857
+ .map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
858
+ end
859
+ if reusable && context[:service_record_selection] == selection
860
+ retained = context[:service_record]
861
+ else
862
+ context&.delete(:service_record_selection)
863
+ context&.delete(:service_record)
864
+ retained = read_service_request_record(request_id, commit: commit)
865
+ if reusable && retained
866
+ retained = freeze_inventory_projection(retained)
867
+ context[:service_record_selection], context[:service_record] = selection, retained
868
+ end
869
+ end
870
+ # This memo contains JSON and its immutable accepted event proof,
871
+ # never terminal evidence admission. Every callback remains fresh;
872
+ # callers receive their own mutable copy rather than cached state.
873
+ record = retained && (reusable ? JSON.parse(JSON.generate(retained)) : retained)
874
+ validate_terminal_receipt!(record, record["state"], record["receipt"], pending: pending) if
875
+ record && terminal && @mode == :protected && terminal_state?(record["state"])
876
+ # Historical dependency reads may replace this one slot while the
877
+ # fresh callback executes. Restore only this successful raw view.
878
+ if reusable && retained
879
+ context[:service_record_selection], context[:service_record] = selection, retained
880
+ end
881
+ record
882
+ rescue Exception
883
+ context&.delete(:service_record_selection)
884
+ context&.delete(:service_record)
885
+ raise
886
+ end
887
+
888
+ def read_service_request_record(request_id, commit:)
889
+ return nil unless commit
890
+ out, stderr, status = git("show", "#{commit}:#{service_request_path(request_id)}")
299
891
  if status.success?
300
892
  record = JSON.parse(out)
301
- verify_service_record!(record, commit: value) if @mode == :protected
302
- validate_terminal_receipt!(record, record["state"], record["receipt"], pending: {commit: value}) if @mode == :protected && terminal_state?(record["state"])
893
+ verify_service_record!(record, commit: commit) if @mode == :protected
303
894
  return record
304
895
  end
305
896
  return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
@@ -308,6 +899,8 @@ module Ace
308
899
  raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
309
900
  end
310
901
 
902
+ private :read_service_request, :read_service_request_record
903
+
311
904
  # Every service request recorded in this evidence ref, whatever the
312
905
  # owning assignment. Backs the journal-wide request-ID and
313
906
  # authorization-consumption checks.
@@ -431,6 +1024,46 @@ module Ace
431
1024
 
432
1025
  private
433
1026
 
1027
+ # Sizes come from the same immutable tree selection. Batch memory is
1028
+ # bounded by that selection; an existing larger single event retains
1029
+ # its previous read semantics rather than gaining a lifetime ceiling.
1030
+ def read_event_blobs!(entries)
1031
+ limit = entries.sum { |_oid, size| size + 128 }
1032
+ return @read_boundary.blob_batch(entries.map(&:first), output_limit: limit) if @read_boundary
1033
+ result = Herdr::Molecules::BoundedProcess.call(["git", "cat-file", "--batch"],
1034
+ chdir: File.expand_path(@repo_root), stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join,
1035
+ timeout_s: 30, output_limit: limit)
1036
+ unless result.status.success? && !result.oversized
1037
+ raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable or exceeds selected sizes"
1038
+ end
1039
+ result.stdout.to_s.b
1040
+ rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError => error
1041
+ raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable: #{error.message}"
1042
+ end
1043
+
1044
+ def decode_event_blobs!(entries, output)
1045
+ output = output.b
1046
+ offset = 0
1047
+ entries.map do |oid, size|
1048
+ ending = output.index("\n", offset)
1049
+ unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
1050
+ raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
1051
+ end
1052
+ offset = ending + 1
1053
+ content = output.byteslice(offset, size)
1054
+ unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
1055
+ Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
1056
+ raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
1057
+ end
1058
+ offset += size + 1
1059
+ content
1060
+ end.tap do
1061
+ unless offset == output.bytesize
1062
+ raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
1063
+ end
1064
+ end
1065
+ end
1066
+
434
1067
  def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
435
1068
  validate_request_id!(request_id)
436
1069
  with_lock do
@@ -468,7 +1101,7 @@ module Ace
468
1101
  def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
469
1102
  validate_request_id!(request_id)
470
1103
  unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
471
- %w[service_claim service_transition].include?(event_type) &&
1104
+ %w[service_claim service_transition service_challenge].include?(event_type) &&
472
1105
  %w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
473
1106
  raise ArgumentError, "invalid service update plan"
474
1107
  end
@@ -496,17 +1129,43 @@ module Ace
496
1129
  raise_authorization_conflict!(conflict) if conflict
497
1130
  end
498
1131
  else
499
- unless event_type == "service_transition"
1132
+ challenge_update = event_type == "service_challenge" && pending && pending[:operation] == "claim_service_settlement" &&
1133
+ existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
1134
+ %w[uncertain failed].include?(existing["state"])
1135
+ unless event_type == "service_transition" || challenge_update
500
1136
  raise AttemptErrors::InvalidState, "Existing service request needs a transition"
501
1137
  end
502
1138
  terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
503
1139
  settlement = replacement["state"] == "failed-settled" &&
504
1140
  (existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
505
- if terminal && !settlement
1141
+ recovered_cleanup = @mode == :protected && pending && pending[:operation] == "complete_service" &&
1142
+ existing["operation"] == "prune-preserved-workspace" && existing["state"] == "failed" &&
1143
+ existing["dispatch_phase"] == "dispatch_started" && existing["completion_digest"].nil? &&
1144
+ replacement["state"] == "succeeded"
1145
+ if terminal && !settlement && !challenge_update && !recovered_cleanup
506
1146
  raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
507
1147
  end
508
1148
  mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
509
- challenge_generation challenge_event_digest completion_digest]
1149
+ challenge_generation challenge_event_digest completion_digest no_effect_completion_digest]
1150
+ # Only this authenticated fresh dispatch may introduce the fixed
1151
+ # root collaborator identity. It is immutable on every later update.
1152
+ if @mode == :protected && pending && pending[:operation] == "begin_dispatch" &&
1153
+ existing["operation"] == "prune-preserved-workspace" && existing["dispatch_phase"] == "issued" &&
1154
+ replacement["dispatch_phase"] == "dispatch_started" &&
1155
+ %w[operation_owner_binding executor_process_binding].none? { |key| existing.key?(key) } &&
1156
+ %w[operation_owner_binding executor_process_binding].all? { |key| replacement.key?(key) }
1157
+ mutable.concat(%w[operation_owner_binding executor_process_binding])
1158
+ end
1159
+ if @mode == :protected && pending && existing["operation"] == "publish"
1160
+ if pending[:operation] == "begin_dispatch" && existing["dispatch_phase"] == "issued" &&
1161
+ replacement["dispatch_phase"] == "dispatch_started" && !existing.key?("executor_process_binding") &&
1162
+ replacement.key?("executor_process_binding")
1163
+ mutable << "executor_process_binding"
1164
+ elsif %w[publication_challenge publication_continue].include?(pending[:operation])
1165
+ mutable.concat(%w[publication_challenge_digest publication_challenge_ref publication_challenge_event_digest
1166
+ publication_challenge_generation publication_issuing_event_digest])
1167
+ end
1168
+ end
510
1169
  unless existing.except(*mutable) == replacement.except(*mutable)
511
1170
  raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
512
1171
  end
@@ -554,7 +1213,7 @@ module Ace
554
1213
  raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
555
1214
  end
556
1215
  event = events.reverse.find do |entry|
557
- %w[service_claim service_transition].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
1216
+ %w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
558
1217
  end
559
1218
  unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
560
1219
  raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
@@ -589,22 +1248,7 @@ module Ace
589
1248
  # Latest lifecycle state implied by the events, or nil when the
590
1249
  # events do not describe a full attempt (intent missing).
591
1250
  def derive_state(events)
592
- return nil unless events.any? { |event| event["type"] == "intent" }
593
-
594
- state = "reserved"
595
- events.each do |event|
596
- case event["type"]
597
- when "process_start"
598
- state = "running"
599
- when "receipt_accepted"
600
- state = event.dig("payload", "receipt", "verdict") || state
601
- when "transition"
602
- state = event.dig("payload", "to") || state
603
- when "reconciliation"
604
- state = event.dig("payload", "resolution") || state
605
- end
606
- end
607
- state
1251
+ CanonicalAttemptState.derive(events)
608
1252
  end
609
1253
 
610
1254
  def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
@@ -743,21 +1387,52 @@ module Ace
743
1387
  File.join(@checkout_root, ".evidence.lock")
744
1388
  end
745
1389
 
746
- def with_lock
1390
+ def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
1391
+ raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
747
1392
  FileUtils.mkdir_p(@checkout_root)
748
- File.open(lock_path, File::RDWR | File::CREAT) do |lock|
1393
+ lock = if owner_uid
1394
+ initialization_lock!(owner_uid: owner_uid, owner_gid: owner_gid)
1395
+ else
1396
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600)
1397
+ end
1398
+ begin
749
1399
  lock.flock(File::LOCK_EX)
750
1400
  begin
751
1401
  yield
752
1402
  rescue StandardError
753
- # No rejected writer may leave staged or untracked transaction
754
- # data for the next writer to admit. This checkout is disposable.
755
- sync_checkout(ref_value) if File.exist?(File.join(checkout_dir, ".git"))
1403
+ # Ordinary mutation rollback is unchanged. Initialization must
1404
+ # not adopt/reset a foreign disposable checkout on refusal.
1405
+ sync_checkout(ref_value) if cleanup_on_failure && File.exist?(File.join(checkout_dir, ".git"))
756
1406
  raise
757
1407
  ensure
758
1408
  lock.flock(File::LOCK_UN)
759
1409
  end
1410
+ ensure
1411
+ lock.close
1412
+ end
1413
+ end
1414
+
1415
+ def initialization_lock!(owner_uid:, owner_gid:)
1416
+ created = false
1417
+ begin
1418
+ file = File.open(lock_path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600)
1419
+ created = true
1420
+ rescue Errno::EEXIST
1421
+ file = File.open(lock_path, File::RDWR | File::NOFOLLOW)
1422
+ end
1423
+ if created
1424
+ file.flush
1425
+ file.fsync
1426
+ end
1427
+ info = file.stat
1428
+ unless info.file? && info.nlink == 1 && info.size.zero? &&
1429
+ [info.uid, info.gid, info.mode & 0o7777] == [owner_uid, owner_gid, 0o600]
1430
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization lock protection differs"
760
1431
  end
1432
+ file
1433
+ rescue Exception
1434
+ file&.close
1435
+ raise
761
1436
  end
762
1437
 
763
1438
  def ensure_checkout!
@@ -811,6 +1486,7 @@ module Ace
811
1486
  end
812
1487
 
813
1488
  def git(*argv)
1489
+ return @read_boundary.call(argv) if @read_boundary
814
1490
  unless File.directory?(@repo_root)
815
1491
  return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
816
1492
  end