ace-assign 0.63.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require_relative "canonical_attempt_state"
4
+
4
5
  require "digest"
5
6
  require "fileutils"
6
7
  require "json"
7
8
  require "open3"
9
+ require "pathname"
10
+ require "ace/herdr/molecules/bounded_process"
11
+ require_relative "proposal_journal"
12
+ require_relative "journal_mutation"
8
13
 
9
14
  module Ace
10
15
  module Assign
@@ -25,12 +30,43 @@ module Ace
25
30
  # reloads the authoritative ref and replays only still-valid,
26
31
  # non-duplicate events.
27
32
  class EvidenceJournal
33
+ include JournalMutation
34
+ include ProposalJournal
28
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
50
+
51
+ # Selected by source composition, never by receipt or wire parameters.
52
+ def evidence_mode
53
+ @mode
54
+ end
29
55
 
30
56
  # @param repo_root [String] Git repository root holding the evidence ref
31
57
  # @param ref [String, nil] Evidence ref (default from config)
32
58
  # @param checkout_root [String, nil] Isolated checkout root (default from config)
33
- def initialize(repo_root:, ref: nil, checkout_root: nil)
59
+ def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
60
+ evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
61
+ unless %i[local protected].include?(mode) &&
62
+ (mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
63
+ raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
64
+ end
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
34
70
  @repo_root = repo_root
35
71
  @ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
36
72
  root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
@@ -44,6 +80,71 @@ module Ace
44
80
  git_ok?("rev-parse", "--git-dir")
45
81
  end
46
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
+
47
148
  # Current value of the evidence ref.
48
149
  #
49
150
  # @return [String, nil] Commit SHA or nil when the ref does not exist
@@ -56,6 +157,376 @@ module Ace
56
157
  nil
57
158
  end
58
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
+
59
530
  # Append accepted events to the journal under lock + CAS.
60
531
  #
61
532
  # @param assignment_id [String] Assignment ID (journal path segment)
@@ -89,28 +560,112 @@ module Ace
89
560
  end
90
561
  end
91
562
 
563
+ # Build an event against the latest chain while holding the ref lock.
564
+ # Re-run the eligibility guard on each CAS retry, never append a
565
+ # stale chain continuation after another accepted writer.
566
+ def record(assignment_id:, attempt_id:, type:, payload:, guard: nil)
567
+ with_lock do
568
+ CAS_ATTEMPTS.times do
569
+ old = ref_value
570
+ ensure_checkout!
571
+ old = ref_value if old.nil?
572
+ sync_checkout(old)
573
+ guard&.call
574
+ previous = read_events(assignment_id).reverse.find { |event| event["attempt_id"] == attempt_id }&.fetch("digest")
575
+ event = Models::EvidenceEvent.build(type: type, attempt_id: attempt_id,
576
+ payload: payload, previous_digest: previous)
577
+ write_event_files(assignment_id, [event])
578
+ commit = commit_events(assignment_id, attempt_id, [event])
579
+ return commit if update_ref_cas(commit, old)
580
+ end
581
+ raise AttemptErrors::EvidenceUnavailable, "Evidence ref stayed conflicting during event recording"
582
+ end
583
+ end
584
+
92
585
  # Read all journal events recorded for an assignment, ordered by the
93
586
  # digest chain (never by filename: same-second events sort
94
587
  # alphabetically, which can invert lifecycle order).
95
588
  #
96
589
  # @param assignment_id [String] Assignment ID
97
590
  # @return [Array<Hash>] Parsed events in journal order
98
- def read_events(assignment_id)
99
- value = ref_value
100
- return [] if value.nil?
591
+ def read_events(assignment_id, commit: ref_value)
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
101
618
 
102
- paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--",
103
- "execution/#{assignment_id}/events/")
104
- raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
105
- events = paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.map do |path|
106
- content, error, read_status = git("show", "#{value}:#{path}")
107
- raise AttemptErrors::EvidenceUnavailable, "Cannot read journal event: #{error}" unless read_status.success?
108
- 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
109
652
  end
110
- order_by_chain(events)
111
- rescue JSON::ParserError => e
112
- 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"
113
667
  end
668
+ private :read_event_snapshots!
114
669
 
115
670
  # Accepted receipt payloads recorded for an assignment.
116
671
  #
@@ -197,7 +752,7 @@ module Ace
197
752
  %w[succeeded failed failed-settled].include?(state)
198
753
  end
199
754
 
200
- def validate_terminal_receipt!(current, state, receipt)
755
+ def validate_terminal_receipt!(current, state, receipt, pending: nil)
201
756
  unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
202
757
  raise AttemptErrors::ReceiptRejected,
203
758
  "Service terminal receipt has invalid fields"
@@ -224,6 +779,27 @@ module Ace
224
779
  raise AttemptErrors::ReceiptRejected,
225
780
  "Service settlement requires a no-effect attestation artifact"
226
781
  end
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
794
+ evidence_items.each do |item|
795
+ content = @evidence_reader.call(item, current, state, pending)
796
+ unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
797
+ raise AttemptErrors::ReceiptRejected, "Canonical service evidence is unverifiable"
798
+ end
799
+ verify_service_attestation!(content, current, state)
800
+ end
801
+ return true
802
+ end
227
803
  repo_root = File.realpath(@repo_root)
228
804
  receipt["evidence"].each do |item|
229
805
  path = File.expand_path(item["ref"], repo_root)
@@ -241,30 +817,90 @@ module Ace
241
817
  end
242
818
  raise AttemptErrors::ReceiptRejected,
243
819
  "Service terminal receipt evidence is unverifiable: #{item["ref"]}" unless intact
244
- attestation = /^ace-service-attestation request:#{Regexp.escape(current["request_id"])} \
245
- input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
246
- attested = File.read(real).scan(attestation).first
247
- attested_outcome = state == "failed-settled" ? "failed" : state
248
- unless attested && attested.first == attested_outcome &&
249
- (state == "failed-settled" ? attested[2] == "true" : true)
250
- raise AttemptErrors::ReceiptRejected,
251
- "Service terminal receipt evidence does not attest #{attested_outcome}: #{item["ref"]}"
252
- end
820
+ verify_service_attestation!(File.read(real), current, state)
821
+ end
822
+ end
823
+
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})
253
846
  end
847
+ view.freeze
848
+ {record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
254
849
  end
255
850
 
256
- def service_request(request_id)
851
+ def read_service_request(request_id, commit:, pending:, terminal: true)
852
+ context = Thread.current[:ace_assign_event_read_operation]
257
853
  validate_request_id!(request_id)
258
- value = ref_value
259
- return nil unless value
260
- out, stderr, status = git("show", "#{value}:#{service_request_path(request_id)}")
261
- return JSON.parse(out) if status.success?
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)}")
891
+ if status.success?
892
+ record = JSON.parse(out)
893
+ verify_service_record!(record, commit: commit) if @mode == :protected
894
+ return record
895
+ end
262
896
  return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
263
897
  raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
264
898
  rescue JSON::ParserError
265
899
  raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
266
900
  end
267
901
 
902
+ private :read_service_request, :read_service_request_record
903
+
268
904
  # Every service request recorded in this evidence ref, whatever the
269
905
  # owning assignment. Backs the journal-wide request-ID and
270
906
  # authorization-consumption checks.
@@ -292,6 +928,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
292
928
  def settlement_evidence_intact?(record)
293
929
  receipt = record["receipt"]
294
930
  return false unless receipt.is_a?(Hash)
931
+ if @mode == :protected
932
+ commit = ref_value
933
+ verify_service_record!(record, commit: commit)
934
+ validate_terminal_receipt!(record, "failed-settled", receipt, pending: {commit: commit})
935
+ return true
936
+ end
295
937
  repo_root = File.realpath(@repo_root)
296
938
  Array(receipt["evidence"]).all? do |item|
297
939
  path = File.expand_path(item["ref"].to_s, repo_root)
@@ -307,10 +949,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
307
949
  false
308
950
  end
309
951
  end
952
+ rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::ReceiptRejected
953
+ false
310
954
  end
311
955
 
312
- def service_request_records
313
- value = ref_value
956
+ def service_request_records(commit: ref_value)
957
+ value = commit
314
958
  return [] unless value
315
959
  paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
316
960
  raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
@@ -319,7 +963,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
319
963
  unless read_status.success?
320
964
  raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
321
965
  end
322
- JSON.parse(content)
966
+ record = JSON.parse(content)
967
+ verify_service_record!(record, commit: value) if @mode == :protected
968
+ record
323
969
  end
324
970
  rescue JSON::ParserError
325
971
  raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
@@ -366,8 +1012,8 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
366
1012
  # ref itself (works even when the local assignment cache is gone).
367
1013
  #
368
1014
  # @return [Array<String>] Assignment IDs with journal evidence
369
- def assignment_ids
370
- value = ref_value
1015
+ def assignment_ids(commit: ref_value)
1016
+ value = commit
371
1017
  return [] if value.nil?
372
1018
 
373
1019
  paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
@@ -378,6 +1024,46 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
378
1024
 
379
1025
  private
380
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
+
381
1067
  def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
382
1068
  validate_request_id!(request_id)
383
1069
  with_lock do
@@ -386,47 +1072,20 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
386
1072
  ensure_checkout!
387
1073
  old = ref_value if old.nil?
388
1074
  sync_checkout(old)
389
- # Guards (for example authorization consumption) re-run inside
390
- # the lock against the ref being committed, so a racing claim
391
- # cannot slip through between the check and the CAS.
392
- if guard
393
- conflict = guard.call
394
- if conflict
395
- raise AttemptErrors::Conflict,
396
- "Authorization reference already consumed by request #{conflict.fetch("request_id")}"
397
- end
398
- end
399
- path = File.join(checkout_dir, service_request_path(request_id))
400
- existing = File.exist?(path) ? JSON.parse(File.read(path)) : nil
401
- if expected.nil? && existing
402
- return existing if existing.except("state", "receipt", "reason", "claimed_at") ==
403
- replacement.except("state", "receipt", "reason", "claimed_at")
404
- raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
405
- end
406
- if expected && existing != expected
407
- raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
408
- end
409
- terminal = existing && %w[succeeded failed rejected].include?(existing["state"])
410
- settlement = existing && existing["state"] == "failed" && replacement["state"] == "failed-settled"
411
- if expected && existing && terminal && !settlement
412
- raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
413
- end
414
-
415
- FileUtils.mkdir_p(File.dirname(path))
416
- File.write(path, JSON.pretty_generate(replacement))
1075
+ prepared = prepare_service_update(request_id: request_id, expected: expected,
1076
+ replacement: replacement, event_type: event_type, guard: guard)
1077
+ return prepared.fetch(:record) if prepared[:replayed]
1078
+ write_service_records([prepared])
417
1079
  assignment_id = replacement.fetch("assignment_id")
418
1080
  attempt_id = replacement.fetch("attempt_id")
419
1081
  prior = read_events(assignment_id).reverse
420
1082
  .find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
421
- payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
422
- "input_digest" => replacement.fetch("input_digest"),
423
- "receipt_digest" => replacement["receipt"] &&
424
- Atoms::EvidenceDigest.digest(replacement["receipt"])}
425
1083
  event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
426
- payload: payload, previous_digest: prior)
1084
+ payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
427
1085
  write_event_files(assignment_id, [event])
428
1086
  git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
429
1087
  "execution/#{assignment_id}/events")
1088
+ stage_service_records([prepared])
430
1089
  state = replacement.fetch("state")
431
1090
  git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
432
1091
  "commit", "-m", "evidence: service request #{request_id} #{state}")
@@ -437,6 +1096,146 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
437
1096
  end
438
1097
  end
439
1098
 
1099
+ # Shared owner for ordinary service writes and atomic authority
1100
+ # imports. Call only after checkout sync, under this journal's CAS lock.
1101
+ def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
1102
+ validate_request_id!(request_id)
1103
+ unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
1104
+ %w[service_claim service_transition service_challenge].include?(event_type) &&
1105
+ %w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
1106
+ raise ArgumentError, "invalid service update plan"
1107
+ end
1108
+ existing = service_request(request_id)
1109
+ if expected.nil? && existing
1110
+ return {record: existing, replayed: true} if existing.except("state", "receipt", "reason", "claimed_at") ==
1111
+ replacement.except("state", "receipt", "reason", "claimed_at")
1112
+ raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
1113
+ end
1114
+ if expected && existing != expected
1115
+ raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
1116
+ end
1117
+ if expected.nil?
1118
+ unless event_type == "service_claim" && %w[accepted uncertain rejected].include?(replacement["state"])
1119
+ raise AttemptErrors::InvalidState, "Service request needs an initial claim"
1120
+ end
1121
+ if %w[accepted uncertain].include?(replacement["state"])
1122
+ if replacement["authorization"].to_s.start_with?("proposal-")
1123
+ unless respond_to?(:proposal_authorize!, true)
1124
+ raise AttemptErrors::UnauthorizedIdentity, "Canonical proposal producer is unavailable"
1125
+ end
1126
+ proposal_authorize!(replacement["authorization"], replacement)
1127
+ end
1128
+ conflict = authorization_conflict(replacement)
1129
+ raise_authorization_conflict!(conflict) if conflict
1130
+ end
1131
+ else
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
1136
+ raise AttemptErrors::InvalidState, "Existing service request needs a transition"
1137
+ end
1138
+ terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
1139
+ settlement = replacement["state"] == "failed-settled" &&
1140
+ (existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
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
1146
+ raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
1147
+ end
1148
+ mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
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
1169
+ unless existing.except(*mutable) == replacement.except(*mutable)
1170
+ raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
1171
+ end
1172
+ end
1173
+ conflict = guard&.call
1174
+ raise_authorization_conflict!(conflict) if conflict
1175
+ @service_authorizer.call(existing, replacement, pending) if @mode == :protected
1176
+ validate_terminal_receipt!(replacement, replacement["state"], replacement["receipt"], pending: pending) if terminal_state?(replacement["state"])
1177
+ payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
1178
+ "input_digest" => replacement.fetch("input_digest"),
1179
+ "record_digest" => Atoms::EvidenceDigest.digest(replacement),
1180
+ "receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
1181
+ {record: replacement, path: service_request_path(request_id),
1182
+ event: {type: event_type, payload: payload}, replayed: false}
1183
+ end
1184
+
1185
+ def prepare_service_updates(updates, assignment_id:, attempt_id:, pending:)
1186
+ unless updates.is_a?(Array) && updates.length <= 1
1187
+ raise ArgumentError, "one service update is allowed per authority mutation"
1188
+ end
1189
+ updates.map do |update|
1190
+ unless update.is_a?(Hash) && update.keys.sort == %i[event_type expected replacement request_id].sort &&
1191
+ update.dig(:replacement, "assignment_id") == assignment_id && update.dig(:replacement, "attempt_id") == attempt_id
1192
+ raise ArgumentError, "service update does not match mutation attempt"
1193
+ end
1194
+ prepare_service_update(**update, pending: pending)
1195
+ end.reject { |prepared| prepared[:replayed] }
1196
+ end
1197
+
1198
+ def write_service_records(records)
1199
+ records.each do |prepared|
1200
+ path = File.join(checkout_dir, prepared.fetch(:path))
1201
+ FileUtils.mkdir_p(File.dirname(path))
1202
+ File.write(path, JSON.pretty_generate(prepared.fetch(:record)))
1203
+ end
1204
+ end
1205
+
1206
+ def stage_service_records(records)
1207
+ stage_mutation_blobs(records.to_h { |prepared| [prepared.fetch(:path), JSON.pretty_generate(prepared.fetch(:record))] })
1208
+ end
1209
+
1210
+ def verify_service_record!(record, commit: ref_value)
1211
+ events = read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
1212
+ unless Models::EvidenceEvent.chain_valid?(events)
1213
+ raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
1214
+ end
1215
+ event = events.reverse.find do |entry|
1216
+ %w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
1217
+ end
1218
+ unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
1219
+ raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
1220
+ end
1221
+ rescue KeyError, TypeError
1222
+ raise AttemptErrors::EvidenceUnavailable, "Canonical service record has invalid binding"
1223
+ end
1224
+
1225
+ def raise_authorization_conflict!(conflict)
1226
+ raise AttemptErrors::Conflict,
1227
+ "Authorization reference already consumed by request #{conflict.fetch('request_id')}"
1228
+ end
1229
+
1230
+ def verify_service_attestation!(content, request, state)
1231
+ expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
1232
+ attested = content.scan(expression).first
1233
+ outcome = state == "failed-settled" ? "failed" : state
1234
+ unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
1235
+ raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
1236
+ end
1237
+ end
1238
+
440
1239
  def service_request_path(request_id)
441
1240
  "execution/requests/#{request_id}.json"
442
1241
  end
@@ -449,39 +1248,31 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
449
1248
  # Latest lifecycle state implied by the events, or nil when the
450
1249
  # events do not describe a full attempt (intent missing).
451
1250
  def derive_state(events)
452
- return nil unless events.any? { |event| event["type"] == "intent" }
453
-
454
- state = "running"
455
- events.each do |event|
456
- case event["type"]
457
- when "receipt_accepted"
458
- state = event.dig("payload", "receipt", "verdict") || state
459
- when "transition"
460
- state = event.dig("payload", "to") || state
461
- when "reconciliation"
462
- state = event.dig("payload", "resolution") || state
463
- end
464
- end
465
- state
1251
+ CanonicalAttemptState.derive(events)
466
1252
  end
467
1253
 
468
1254
  def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
469
1255
  process_start = events.reverse.find do |event|
470
1256
  event["type"] == "process_start" && event["attempt_id"] == attempt_id
471
1257
  end
472
- candidate_head = events.select { |event| event["type"] == "receipt_accepted" }
473
- .map { |event| event.dig("payload", "receipt", "head") }
474
- .compact
475
- .first
1258
+ candidate_head = nil
1259
+ events.each do |event|
1260
+ case event["type"]
1261
+ when "candidate_invalidated" then candidate_head = nil
1262
+ when "receipt_accepted" then candidate_head ||= event.dig("payload", "receipt", "head")
1263
+ when "delivery"
1264
+ candidate_head = event.dig("payload", "head") if %w[intent result].include?(event.dig("payload", "stage"))
1265
+ end
1266
+ end
476
1267
  binding = Models::AttemptBinding.new(
477
1268
  attempt_id: attempt_id,
478
1269
  assignment_id: assignment_id,
479
1270
  scope: intent_payload["scope"],
480
1271
  project_id: intent_payload["project_id"],
481
1272
  task_id: intent_payload["task_id"],
482
- actor: process_start&.dig("payload", "actor") || "recovered",
483
- role: process_start&.dig("payload", "role") || "coordinator",
484
- runtime: process_start&.dig("payload", "runtime") || "recovered",
1273
+ actor: process_start&.dig("payload", "actor") || intent_payload["actor"] || "recovered",
1274
+ role: process_start&.dig("payload", "role") || intent_payload["role"] || "coordinator",
1275
+ runtime: process_start&.dig("payload", "runtime") || intent_payload["runtime"] || "recovered",
485
1276
  base_head: intent_payload["base_head"],
486
1277
  evidence_git_ref: ref,
487
1278
  created_at: parse_event_time(intent_time(events, attempt_id))
@@ -596,16 +1387,52 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
596
1387
  File.join(@checkout_root, ".evidence.lock")
597
1388
  end
598
1389
 
599
- 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
600
1392
  FileUtils.mkdir_p(@checkout_root)
601
- 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
602
1399
  lock.flock(File::LOCK_EX)
603
1400
  begin
604
1401
  yield
1402
+ rescue StandardError
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"))
1406
+ raise
605
1407
  ensure
606
1408
  lock.flock(File::LOCK_UN)
607
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"
608
1431
  end
1432
+ file
1433
+ rescue Exception
1434
+ file&.close
1435
+ raise
609
1436
  end
610
1437
 
611
1438
  def ensure_checkout!
@@ -648,6 +1475,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
648
1475
  return if target.nil?
649
1476
 
650
1477
  git!("-C", checkout_dir, "reset", "--hard", target)
1478
+ # All writers use this boundary, including CAS retries. Only the
1479
+ # journal's disposable namespaces are cleaned, never the candidate.
1480
+ git!("-C", checkout_dir, "clean", "-fd", "--", "execution", "evidence")
651
1481
  rescue AttemptErrors::EvidenceUnavailable
652
1482
  # Stale, broken, or unregistered worktree: rebuild from scratch.
653
1483
  FileUtils.rm_rf(checkout_dir)
@@ -656,6 +1486,7 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
656
1486
  end
657
1487
 
658
1488
  def git(*argv)
1489
+ return @read_boundary.call(argv) if @read_boundary
659
1490
  unless File.directory?(@repo_root)
660
1491
  return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
661
1492
  end