ace-assign 0.70.0 → 0.73.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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +3 -2099
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -1
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  36. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  37. data/lib/ace/assign/version.rb +1 -1
  38. data/lib/ace/assign.rb +0 -6
  39. metadata +4 -161
  40. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  41. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  42. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  43. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  44. data/docs/protected-authority.md +0 -282
  45. data/exe/ace-scope-ready +0 -55
  46. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  47. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  48. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  49. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  50. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  51. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  52. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  53. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  54. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  55. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  56. data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
  57. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  58. data/lib/ace/assign/authority/client.rb +0 -257
  59. data/lib/ace/assign/authority/deployment.rb +0 -627
  60. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  61. data/lib/ace/assign/authority/endcap.rb +0 -455
  62. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  63. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  64. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  65. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  66. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  67. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  68. data/lib/ace/assign/authority/endcap_publication.rb +0 -207
  69. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  70. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  71. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  72. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  73. data/lib/ace/assign/authority/endcap_services.rb +0 -598
  74. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  75. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  76. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  77. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  78. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  79. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  80. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  81. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  82. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  83. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  84. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  85. data/lib/ace/assign/authority/launch_review.rb +0 -91
  86. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  87. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  88. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  89. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  90. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  91. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  92. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  93. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  94. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  95. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  96. data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
  97. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  98. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
  99. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  100. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  101. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  102. data/lib/ace/assign/authority/ordinary_results.rb +0 -196
  103. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  104. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  105. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  106. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  107. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  108. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  109. data/lib/ace/assign/authority/prepared_work.rb +0 -304
  110. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  111. data/lib/ace/assign/authority/private_directory.rb +0 -33
  112. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  113. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  114. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  115. data/lib/ace/assign/authority/router.rb +0 -81
  116. data/lib/ace/assign/authority/server.rb +0 -131
  117. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  118. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  119. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  120. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  121. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  122. data/lib/ace/assign/authority/socket_server.rb +0 -255
  123. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  124. data/lib/ace/assign/authority/transfer_codec.rb +0 -187
  125. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  126. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  127. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  128. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  129. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  130. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  131. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  132. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  133. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  134. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  135. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  136. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  137. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  138. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  139. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  140. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  141. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  142. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  143. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  144. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  145. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  146. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  147. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  148. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  149. data/lib/ace/assign/models/attempt.rb +0 -178
  150. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  151. data/lib/ace/assign/models/evidence_event.rb +0 -86
  152. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  153. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  154. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  155. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  156. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
  157. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  158. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  159. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  160. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
  161. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  162. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  163. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  164. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  165. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  166. data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
  167. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  168. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  169. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  170. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  171. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  172. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  173. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  174. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  175. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
  176. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  177. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
  178. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  179. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  180. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  181. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,1912 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "canonical_attempt_state"
4
- require_relative "fixed_effect_reference"
5
- require_relative "../atoms/fixed_merge_schemas"
6
-
7
- require "digest"
8
- require "fileutils"
9
- require "json"
10
- require "open3"
11
- require "pathname"
12
- require "ace/herdr/molecules/bounded_process"
13
- require_relative "proposal_journal"
14
- require_relative "journal_mutation"
15
-
16
- module Ace
17
- module Assign
18
- module Molecules
19
- # Append-only execution evidence journal backed by a dedicated Git ref.
20
- #
21
- # Accepted evidence for assignment attempts is committed to a separate
22
- # configured evidence ref (default `refs/ace/execution`) in canonical,
23
- # non-secret paths `execution/<assignment-id>/events/`. The ref lives
24
- # outside the deliverable candidate branch: appending evidence never
25
- # advances or changes the reviewed candidate. Working state uses an
26
- # isolated, disposable detached worktree under the configured checkout
27
- # root.
28
- #
29
- # Concurrency: all ref updates serialize on a checkout-root flock, and
30
- # every update is additionally guarded by an expected-old-value
31
- # `git update-ref` compare-and-swap. On a CAS conflict the journal
32
- # reloads the authoritative ref and replays only still-valid,
33
- # non-duplicate events.
34
- class EvidenceJournal
35
- include JournalMutation
36
- include ProposalJournal
37
-
38
- CAS_ATTEMPTS = 3
39
- HISTORY_LIMIT = 100_000
40
- EVENT_BATCH_BYTES = 1024 * 1024
41
-
42
- OPERATION_INVENTORY_LIMIT = 8
43
-
44
- # A wire operation may retain one event/raw service selection and bounded immutable inventories, never admission
45
- # or current-ref truth. Nested operations own independent selections.
46
- def self.with_event_read_operation
47
- previous = Thread.current[:ace_assign_event_read_operation]
48
- Thread.current[:ace_assign_event_read_operation] = {}
49
- yield
50
- ensure
51
- Thread.current[:ace_assign_event_read_operation] = previous
52
- end
53
-
54
- # Selected by source composition, never by receipt or wire parameters.
55
- def evidence_mode
56
- @mode
57
- end
58
-
59
- # @param repo_root [String] Git repository root holding the evidence ref
60
- # @param ref [String, nil] Evidence ref (default from config)
61
- # @param checkout_root [String, nil] Isolated checkout root (default from config)
62
- def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
63
- evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
64
- unless %i[local protected].include?(mode) &&
65
- (evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
66
- (mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
67
- raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
68
- end
69
- @mode, @evidence_reader, @service_authorizer = mode, evidence_reader, service_authorizer
70
- if read_boundary && !%i[call blob_batch blob history_diff blob_sizes].all? { |method| read_boundary.respond_to?(method) }
71
- raise ArgumentError, "read-only journal boundary is malformed"
72
- end
73
- @read_boundary = read_boundary
74
- @repo_root = repo_root
75
- @ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
76
- root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
77
- @checkout_root = Pathname.new(root).absolute? ? root : File.join(repo_root, root)
78
- end
79
-
80
- attr_reader :repo_root, :ref, :checkout_root
81
-
82
- # @return [Boolean] True when the repo can hold the evidence ref
83
- def available?
84
- git_ok?("rev-parse", "--git-dir")
85
- end
86
-
87
- # Original operator preparation only. Reuse the journal's existing lock
88
- # and absent-ref CAS; never manufacture an event or reset existing history.
89
- def initialize_canonical!(owner_uid:, owner_gid:, owner_groups:)
90
- raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot initialize canonical evidence" if @read_boundary
91
- 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) &&
92
- [@repo_root, @checkout_root].all? { |path| Pathname.new(path).absolute? && File.expand_path(path) == path } &&
93
- File.directory?(@repo_root) && !File.symlink?(@repo_root) &&
94
- git_ok?("check-ref-format", @ref)
95
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection is invalid"
96
- end
97
- unless owner_groups.is_a?(Array) && owner_groups.all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } &&
98
- owner_groups.uniq.size == owner_groups.size &&
99
- [Process.uid, Process.euid, Process.gid, Process.egid] == [owner_uid, owner_uid, owner_gid, owner_gid] &&
100
- Process.groups.sort == owner_groups.sort
101
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization process identity differs"
102
- end
103
- selected = File.stat(@repo_root)
104
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository owner differs" unless selected.uid == owner_uid
105
- identity = [selected.dev, selected.ino, selected.uid, selected.gid, selected.mode]
106
- with_lock(cleanup_on_failure: false, owner_uid: owner_uid, owner_gid: owner_gid) do
107
- value = ref_value
108
- unless value
109
- if File.exist?(checkout_dir) || File.symlink?(checkout_dir)
110
- raise AttemptErrors::EvidenceUnavailable, "uninitialized journal has foreign checkout state"
111
- end
112
- seed_ref
113
- value = ref_value
114
- end
115
- nodes = canonical_history_nodes!(value)
116
- seed = nodes.last.first
117
- tree = git!("rev-parse", "#{seed}^{tree}").first
118
- message = git!("show", "-s", "--format=%B", seed).first
119
- unless tree == git!("hash-object", "-t", "tree", "--stdin").first && message == "seed: ace-assign execution evidence"
120
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization has foreign root history"
121
- end
122
- canonical_event_inventory!(commit: value)
123
- raw_paths = bounded_history_read!(["ls-tree", "-r", "-z", "--name-only", value], "", HISTORY_DIFF_BYTES)
124
- paths = raw_paths.empty? ? [] : raw_paths.split("\0", -1)
125
- unless paths.empty? || paths.pop == ""
126
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is incomplete"
127
- end
128
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is oversized" if paths.size > HISTORY_LIMIT
129
- paths.each do |path|
130
- parts = path.split("/", -1)
131
- event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
132
- request = parts.size == 3 && [%w[execution requests], %w[execution merge-requests]].include?(parts[0, 2]) && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
133
- # Definitions, prepared packets, candidate bundles and imports
134
- # retain the SAME mutation owner's closed immutable blob dialect.
135
- validate_blob_path!(path) unless event || request
136
- end
137
- records = service_request_records(commit: value)
138
- expected_requests = paths.select { |path| path.start_with?("execution/requests/") }.map { |path| File.basename(path, ".json") }.sort
139
- unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
140
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
141
- end
142
- fixed_records = fixed_merge_request_records(commit: value)
143
- expected_fixed = paths.select { |path| path.start_with?("execution/merge-requests/") }.map { |path| File.basename(path, ".json") }.sort
144
- unless fixed_records.map { |record| record.fetch("request_id") }.sort == expected_fixed
145
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization fixed merge inventory differs"
146
- end
147
- fixed_records.each { |record| verify_fixed_record!(record, commit: value) }
148
- current = File.stat(@repo_root)
149
- unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
150
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
151
- end
152
- value.dup.freeze
153
- end
154
- rescue SystemCallError, ArgumentError
155
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository is unavailable"
156
- end
157
-
158
- # Current value of the evidence ref.
159
- #
160
- # @return [String, nil] Commit SHA or nil when the ref does not exist
161
- def ref_value
162
- out, stderr, status = git("rev-parse", "--verify", "--quiet", @ref)
163
- return out if status.success?
164
-
165
- raise AttemptErrors::EvidenceUnavailable, "Cannot read evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
166
-
167
- nil
168
- end
169
-
170
- # Read-only verification for a fixed immutable maintenance snapshot.
171
- def verify_commit!(commit)
172
- unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
173
- raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is invalid"
174
- end
175
- kind, error, status = git("cat-file", "-t", commit)
176
- unless status.success? && kind == "commit"
177
- raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is unavailable: #{error}"
178
- end
179
- true
180
- end
181
-
182
- # All immutable owner queries select retained canonical evidence, not
183
- # arbitrary Git objects or valid-looking abandoned CAS candidates.
184
- def verify_canonical_prefix!(commit:, canonical_commit: ref_value)
185
- verify_commit!(commit)
186
- verify_commit!(canonical_commit)
187
- text, _error, status = git("rev-list", "--first-parent", "--parents",
188
- "--max-count=#{self.class::HISTORY_LIMIT + 1}", canonical_commit)
189
- nodes = text.lines.map(&:split)
190
- unless status.success? && nodes.size <= self.class::HISTORY_LIMIT && !nodes.empty? &&
191
- nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
192
- nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1 &&
193
- nodes.any? { |node| node.first == commit }
194
- raise AttemptErrors::EvidenceUnavailable, "Evidence prefix is not retained canonical first-parent history"
195
- end
196
- true
197
- end
198
-
199
- # Normal append commits inherit the event unchanged. Locate its one
200
- # introduction; inherited appearances are not competing provenance.
201
- def event_commit!(assignment_id:, event_digest:, commit:)
202
- event_commits!(assignment_id: assignment_id, event_digests: [event_digest], commit: commit).fetch(event_digest)
203
- end
204
-
205
- # Operation-scoped batch of the same provenance proof. Reuse only the
206
- # already authenticated exact wire inventory; never an owner cache.
207
- def event_commits!(assignment_id:, event_digests:, commit:)
208
- event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
209
- end
210
-
211
- # Cross-attempt projections use canonical publication order, never the
212
- # wall-clock timestamps of independently chained attempts. Exactly one
213
- # selected event must have been introduced by the latest relevant CAS.
214
- def latest_event!(assignment_id:, event_digests:, commit:)
215
- introductions = event_commits!(assignment_id: assignment_id, event_digests: event_digests, commit: commit)
216
- introduced_commits = introductions.values.to_h { |value| [value, true] }
217
- node = canonical_history_nodes!(commit).find { |entry| introduced_commits.key?(entry.first) }
218
- selected = node && introductions.select { |_digest, introduced| introduced == node.first }.keys
219
- unless selected&.one?
220
- raise AttemptErrors::EvidenceUnavailable, "latest canonical event is missing or ambiguous"
221
- end
222
- read_events(assignment_id, commit: commit).find { |event| event.fetch("digest") == selected.first } ||
223
- raise(AttemptErrors::EvidenceUnavailable, "latest canonical event is unavailable")
224
- end
225
-
226
- # One immutable walk for an operation selecting several assignments.
227
- # Every selected assignment retains its complete chain and exact raw
228
- # blob proof; the result is returned, never cached on this owner.
229
- def event_commits_for_assignments!(selectors:, commit:)
230
- unless selectors.is_a?(Hash) && !selectors.empty? && selectors.size <= HISTORY_LIMIT &&
231
- selectors.keys.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) } &&
232
- selectors.values.all? { |digests|
233
- digests.is_a?(Array) && !digests.empty? &&
234
- digests.uniq.size == digests.size && digests.all? { |digest| digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) }
235
- } &&
236
- selectors.values.sum(&:size) <= HISTORY_LIMIT
237
- raise AttemptErrors::EvidenceUnavailable, "historical event selectors are invalid"
238
- end
239
- if (inventory = retained_operation_inventory(commit))
240
- return selectors.to_h do |id, digests|
241
- introductions = inventory.fetch("introductions").fetch(id)
242
- [id.dup.freeze, digests.to_h { |digest| [digest.dup.freeze, introductions.fetch(digest)] }.freeze]
243
- end.freeze
244
- end
245
- nodes = canonical_history_nodes!(commit)
246
- states = selectors.to_h do |id, digests|
247
- [id, digests.to_h { |digest| [digest, {retained: nil, blob: nil, absent: false, introduction: nil}] }]
248
- end
249
- each_history_event_snapshot!(states.keys, nodes: nodes) do |node, snapshots, files|
250
- states.each do |assignment_id, selected_states|
251
- events = snapshots.fetch(assignment_id)
252
- validated_attempt_chains!(events)
253
- matches = events.select { |event| selected_states.key?(event["digest"]) }.group_by { |event| event.fetch("digest") }
254
- selected_states.each do |digest, state|
255
- selected = matches.fetch(digest, [])
256
- raise AttemptErrors::EvidenceUnavailable, "historical event selector is ambiguous" if selected.size > 1
257
- if selected.empty?
258
- raise AttemptErrors::EvidenceUnavailable, "historical event disappeared" if state[:retained]
259
- next
260
- end
261
- event = selected.first
262
- blob = files.fetch(assignment_id).fetch(event_filename(event))
263
- if (state[:retained] && state[:retained] != event) || (state[:blob] && state[:blob] != blob)
264
- raise AttemptErrors::EvidenceUnavailable, "historical event changed"
265
- end
266
- state[:blob] ||= blob
267
- state[:retained] ||= event
268
- state[:introduction] ||= node.first
269
- end
270
- end
271
- end
272
- unless states.values.all? { |selected_states| selected_states.values.all? { |state| state[:introduction] } }
273
- raise AttemptErrors::EvidenceUnavailable, "historical canonical event is unavailable"
274
- end
275
- states.to_h do |id, selected_states|
276
- [id.dup.freeze, selected_states.to_h { |digest, state| [digest.dup.freeze, state.fetch(:introduction).dup.freeze] }.freeze]
277
- end.freeze
278
- rescue KeyError, TypeError, ArgumentError, NoMethodError
279
- raise AttemptErrors::EvidenceUnavailable, "historical canonical event selectors are unverifiable"
280
- end
281
-
282
- HISTORY_DIFF_BYTES = 256 * 1024 * 1024
283
- HISTORY_DIFF_FLAGS = %w[diff-tree --stdin --always --root -r --raw -z --no-renames
284
- --no-ext-diff --no-textconv --no-abbrev --].freeze
285
-
286
- # The existing history owner still validates every prefix. Git returns
287
- # all requested headers, including unchanged commits; only immutable
288
- # changed blob bytes are batched rather than respawned at each node.
289
- def each_history_event_snapshot!(ids, nodes:)
290
- inventory = ids.nil?
291
- ids = [] if inventory
292
- files = ids.to_h { |id| [id, {}] }
293
- contents = ids.to_h { |id| [id, {}] }
294
- paths = inventory ? ["execution/"] : ids.map { |id| "execution/#{id}/events/" }
295
- nodes.reverse.each_slice(64) do |chunk|
296
- commits = chunk.map(&:first)
297
- raw = history_diff!(commits, paths)
298
- changes = decode_history_diff!(commits, paths, raw, all_paths: inventory)
299
- if inventory
300
- changes.each_value do |entries|
301
- entries.each do |path, *_|
302
- id = path.split("/")[1]
303
- next if id == "requests"
304
- unless id && id.match?(JournalMutation::ID)
305
- raise AttemptErrors::EvidenceUnavailable, "historical assignment path is invalid"
306
- end
307
- unless files.key?(id)
308
- raise AttemptErrors::EvidenceUnavailable, "historical assignment inventory exceeds bound" if ids.size >= HISTORY_LIMIT
309
- ids << id
310
- files[id], contents[id] = {}, {}
311
- end
312
- end
313
- entries.select! do |path, *_|
314
- id = path.split("/")[1]
315
- id != "requests" && path.start_with?("execution/#{id}/events/") && path.end_with?(".json")
316
- end
317
- end
318
- end
319
- oids = changes.values.flatten(1).filter_map { |change| change[3] unless change[3] == "0" * 40 }.uniq
320
- decoded = {}
321
- oids.each_slice(64) do |batch|
322
- sizes = history_blob_sizes!(batch)
323
- entries = batch.zip(sizes)
324
- groups = []
325
- entries.each do |entry|
326
- groups << [] if groups.empty? || (!groups.last.empty? && groups.last.sum(&:last) + entry.last > EVENT_BATCH_BYTES)
327
- groups.last << entry
328
- end
329
- groups.each do |group|
330
- decode_event_blobs!(group, read_event_blobs!(group)).each_with_index do |bytes, index|
331
- decoded[group.fetch(index).first] = JSON.parse(bytes)
332
- end
333
- end
334
- end
335
- chunk.each do |node|
336
- changes.fetch(node.first).each do |path, status, old, fresh|
337
- id = ids.find { |selected| path.start_with?("execution/#{selected}/events/") }
338
- name = path.delete_prefix("execution/#{id}/events/")
339
- current = files.fetch(id)[name]
340
- unless (status == "A" && old == "0" * 40 && current.nil?) ||
341
- (%w[M D T].include?(status) && current == old)
342
- raise AttemptErrors::EvidenceUnavailable, "historical tree change differs from previous immutable snapshot"
343
- end
344
- if status == "D"
345
- files.fetch(id).delete(name)
346
- contents.fetch(id).delete(name)
347
- else
348
- files.fetch(id)[name] = fresh
349
- contents.fetch(id)[name] = decoded.fetch(fresh)
350
- end
351
- end
352
- snapshots = contents.transform_values do |selected|
353
- events = selected.values
354
- digests = events.map { |event| event.fetch("digest") }
355
- raise AttemptErrors::EvidenceUnavailable, "historical event digest is ambiguous" unless digests.uniq.size == digests.size
356
- order_by_chain(events)
357
- end
358
- yield node, snapshots, files
359
- end
360
- end
361
- _events, actual = ids.empty? ? [{}, {}] : read_event_snapshots!(ids, commit: nodes.first.first)
362
- raise AttemptErrors::EvidenceUnavailable, "historical reconstructed tip differs from its immutable tree" unless files == actual
363
- rescue JSON::ParserError, KeyError, TypeError, ArgumentError
364
- raise AttemptErrors::EvidenceUnavailable, "historical batched snapshots are unverifiable"
365
- end
366
-
367
- def history_diff!(commits, paths)
368
- return @read_boundary.history_diff(commits, paths, output_limit: HISTORY_DIFF_BYTES) if @read_boundary
369
- bounded_history_read!(HISTORY_DIFF_FLAGS + paths, commits.join("\n") + "\n", HISTORY_DIFF_BYTES)
370
- end
371
-
372
- def history_blob_sizes!(oids)
373
- raw = if @read_boundary
374
- @read_boundary.blob_sizes(oids)
375
- else
376
- bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
377
- end
378
- lines = raw.lines
379
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata count differs" unless lines.size == oids.size
380
- lines.zip(oids).map do |line, oid|
381
- match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
382
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
383
- match[2].to_i
384
- end
385
- end
386
-
387
- def bounded_history_read!(argv, stdin, limit)
388
- result = Herdr::Molecules::BoundedProcess.call(["git", *argv], chdir: File.expand_path(@repo_root),
389
- stdin_data: stdin, timeout_s: 30, output_limit: limit)
390
- raise AttemptErrors::EvidenceUnavailable, "historical batch unavailable or exceeds bound" unless result.status.success? && !result.oversized
391
- result.stdout.to_s.b
392
- rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError
393
- raise AttemptErrors::EvidenceUnavailable, "historical batch is unavailable"
394
- end
395
-
396
- def decode_history_diff!(commits, paths, raw, all_paths: false)
397
- unless raw.is_a?(String) && raw.end_with?("\0")
398
- raise AttemptErrors::EvidenceUnavailable, "historical diff frame is incomplete"
399
- end
400
- tokens = raw.split("\0", -1)
401
- tokens.pop
402
- result = commits.to_h { |commit| [commit, []] }
403
- position = 0
404
- commits.each do |commit|
405
- raise AttemptErrors::EvidenceUnavailable, "historical commit header differs" unless tokens[position] == commit
406
- position += 1
407
- seen = {}
408
- while tokens[position]&.start_with?(":")
409
- metadata, path = tokens.values_at(position, position + 1)
410
- match = /\A:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([AMDT])\z/.match(metadata)
411
- unless match && path.is_a?(String) && paths.any? { |prefix| path.start_with?(prefix) } && !seen[path]
412
- raise AttemptErrors::EvidenceUnavailable, "historical tree metadata differs"
413
- end
414
- seen[path] = true
415
- position += 2
416
- next unless all_paths || path.end_with?(".json")
417
- old, fresh, status = match.values_at(3, 4, 5)
418
- if all_paths && !path.match?(%r{\Aexecution/[^/]+/events/.*\.json\z})
419
- # Complete discovery needs these assignment paths, but their
420
- # non-event bodies/modes never become canonical event facts.
421
- result.fetch(commit) << [path, status, old, fresh]
422
- next
423
- end
424
- if all_paths && path.start_with?("execution/requests/")
425
- result.fetch(commit) << [path, status, old, fresh]
426
- next
427
- end
428
- modes = match.values_at(1, 2)
429
- unless modes.all? { |mode| %w[000000 100644 100755 120000].include?(mode) } &&
430
- (if status == "A"
431
- old == "0" * 40 && fresh != "0" * 40 && modes.first == "000000" && modes.last != "000000"
432
- else
433
- (status == "D") ? old != "0" * 40 && fresh == "0" * 40 && modes.first != "000000" && modes.last == "000000" :
434
- old != "0" * 40 && fresh != "0" * 40 && modes.none? { |mode| mode == "000000" }
435
- end)
436
- raise AttemptErrors::EvidenceUnavailable, "historical event object type differs"
437
- end
438
- result.fetch(commit) << [path, status, old, fresh]
439
- end
440
- end
441
- raise AttemptErrors::EvidenceUnavailable, "historical diff has extra headers or bytes" unless position == tokens.size
442
- result
443
- end
444
- private :each_history_event_snapshot!, :history_diff!, :history_blob_sizes!, :bounded_history_read!, :decode_history_diff!
445
-
446
- # Complete immutable index facts are authenticated before any caller
447
- # filters ownership. A missing current fact cannot hide an older one.
448
- def canonical_event_inventory!(commit:)
449
- context = Thread.current[:ace_assign_event_read_operation]
450
- reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
451
- selection = operation_inventory_selection(commit) if reusable
452
- if reusable
453
- inventories = context[:inventories] ||= {}
454
- if inventories.key?(selection)
455
- # Refresh bounded insertion order, retaining only immutable facts.
456
- inventory = inventories.delete(selection)
457
- inventories[selection] = inventory
458
- return inventory
459
- end
460
- else
461
- context&.delete(:inventories)
462
- end
463
- inventory = read_canonical_inventory!(commit: commit)
464
- if reusable
465
- inventories.shift while inventories.size >= OPERATION_INVENTORY_LIMIT
466
- inventories[selection] = inventory
467
- end
468
- inventory
469
- end
470
-
471
- # Query only the exact full history already authenticated in this wire
472
- # operation. No historical selector builds or advances this snapshot.
473
- def operation_inventory_selection(commit)
474
- [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id, commit]
475
- .map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
476
- end
477
-
478
- def retained_operation_inventory(commit)
479
- context = Thread.current[:ace_assign_event_read_operation]
480
- return unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
481
- context.fetch(:inventories, {})[operation_inventory_selection(commit)]
482
- end
483
- private :operation_inventory_selection, :retained_operation_inventory
484
-
485
- def read_canonical_inventory!(commit:)
486
- nodes = canonical_history_nodes!(commit)
487
- root_events, introductions = {}, {}
488
- previous_chains, previous_files = {}, {}
489
- each_history_event_snapshot!(nil, nodes: nodes) do |node, snapshots, files|
490
- chains = snapshots.transform_values { |events| validated_attempt_chains!(events) }
491
- snapshots.each do |id, events|
492
- unless events.all? { |event| files.fetch(id).key?(event_filename(event)) }
493
- raise AttemptErrors::EvidenceUnavailable, "canonical event filename differs"
494
- end
495
- end
496
- previous_chains.each do |id, attempts|
497
- attempts.each do |attempt_id, chain|
498
- newer = chains.fetch(id, {}).fetch(attempt_id, [])
499
- unless chain == newer.take(chain.size)
500
- raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote an attempt"
501
- end
502
- end
503
- end
504
- previous_files.each do |id, selected_files|
505
- unless selected_files.all? { |name, oid| files.fetch(id, {})[name] == oid }
506
- raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote event bytes"
507
- end
508
- end
509
- snapshots.each do |id, events|
510
- introductions[id] ||= {}
511
- events.each { |event| introductions.fetch(id)[event.fetch("digest")] ||= node.first }
512
- end
513
- root_events = snapshots.reject { |_id, events| events.empty? }
514
- previous_chains = chains
515
- previous_files = files.transform_values(&:dup)
516
- end
517
- introductions.select! { |id, _| root_events.key?(id) }
518
- freeze_inventory_projection("commit" => commit, "events" => root_events, "introductions" => introductions)
519
- rescue KeyError, TypeError, ArgumentError, NoMethodError
520
- raise AttemptErrors::EvidenceUnavailable, "canonical inventory history is unverifiable"
521
- end
522
-
523
- private :read_canonical_inventory!
524
-
525
- def canonical_history_nodes!(commit)
526
- verify_commit!(commit)
527
- topology, error, status = git("rev-list", "--first-parent", "--parents",
528
- "--max-count=#{HISTORY_LIMIT + 1}", commit)
529
- nodes = topology.lines.map(&:split)
530
- unless status.success? && !nodes.empty? && nodes.size <= HISTORY_LIMIT &&
531
- nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
532
- nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1
533
- raise AttemptErrors::EvidenceUnavailable, "canonical history topology is unsupported or incomplete: #{error}"
534
- end
535
- nodes
536
- end
537
-
538
- def validated_attempt_chains!(events)
539
- chains = events.group_by { |event| event.fetch("attempt_id") }
540
- unless chains.values.all? { |chain| Models::EvidenceEvent.chain_valid?(chain) }
541
- raise AttemptErrors::EvidenceUnavailable, "historical canonical event chain is corrupt"
542
- end
543
- chains
544
- end
545
-
546
- def freeze_inventory_projection(value)
547
- case value
548
- when Hash
549
- value.to_h { |key, item| [freeze_inventory_projection(key), freeze_inventory_projection(item)] }.freeze
550
- when Array
551
- value.map { |item| freeze_inventory_projection(item) }.freeze
552
- when String
553
- value.dup.freeze
554
- else
555
- value.freeze
556
- end
557
- end
558
- private :canonical_history_nodes!, :validated_attempt_chains!, :freeze_inventory_projection
559
-
560
- # Append accepted events to the journal under lock + CAS.
561
- #
562
- # @param assignment_id [String] Assignment ID (journal path segment)
563
- # @param attempt_id [String] Attempt ID (commit message + dedupe scope)
564
- # @param events [Array<Hash>] Event payloads from Models::EvidenceEvent.build
565
- # @return [String] Journal commit SHA after the append
566
- # @raise [AttemptErrors::EvidenceUnavailable] when the ref stays conflicting
567
- def append(assignment_id:, attempt_id:, events:)
568
- return ref_value if events.empty?
569
-
570
- with_lock do
571
- CAS_ATTEMPTS.times do
572
- # Re-read the authoritative value every attempt. nil means the
573
- # ref does not exist yet; ensure_checkout! seeds it, so re-read
574
- # before computing the compare-and-swap baseline.
575
- old = ref_value
576
- ensure_checkout!
577
- old = ref_value if old.nil?
578
- sync_checkout(old)
579
- written = write_event_files(assignment_id, events)
580
- new_commit = written.zero? ? old : commit_events(assignment_id, attempt_id, events)
581
- return new_commit if update_ref_cas(new_commit, old)
582
-
583
- # CAS conflict: another writer advanced the ref. The next pass
584
- # resets the checkout to the authoritative tree and replays
585
- # only the events that are still missing (duplicates skipped).
586
- end
587
-
588
- raise AttemptErrors::EvidenceUnavailable,
589
- "Evidence ref #{@ref} stayed conflicting after #{CAS_ATTEMPTS} compare-and-swap attempts"
590
- end
591
- end
592
-
593
- # Build an event against the latest chain while holding the ref lock.
594
- # Re-run the eligibility guard on each CAS retry, never append a
595
- # stale chain continuation after another accepted writer.
596
- def record(assignment_id:, attempt_id:, type:, payload:, guard: nil)
597
- with_lock do
598
- CAS_ATTEMPTS.times do
599
- old = ref_value
600
- ensure_checkout!
601
- old = ref_value if old.nil?
602
- sync_checkout(old)
603
- guard&.call
604
- previous = read_events(assignment_id).reverse.find { |event| event["attempt_id"] == attempt_id }&.fetch("digest")
605
- event = Models::EvidenceEvent.build(type: type, attempt_id: attempt_id,
606
- payload: payload, previous_digest: previous)
607
- write_event_files(assignment_id, [event])
608
- commit = commit_events(assignment_id, attempt_id, [event])
609
- return commit if update_ref_cas(commit, old)
610
- end
611
- raise AttemptErrors::EvidenceUnavailable, "Evidence ref stayed conflicting during event recording"
612
- end
613
- end
614
-
615
- # Read all journal events recorded for an assignment, ordered by the
616
- # digest chain (never by filename: same-second events sort
617
- # alphabetically, which can invert lifecycle order).
618
- #
619
- # @param assignment_id [String] Assignment ID
620
- # @return [Array<Hash>] Parsed events in journal order
621
- def read_events(assignment_id, commit: ref_value)
622
- context = Thread.current[:ace_assign_event_read_operation]
623
- if commit.nil?
624
- context&.delete(:selection)
625
- context&.delete(:events)
626
- return []
627
- end
628
- unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
629
- context&.delete(:selection)
630
- context&.delete(:events)
631
- return read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
632
- end
633
- selection = [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id,
634
- assignment_id, commit].map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
635
- return context.fetch(:events) if context[:selection] == selection
636
- context.delete(:selection)
637
- context.delete(:events)
638
- inventory = retained_operation_inventory(commit) if assignment_id.is_a?(String) && assignment_id.match?(JournalMutation::ID)
639
- events = if inventory
640
- inventory.fetch("events").fetch(assignment_id, [])
641
- else
642
- read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
643
- end
644
- frozen = freeze_inventory_projection(events)
645
- context[:selection], context[:events] = selection, frozen
646
- frozen
647
- end
648
-
649
- # Bounded argv/blob batches from the same immutable tree. Preserve the
650
- # actual file OIDs alongside parsed chains for introduction proofs.
651
- def read_event_snapshots!(assignment_ids, commit:)
652
- unless assignment_ids.is_a?(Array) && !assignment_ids.empty? && assignment_ids.size <= HISTORY_LIMIT &&
653
- assignment_ids.uniq.size == assignment_ids.size && assignment_ids.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) }
654
- raise AttemptErrors::EvidenceUnavailable, "canonical assignment selectors are invalid"
655
- end
656
- events = assignment_ids.to_h { |id| [id, []] }
657
- files = assignment_ids.to_h { |id| [id, {}] }
658
- entries = []
659
- assignment_ids.each_slice(64) do |ids|
660
- paths, stderr, status = git("ls-tree", "-r", "-l", commit, "--", *ids.map { |id| "execution/#{id}/events/" })
661
- raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
662
- paths.lines.each do |line|
663
- metadata, path = line.chomp.split("\t", 2)
664
- next unless path&.end_with?(".json")
665
- _mode, kind, oid, size = metadata.split
666
- id = path.split("/")[1]
667
- unless events.key?(id) && path.start_with?("execution/#{id}/events/") && kind == "blob" &&
668
- oid&.match?(/\A[0-9a-f]{40}\z/) && size&.match?(/\A(?:0|[1-9][0-9]*)\z/)
669
- raise AttemptErrors::EvidenceUnavailable, "canonical event blob selection is invalid"
670
- end
671
- name = path.delete_prefix("execution/#{id}/events/")
672
- raise AttemptErrors::EvidenceUnavailable, "canonical event file is ambiguous" if files.fetch(id).key?(name)
673
- files.fetch(id)[name] = oid
674
- entries << [oid, size.to_i, id]
675
- end
676
- end
677
- batches = []
678
- entries.each do |entry|
679
- batches << [] if batches.empty? || batches.last.size == 64 ||
680
- (!batches.last.empty? && batches.last.sum { |item| item[1] } + entry[1] > EVENT_BATCH_BYTES)
681
- batches.last << entry
682
- end
683
- batches.each do |batch|
684
- selected = batch.map { |oid, size, _id| [oid, size] }
685
- contents = decode_event_blobs!(selected, read_event_blobs!(selected))
686
- batch.zip(contents).each { |(_oid, _size, id), content| events.fetch(id) << JSON.parse(content) }
687
- end
688
- events.each_value do |chain|
689
- digests = chain.map { |event| event.fetch("digest") }
690
- unless digests.uniq.size == digests.size
691
- raise AttemptErrors::EvidenceUnavailable, "canonical event digest is ambiguous"
692
- end
693
- end
694
- [events.transform_values { |chain| order_by_chain(chain) }, files]
695
- rescue JSON::ParserError
696
- raise AttemptErrors::EvidenceUnavailable, "Corrupt canonical journal event"
697
- end
698
- private :read_event_snapshots!
699
-
700
- # Accepted receipt payloads recorded for an assignment.
701
- #
702
- # @param assignment_id [String] Assignment ID
703
- # @return [Array<Hash>] Accepted receipt payloads (digest included)
704
- def accepted_receipts(assignment_id)
705
- read_events(assignment_id)
706
- .select { |event| event["type"] == "receipt_accepted" }
707
- .map { |event| event.dig("payload", "receipt") }
708
- .compact
709
- end
710
-
711
- # Reserve a globally unique service request in the same evidence ref
712
- # as its owning attempt. The request index and attempt event are one
713
- # Git commit, so a ref race cannot admit two effects for one ID. An
714
- # exact authorization reference is consumed by its first live claim:
715
- # a second request presenting the same operation/project/target
716
- # authorization is rejected instead of dispatching a duplicate effect.
717
- # Dispatching callers claim directly as +uncertain+ so no commit
718
- # window exists where a stranded claim reads as accepted.
719
- def claim_service_request(binding, state: "accepted", guard: nil)
720
- guard ||= -> { authorization_conflict(binding) }
721
- update_service_request(binding.fetch("request_id"), expected: nil,
722
- replacement: binding.merge("state" => state, "claimed_at" => Time.now.utc.iso8601(9)),
723
- event_type: "service_claim", guard: guard)
724
- end
725
-
726
- # Reject a service request. A request not yet on file gets its
727
- # auditable rejection claim (which never consumed the authorization);
728
- # an existing live claim transitions to rejected but stays consuming:
729
- # only attributable evidence that the effect did not occur may free a
730
- # dispatched authorization, never a bare rejection.
731
- def reject_service_request(binding, reason:)
732
- request_id = binding.fetch("request_id")
733
- existing = service_request(request_id)
734
- if existing
735
- unless existing.except("state", "receipt", "reason", "claimed_at", "failed_at") == binding.except("state", "receipt", "reason", "claimed_at", "failed_at")
736
- raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
737
- end
738
- return existing if existing["state"] == "rejected"
739
- return update_service_request(request_id, expected: existing,
740
- replacement: existing.merge("state" => "rejected", "reason" => reason),
741
- event_type: "service_transition")
742
- end
743
- update_service_request(request_id, expected: nil,
744
- replacement: binding.merge("state" => "rejected", "reason" => reason, "consumed" => false),
745
- event_type: "service_claim")
746
- end
747
-
748
- # Terminal writes are validated HERE at the journal boundary —
749
- # receipt schema, binding against the current record, and attested
750
- # outcome — so no caller-supplied flag decides trust. The
751
- # coordinator additionally performs content-level attestation and
752
- # file verification.
753
- def transition_service_request(request_id, state:, receipt: nil, validated: false)
754
- unless %w[succeeded failed uncertain rejected failed-settled].include?(state)
755
- raise ArgumentError, "invalid service request state"
756
- end
757
- current = service_request(request_id)
758
- raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless current
759
- # Terminal states are immutable; failed-settled is reached only
760
- # from a dispatched (failed, uncertain, or consumed-rejected)
761
- # effect with a validated no-effect receipt.
762
- consumed_rejected = current["state"] == "rejected" && current["consumed"] != false
763
- if %w[succeeded failed-settled].include?(current["state"]) ||
764
- (current["state"] == "rejected" && !consumed_rejected) ||
765
- (consumed_rejected && state != "failed-settled")
766
- raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
767
- end
768
- validate_terminal_receipt!(current, state, receipt) if terminal_state?(state)
769
- stamped = current.merge("state" => state, "receipt" => receipt)
770
- stamped["failed_at"] = Time.now.utc.iso8601(9) if state == "failed"
771
- update_service_request(request_id, expected: current,
772
- replacement: stamped,
773
- event_type: "service_transition")
774
- end
775
-
776
- TERMINAL_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
777
- input_digest operation outcome project_id request_id target transport].freeze
778
- TERMINAL_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
779
- input_digest target candidate_head executor_uid transport].freeze
780
-
781
- def terminal_state?(state)
782
- %w[succeeded failed failed-settled].include?(state)
783
- end
784
-
785
- # Shared schema/binding checks; storage interpretation belongs to the
786
- # caller's source-selected evidence owner, not this pure validator.
787
- def validate_terminal_receipt_structure!(current, state, receipt)
788
- unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
789
- raise AttemptErrors::ReceiptRejected,
790
- "Service terminal receipt has invalid fields"
791
- end
792
- TERMINAL_BINDING_FIELDS.each do |key|
793
- unless receipt[key] == current[key]
794
- raise AttemptErrors::ReceiptRejected, "Service terminal receipt does not match #{key}"
795
- end
796
- end
797
- expected_outcome = (state == "failed-settled") ? "failed" : state
798
- evidence_items = receipt["evidence"]
799
- valid_evidence = evidence_items.is_a?(Array) && !evidence_items.empty? &&
800
- evidence_items.all? do |item|
801
- item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
802
- item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
803
- item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
804
- end
805
- executor_valid = receipt["executor_uid"].is_a?(Integer) && receipt["executor_uid"] >= 0
806
- unless receipt["outcome"] == expected_outcome && executor_valid && valid_evidence
807
- raise AttemptErrors::ReceiptRejected, "Service terminal receipt has invalid executor or evidence"
808
- end
809
- if state == "failed-settled" &&
810
- !receipt["evidence"].any? { |item| item["ref"].end_with?("no-effect") }
811
- raise AttemptErrors::ReceiptRejected,
812
- "Service settlement requires a no-effect attestation artifact"
813
- end
814
- evidence_items
815
- end
816
-
817
- def validate_terminal_receipt!(current, state, receipt, pending: nil)
818
- evidence_items = validate_terminal_receipt_structure!(current, state, receipt)
819
- # Canonical byte ownership is also usable by the ordinary installed
820
- # service. It does not opt that service into protected authority.
821
- if @evidence_reader
822
- if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
823
- (current["operation"] == "publish" && %w[succeeded failed].include?(state))
824
- # The same installed evidence reader owns the entire imported
825
- # cleanup pair; individual textual markers cannot authorize it.
826
- contents = @evidence_reader.call(evidence_items, current, state, pending)
827
- unless contents.is_a?(Array) && contents.size == evidence_items.size &&
828
- contents.each_with_index.all? { |content, index| content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == evidence_items.fetch(index).fetch("sha256") }
829
- raise AttemptErrors::ReceiptRejected, "Canonical cleanup evidence is unverifiable"
830
- end
831
- return true
832
- end
833
- evidence_items.each do |item|
834
- content = @evidence_reader.call(item, current, state, pending)
835
- unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
836
- raise AttemptErrors::ReceiptRejected, "Canonical service evidence is unverifiable"
837
- end
838
- verify_service_attestation!(content, current, state)
839
- end
840
- return true
841
- end
842
- repo_root = File.realpath(@repo_root)
843
- receipt["evidence"].each do |item|
844
- path = File.expand_path(item["ref"], repo_root)
845
- real = begin
846
- File.realpath(path)
847
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
848
- raise AttemptErrors::ReceiptRejected,
849
- "Service terminal receipt evidence is unverifiable: #{item["ref"]}"
850
- end
851
- intact = begin
852
- real.start_with?(repo_root + File::SEPARATOR) &&
853
- Digest::SHA256.file(real).hexdigest == item["sha256"]
854
- rescue Errno::EACCES, Errno::ELOOP
855
- false
856
- end
857
- unless intact
858
- raise AttemptErrors::ReceiptRejected,
859
- "Service terminal receipt evidence is unverifiable: #{item["ref"]}"
860
- end
861
- verify_service_attestation!(File.read(real), current, state)
862
- end
863
- end
864
-
865
- def service_request(request_id, commit: ref_value)
866
- read_service_request(request_id, commit: commit, pending: {commit: commit})
867
- end
868
-
869
- # A read operation owns this view only until its caller returns. The
870
- # original complete first-parent/raw-blob owner supplies every fact;
871
- # it is never retained on the journal or reused for another operation.
872
- ServiceReadView = Struct.new(:journal, :commit, :inventory, :projection, keyword_init: true)
873
-
874
- def service_settlement_read(request_id, commit: ref_value)
875
- verify_canonical_prefix!(commit: commit)
876
- record = read_service_request(request_id, commit: commit, pending: {commit: commit}, terminal: false)
877
- return {record: nil, settlement_context: nil, read_view: nil}.freeze unless record
878
- id = record.fetch("assignment_id")
879
- events = read_events(id, commit: commit)
880
- selectors = events.map { |event| event.fetch("digest") }
881
- introductions = event_commits!(assignment_id: id, event_digests: selectors, commit: commit)
882
- inventory = freeze_inventory_projection("commit" => commit, "events" => {id => events}, "introductions" => {id => introductions})
883
- view = ServiceReadView.new(journal: self, commit: commit, inventory: inventory)
884
- if @mode == :protected && terminal_state?(record["state"])
885
- validate_terminal_receipt!(record, record["state"], record["receipt"],
886
- pending: {commit: commit, service_read_view: view})
887
- end
888
- view.freeze
889
- {record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
890
- end
891
-
892
- def read_service_request(request_id, commit:, pending:, terminal: true)
893
- context = Thread.current[:ace_assign_event_read_operation]
894
- validate_request_id!(request_id)
895
- reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
896
- selection = if reusable
897
- (operation_inventory_selection(commit) + [@evidence_reader&.object_id, @service_authorizer&.object_id, request_id])
898
- .map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
899
- end
900
- if reusable && context[:service_record_selection] == selection
901
- retained = context[:service_record]
902
- else
903
- context&.delete(:service_record_selection)
904
- context&.delete(:service_record)
905
- retained = read_service_request_record(request_id, commit: commit)
906
- if reusable && retained
907
- retained = freeze_inventory_projection(retained)
908
- context[:service_record_selection], context[:service_record] = selection, retained
909
- end
910
- end
911
- # This memo contains JSON and its immutable accepted event proof,
912
- # never terminal evidence admission. Every callback remains fresh;
913
- # callers receive their own mutable copy rather than cached state.
914
- record = retained && (reusable ? JSON.parse(JSON.generate(retained)) : retained)
915
- validate_terminal_receipt!(record, record["state"], record["receipt"], pending: pending) if
916
- record && terminal && @mode == :protected && terminal_state?(record["state"])
917
- # Historical dependency reads may replace this one slot while the
918
- # fresh callback executes. Restore only this successful raw view.
919
- if reusable && retained
920
- context[:service_record_selection], context[:service_record] = selection, retained
921
- end
922
- record
923
- rescue Exception
924
- context&.delete(:service_record_selection)
925
- context&.delete(:service_record)
926
- raise
927
- end
928
-
929
- def read_service_request_record(request_id, commit:)
930
- return nil unless commit
931
- out, stderr, status = git("show", "#{commit}:#{service_request_path(request_id)}")
932
- if status.success?
933
- record = JSON.parse(out)
934
- verify_service_record!(record, commit: commit) if @mode == :protected
935
- return record
936
- end
937
- return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
938
- raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
939
- rescue JSON::ParserError
940
- raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
941
- end
942
-
943
- private :read_service_request, :read_service_request_record
944
-
945
- # Every service request recorded in this evidence ref, whatever the
946
- # owning assignment. Backs the journal-wide request-ID and
947
- # authorization-consumption checks.
948
- # Another live request holding the same exact authorization for the
949
- # same operation, project and target. Only requests that were born
950
- # rejected (never claimed, never dispatched) leave the decision
951
- # unconsumed: a withdrawn or failed claim keeps it consumed until
952
- # attributable evidence proves the effect did not occur.
953
- def authorization_conflict(binding)
954
- authorization = binding["authorization"]
955
- return nil unless authorization.is_a?(String) && !authorization.empty?
956
- if authorization.start_with?("proposal-")
957
- return proposal_claim_conflict(authorization, request_id: binding.fetch("request_id"), fixed: false)
958
- end
959
- service_request_records.find do |other|
960
- next false if other["request_id"] == binding.fetch("request_id")
961
- next false if other["state"] == "rejected" && other["consumed"] == false
962
- # A settled failure frees the exact authorization only while its
963
- # no-effect evidence remains verifiable in the repository.
964
- next false if other["state"] == "failed-settled" && settlement_evidence_intact?(other)
965
- other["authorization"] == authorization &&
966
- other["operation"] == binding.fetch("operation") &&
967
- other["project_id"] == binding.fetch("project_id") &&
968
- other["target"] == binding.fetch("target")
969
- end
970
- end
971
-
972
- def settlement_evidence_intact?(record)
973
- receipt = record["receipt"]
974
- return false unless receipt.is_a?(Hash)
975
- if @mode == :protected
976
- commit = ref_value
977
- verify_service_record!(record, commit: commit)
978
- validate_terminal_receipt!(record, "failed-settled", receipt, pending: {commit: commit})
979
- return true
980
- end
981
- repo_root = File.realpath(@repo_root)
982
- Array(receipt["evidence"]).all? do |item|
983
- path = File.expand_path(item["ref"].to_s, repo_root)
984
- real = begin
985
- File.realpath(path)
986
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
987
- next false
988
- end
989
- begin
990
- real.start_with?(repo_root + File::SEPARATOR) &&
991
- Digest::SHA256.file(real).hexdigest == item["sha256"]
992
- rescue Errno::EACCES, Errno::ELOOP
993
- false
994
- end
995
- end
996
- rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::ReceiptRejected
997
- false
998
- end
999
-
1000
- def service_request_records(commit: ref_value)
1001
- value = commit
1002
- return [] unless value
1003
- paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
1004
- raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
1005
- paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
1006
- content, error, read_status = git("show", "#{value}:#{path}")
1007
- unless read_status.success?
1008
- raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
1009
- end
1010
- record = JSON.parse(content)
1011
- verify_service_record!(record, commit: value) if @mode == :protected
1012
- record
1013
- end
1014
- rescue JSON::ParserError
1015
- raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
1016
- end
1017
-
1018
- def service_requests(assignment_id)
1019
- request_ids = read_events(assignment_id)
1020
- .select { |event| event["type"] == "service_claim" }
1021
- .map { |event| event.dig("payload", "request_id") }.compact.uniq
1022
- request_ids.filter_map { |request_id| service_request(request_id) }
1023
- end
1024
-
1025
- # All attempts derivable from the journal, reconstructed from intent
1026
- # and process_start facts with journal-authoritative state. The
1027
- # journal is the owner of attempt-state interpretation.
1028
- #
1029
- # @param assignment_id [String] Assignment ID
1030
- # @return [Array<Models::Attempt>] Derived attempts
1031
- def derived_attempts(assignment_id, commit: ref_value)
1032
- events = read_events(assignment_id, commit: commit)
1033
- by_attempt = events.group_by { |event| event["attempt_id"] }
1034
- by_attempt.delete(nil)
1035
-
1036
- by_attempt.filter_map do |attempt_id, attempt_events|
1037
- intent = attempt_events.find { |event| event["type"] == "intent" }
1038
- next unless intent
1039
-
1040
- state = derive_state(attempt_events)
1041
- next if state.nil?
1042
-
1043
- build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
1044
- end
1045
- end
1046
-
1047
- # Non-terminal attempts derived from the journal.
1048
- #
1049
- # @param assignment_id [String] Assignment ID
1050
- # @return [Array<Models::Attempt>] Active attempts
1051
- def active_attempts(assignment_id)
1052
- derived_attempts(assignment_id).reject(&:terminal?)
1053
- end
1054
-
1055
- # Assignment IDs present in the journal, discovered from the evidence
1056
- # ref itself (works even when the local assignment cache is gone).
1057
- #
1058
- # @return [Array<String>] Assignment IDs with journal evidence
1059
- def assignment_ids(commit: ref_value)
1060
- value = commit
1061
- return [] if value.nil?
1062
-
1063
- paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
1064
- raise AttemptErrors::EvidenceUnavailable, "Cannot discover journal assignments: #{stderr}" unless status.success?
1065
- paths.lines.map { |path| path.strip.delete_prefix("execution/") }
1066
- .reject { |id| id == "requests" }.sort
1067
- end
1068
-
1069
- private
1070
-
1071
- # Sizes come from the same immutable tree selection. Batch memory is
1072
- # bounded by that selection; an existing larger single event retains
1073
- # its previous read semantics rather than gaining a lifetime ceiling.
1074
- def read_event_blobs!(entries)
1075
- limit = entries.sum { |_oid, size| size + 128 }
1076
- return @read_boundary.blob_batch(entries.map(&:first), output_limit: limit) if @read_boundary
1077
- result = Herdr::Molecules::BoundedProcess.call(["git", "cat-file", "--batch"],
1078
- chdir: File.expand_path(@repo_root), stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join,
1079
- timeout_s: 30, output_limit: limit)
1080
- unless result.status.success? && !result.oversized
1081
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable or exceeds selected sizes"
1082
- end
1083
- result.stdout.to_s.b
1084
- rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError => error
1085
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable: #{error.message}"
1086
- end
1087
-
1088
- def decode_event_blobs!(entries, output)
1089
- output = output.b
1090
- offset = 0
1091
- entries.map do |oid, size|
1092
- ending = output.index("\n", offset)
1093
- unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
1094
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
1095
- end
1096
- offset = ending + 1
1097
- content = output.byteslice(offset, size)
1098
- unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
1099
- Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
1100
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
1101
- end
1102
- offset += size + 1
1103
- content
1104
- end.tap do
1105
- unless offset == output.bytesize
1106
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
1107
- end
1108
- end
1109
- end
1110
-
1111
- def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
1112
- validate_request_id!(request_id)
1113
- with_lock do
1114
- CAS_ATTEMPTS.times do
1115
- old = ref_value
1116
- ensure_checkout!
1117
- old = ref_value if old.nil?
1118
- sync_checkout(old)
1119
- prepared = prepare_service_update(request_id: request_id, expected: expected,
1120
- replacement: replacement, event_type: event_type, guard: guard)
1121
- return prepared.fetch(:record) if prepared[:replayed]
1122
- write_service_records([prepared])
1123
- assignment_id = replacement.fetch("assignment_id")
1124
- attempt_id = replacement.fetch("attempt_id")
1125
- prior = read_events(assignment_id).reverse
1126
- .find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
1127
- event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
1128
- payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
1129
- write_event_files(assignment_id, [event])
1130
- git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
1131
- "execution/#{assignment_id}/events")
1132
- stage_service_records([prepared])
1133
- state = replacement.fetch("state")
1134
- git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1135
- "commit", "-m", "evidence: service request #{request_id} #{state}")
1136
- commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
1137
- return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
1138
- end
1139
- raise AttemptErrors::EvidenceUnavailable, "Service request ref stayed conflicting"
1140
- end
1141
- end
1142
-
1143
- # Shared owner for ordinary service writes and atomic authority
1144
- # imports. Call only after checkout sync, under this journal's CAS lock.
1145
- def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
1146
- validate_request_id!(request_id)
1147
- unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
1148
- %w[service_claim service_transition service_challenge].include?(event_type) &&
1149
- %w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
1150
- raise ArgumentError, "invalid service update plan"
1151
- end
1152
- existing = service_request(request_id)
1153
- if expected.nil? && existing
1154
- return {record: existing, replayed: true} if existing.except("state", "receipt", "reason", "claimed_at") ==
1155
- replacement.except("state", "receipt", "reason", "claimed_at")
1156
- raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
1157
- end
1158
- if expected && existing != expected
1159
- raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
1160
- end
1161
- if expected.nil?
1162
- unless event_type == "service_claim" && %w[accepted uncertain rejected].include?(replacement["state"])
1163
- raise AttemptErrors::InvalidState, "Service request needs an initial claim"
1164
- end
1165
- if %w[accepted uncertain].include?(replacement["state"])
1166
- if replacement["authorization"].to_s.start_with?("proposal-")
1167
- unless respond_to?(:proposal_authorize!, true)
1168
- raise AttemptErrors::UnauthorizedIdentity, "Canonical proposal producer is unavailable"
1169
- end
1170
- proposal_authorize!(replacement["authorization"], replacement)
1171
- end
1172
- conflict = authorization_conflict(replacement)
1173
- raise_authorization_conflict!(conflict) if conflict
1174
- end
1175
- else
1176
- challenge_update = event_type == "service_challenge" && pending && pending[:operation] == "claim_service_settlement" &&
1177
- existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
1178
- %w[uncertain failed].include?(existing["state"])
1179
- unless event_type == "service_transition" || challenge_update
1180
- raise AttemptErrors::InvalidState, "Existing service request needs a transition"
1181
- end
1182
- terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
1183
- settlement = replacement["state"] == "failed-settled" &&
1184
- (existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
1185
- recovered_cleanup = @mode == :protected && pending && pending[:operation] == "complete_service" &&
1186
- existing["operation"] == "prune-preserved-workspace" && existing["state"] == "failed" &&
1187
- existing["dispatch_phase"] == "dispatch_started" && existing["completion_digest"].nil? &&
1188
- replacement["state"] == "succeeded"
1189
- if terminal && !settlement && !challenge_update && !recovered_cleanup
1190
- raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
1191
- end
1192
- mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
1193
- challenge_generation challenge_event_digest completion_digest no_effect_completion_digest]
1194
- # Only this authenticated fresh dispatch may introduce the fixed
1195
- # root collaborator identity. It is immutable on every later update.
1196
- if @mode == :protected && pending && pending[:operation] == "begin_dispatch" &&
1197
- existing["operation"] == "prune-preserved-workspace" && existing["dispatch_phase"] == "issued" &&
1198
- replacement["dispatch_phase"] == "dispatch_started" &&
1199
- %w[operation_owner_binding executor_process_binding].none? { |key| existing.key?(key) } &&
1200
- %w[operation_owner_binding executor_process_binding].all? { |key| replacement.key?(key) }
1201
- mutable.concat(%w[operation_owner_binding executor_process_binding])
1202
- end
1203
- if @mode == :protected && pending && existing["operation"] == "publish"
1204
- if pending[:operation] == "begin_dispatch" && existing["dispatch_phase"] == "issued" &&
1205
- replacement["dispatch_phase"] == "dispatch_started" && !existing.key?("executor_process_binding") &&
1206
- replacement.key?("executor_process_binding")
1207
- mutable << "executor_process_binding"
1208
- elsif %w[publication_challenge publication_continue].include?(pending[:operation])
1209
- mutable.concat(%w[publication_challenge_digest publication_challenge_ref publication_challenge_event_digest
1210
- publication_challenge_generation publication_issuing_event_digest])
1211
- end
1212
- end
1213
- unless existing.except(*mutable) == replacement.except(*mutable)
1214
- raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
1215
- end
1216
- end
1217
- conflict = guard&.call
1218
- raise_authorization_conflict!(conflict) if conflict
1219
- @service_authorizer.call(existing, replacement, pending) if @mode == :protected
1220
- validate_terminal_receipt!(replacement, replacement["state"], replacement["receipt"], pending: pending) if terminal_state?(replacement["state"])
1221
- payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
1222
- "input_digest" => replacement.fetch("input_digest"),
1223
- "record_digest" => Atoms::EvidenceDigest.digest(replacement),
1224
- "receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
1225
- {record: replacement, path: service_request_path(request_id),
1226
- event: {type: event_type, payload: payload}, replayed: false}
1227
- end
1228
-
1229
- def prepare_service_updates(updates, assignment_id:, attempt_id:, pending:)
1230
- unless updates.is_a?(Array) && updates.length <= 1
1231
- raise ArgumentError, "one service update is allowed per authority mutation"
1232
- end
1233
- updates.map do |update|
1234
- unless update.is_a?(Hash) && update.keys.sort == %i[event_type expected replacement request_id].sort &&
1235
- update.dig(:replacement, "assignment_id") == assignment_id && update.dig(:replacement, "attempt_id") == attempt_id
1236
- raise ArgumentError, "service update does not match mutation attempt"
1237
- end
1238
- prepare_service_update(**update, pending: pending)
1239
- end.reject { |prepared| prepared[:replayed] }
1240
- end
1241
-
1242
- def write_service_records(records)
1243
- records.each do |prepared|
1244
- path = File.join(checkout_dir, prepared.fetch(:path))
1245
- FileUtils.mkdir_p(File.dirname(path))
1246
- File.write(path, JSON.pretty_generate(prepared.fetch(:record)))
1247
- end
1248
- end
1249
-
1250
- def stage_service_records(records)
1251
- stage_mutation_blobs(records.to_h { |prepared| [prepared.fetch(:path), JSON.pretty_generate(prepared.fetch(:record))] })
1252
- end
1253
-
1254
- def verify_service_record!(record, commit: ref_value)
1255
- events = read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
1256
- unless Models::EvidenceEvent.chain_valid?(events)
1257
- raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
1258
- end
1259
- event = events.reverse.find do |entry|
1260
- %w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
1261
- end
1262
- unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
1263
- raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
1264
- end
1265
- rescue KeyError, TypeError
1266
- raise AttemptErrors::EvidenceUnavailable, "Canonical service record has invalid binding"
1267
- end
1268
-
1269
- def raise_authorization_conflict!(conflict)
1270
- raise AttemptErrors::Conflict,
1271
- "Authorization reference already consumed by request #{conflict.fetch("request_id")}"
1272
- end
1273
-
1274
- def verify_service_attestation!(content, request, state)
1275
- expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
1276
- attested = content.scan(expression).first
1277
- outcome = (state == "failed-settled") ? "failed" : state
1278
- unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
1279
- raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
1280
- end
1281
- end
1282
-
1283
- def service_request_path(request_id)
1284
- "execution/requests/#{request_id}.json"
1285
- end
1286
-
1287
- def validate_request_id!(request_id)
1288
- return if request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
1289
- raise ArgumentError, "invalid service request ID"
1290
- end
1291
-
1292
- # ---- ordinary fixed merge records (spec 8x3.t.xz9.4) --------------
1293
- #
1294
- # The canonical Assign attempt/evidence state is the SOLE queue/effect
1295
- # record for ordinary fixed merge requests: admitted records live in
1296
- # this evidence ref, chained to their owning attempt, and the issuing
1297
- # receiver keeps no journal, mapping database or receipt ledger. The
1298
- # closed state model is pending -> uncertain -> succeeded/failed;
1299
- # pre-effect refusals are never durable (a refusal has no reference),
1300
- # so every recorded request is an admitted one.
1301
-
1302
- FIXED_MERGE_STATES = %w[pending uncertain succeeded failed].freeze
1303
- # Immutable binding: a replay under the same request ID must match
1304
- # every caller-supplied field, or it refuses before effect. The
1305
- # opaque reference is receiver-issued, never caller-supplied, and
1306
- # replays return the originally issued one.
1307
- FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
1308
- queue_entry operation authorization input_digest request principal caller_uid receiver_id
1309
- configuration_sha256 execution_identity].freeze
1310
- FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
1311
-
1312
- def fixed_merge_request(request_id, commit: ref_value)
1313
- return nil unless commit
1314
- out, stderr, status = git("show", "#{commit}:#{fixed_merge_request_path(request_id)}")
1315
- if status.success?
1316
- record = JSON.parse(out)
1317
- verify_fixed_record!(record, commit: commit) if @mode == :protected
1318
- return record
1319
- end
1320
- return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
1321
- raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request: #{stderr}"
1322
- rescue JSON::ParserError
1323
- raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request #{request_id}"
1324
- end
1325
-
1326
- def fixed_merge_request_records(commit: ref_value)
1327
- value = commit
1328
- return [] unless value
1329
- paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/merge-requests/")
1330
- raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge index: #{stderr}" unless status.success?
1331
- paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
1332
- content, error, read_status = git("show", "#{value}:#{path}")
1333
- unless read_status.success?
1334
- raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request #{path}: #{error}"
1335
- end
1336
- record = JSON.parse(content)
1337
- verify_fixed_record!(record, commit: value) if @mode == :protected
1338
- record
1339
- end
1340
- rescue JSON::ParserError
1341
- raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request index"
1342
- end
1343
-
1344
- # Admit an ordinary fixed merge request: one CAS create that binds the
1345
- # receiver-issued opaque reference before the record can ever be read
1346
- # back. An identical binding under the same request ID replays the
1347
- # original record (the reference is created once); any different
1348
- # field is a conflict, never a replay. Another live record holding
1349
- # the same exact authorization is refused: only a terminal record
1350
- # with proven no-effect evidence frees an authorization.
1351
- def admit_fixed_merge_request(record, guard: nil)
1352
- reference = record.fetch("reference")
1353
- raise ArgumentError, "fixed merge record needs an opaque reference" unless Molecules::FixedEffectReference.valid?(reference)
1354
- raise ArgumentError, "invalid fixed merge state" unless record.fetch("state") == "pending"
1355
- update_fixed_merge_request(record.fetch("request_id"), expected: nil,
1356
- replacement: record, event_type: "merge_admission", guard: guard)
1357
- end
1358
-
1359
- # Claim the start of the fixed effect: pending -> uncertain in one
1360
- # CAS transition, so a receiver restart can never dispatch a second
1361
- # merge for the same admitted record. An already-started claim refuses;
1362
- # only the winning effect may later settle it.
1363
- def begin_fixed_merge_request(request_id)
1364
- current = fixed_merge_request(request_id)
1365
- raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
1366
- if current["state"] == "uncertain"
1367
- raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
1368
- end
1369
- unless current["state"] == "pending"
1370
- raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1371
- end
1372
- update_fixed_merge_request(request_id, expected: current,
1373
- replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
1374
- event_type: "merge_transition", guard: -> { verify_fixed_proposal!(current) })
1375
- end
1376
-
1377
- # Terminal settlement: succeeded/failed with the exact receipt
1378
- # validated HERE at the journal boundary — structure, the closed
1379
- # terminal invariants, and the binding against the current record.
1380
- # Failure is terminal only with immutable no-effect evidence.
1381
- def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
1382
- unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
1383
- raise ArgumentError, "invalid fixed merge terminal status"
1384
- end
1385
- current = fixed_merge_request(request_id)
1386
- raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
1387
- unless %w[pending uncertain].include?(current["state"])
1388
- raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1389
- end
1390
- evidence_files = {}
1391
- if terminal_status == "failed"
1392
- unless attestation.is_a?(Hash) &&
1393
- attestation.values_at("request_id", "input_digest", "reference", "outcome") ==
1394
- current.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
1395
- attestation["candidate_head"] == current.dig("request", "candidate_head") &&
1396
- attestation["expected_base_head"] == current.dig("request", "expected_base_head")
1397
- raise AttemptErrors::ReceiptRejected, "Fixed merge no_effect_evidence requires a bound canonical attestation"
1398
- end
1399
- content = JSON.generate(attestation)
1400
- raise AttemptErrors::ReceiptRejected, "Fixed merge attestation exceeds bound" if content.bytesize > 16_384
1401
- path = fixed_no_effect_path(request_id)
1402
- evidence_files[path] = content
1403
- receipt = receipt.merge("effect" => receipt.fetch("effect").merge(
1404
- "no_effect_evidence" => {"reference" => path, "sha256" => Digest::SHA256.hexdigest(content)}))
1405
- elsif attestation
1406
- raise AttemptErrors::ReceiptRejected, "Successful merge cannot carry no-effect attestation"
1407
- end
1408
- validate_fixed_receipt!(current, terminal_status, receipt)
1409
- update_fixed_merge_request(request_id, expected: current,
1410
- replacement: current.merge("state" => terminal_status, "receipt" => receipt,
1411
- "finished_at" => Time.now.utc.iso8601(9)),
1412
- event_type: "merge_transition", evidence_files: evidence_files)
1413
- end
1414
-
1415
- def fixed_merge_request_path(request_id)
1416
- "execution/merge-requests/#{request_id}.json"
1417
- end
1418
-
1419
- def fixed_no_effect_path(request_id)
1420
- validate_request_id!(request_id)
1421
- "execution/merge-evidence/#{request_id}.json"
1422
- end
1423
-
1424
- def validate_fixed_receipt!(current, terminal_status, receipt)
1425
- Atoms::FixedMergeSchemas.validate_receipt!(receipt)
1426
- Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
1427
- unless receipt.fetch("terminal_status") == terminal_status
1428
- raise AttemptErrors::ReceiptRejected, "Fixed merge receipt terminal status does not match the settlement"
1429
- end
1430
- true
1431
- rescue Atoms::FixedMergeSchemas::SchemaError => e
1432
- raise AttemptErrors::ReceiptRejected, "Fixed merge receipt is invalid: #{e.message}"
1433
- end
1434
-
1435
- # Another live fixed merge record holding the same exact
1436
- # authorization. Terminal failure with intact no-effect evidence
1437
- # proves the effect never happened and frees the reference.
1438
- def fixed_authorization_conflict(record)
1439
- authorization = record["authorization"]
1440
- return nil unless authorization.is_a?(String) && !authorization.empty?
1441
- fixed_merge_request_records.find do |other|
1442
- next false if other["request_id"] == record.fetch("request_id")
1443
- next false if other["state"] == "failed" && fixed_no_effect_intact?(other)
1444
- other["authorization"] == authorization &&
1445
- other["operation"] == record.fetch("operation") &&
1446
- other["project_id"] == record.fetch("project_id")
1447
- end
1448
- end
1449
-
1450
- def fixed_no_effect_intact?(record, commit: ref_value)
1451
- receipt = record["receipt"]
1452
- return false unless receipt.is_a?(Hash)
1453
- evidence = receipt.dig("effect", "no_effect_evidence")
1454
- return false unless evidence.is_a?(Hash)
1455
- path = fixed_no_effect_path(record.fetch("request_id"))
1456
- return false unless evidence["reference"] == path
1457
- content = bounded_blob(path, commit: commit, max_bytes: 16_384)
1458
- proof = JSON.parse(content)
1459
- Digest::SHA256.hexdigest(content) == evidence["sha256"] &&
1460
- proof.values_at("request_id", "input_digest", "reference", "outcome") == record.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
1461
- proof["candidate_head"] == record.dig("request", "candidate_head") && proof["expected_base_head"] == record.dig("request", "expected_base_head")
1462
- rescue AttemptErrors::EvidenceUnavailable, JSON::ParserError, NoMethodError
1463
- false
1464
- end
1465
-
1466
- # Immutable terminal consumption for ordinary completion. It uses the
1467
- # same receipt and no-effect owners as admission/authorization release.
1468
- def fixed_merge_settlement!(request_id, commit: ref_value)
1469
- record = fixed_merge_request(request_id, commit: commit)
1470
- raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge is missing" unless record
1471
- verify_fixed_record!(record, commit: commit)
1472
- unless %w[succeeded failed].include?(record["state"])
1473
- raise AttemptErrors::ServiceSettlementPending, "canonical fixed merge is unresolved"
1474
- end
1475
- validate_fixed_receipt!(record, record.fetch("state"), record.fetch("receipt"))
1476
- if record["state"] == "failed" && !fixed_no_effect_intact?(record, commit: commit)
1477
- raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge no-effect proof differs"
1478
- end
1479
- record
1480
- end
1481
-
1482
- def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil, evidence_files: {})
1483
- unless %w[merge_admission merge_transition].include?(event_type)
1484
- raise ArgumentError, "invalid fixed merge event type"
1485
- end
1486
- with_lock do
1487
- CAS_ATTEMPTS.times do
1488
- old = ref_value
1489
- ensure_checkout!
1490
- old = ref_value if old.nil?
1491
- sync_checkout(old)
1492
- prepared = prepare_fixed_update(request_id: request_id, expected: expected,
1493
- replacement: replacement, event_type: event_type, guard: guard)
1494
- return prepared.fetch(:record) if prepared[:replayed]
1495
- write_mutation_blobs(evidence_files, commit: old)
1496
- path = fixed_merge_request_path(request_id)
1497
- assignment_id = replacement.fetch("assignment_id")
1498
- attempt_id = replacement.fetch("attempt_id")
1499
- prior = read_events(assignment_id).reverse
1500
- .find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
1501
- event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
1502
- payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
1503
- write_event_files(assignment_id, [event])
1504
- git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
1505
- stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
1506
- state = replacement.fetch("state")
1507
- git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1508
- "commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
1509
- commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
1510
- return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
1511
- end
1512
- raise AttemptErrors::EvidenceUnavailable, "Fixed merge evidence ref stayed conflicting"
1513
- end
1514
- end
1515
- private :update_fixed_merge_request
1516
-
1517
- def prepare_fixed_update(request_id:, expected:, replacement:, event_type:, guard: nil)
1518
- validate_request_id!(request_id)
1519
- unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
1520
- FIXED_MERGE_STATES.include?(replacement["state"]) &&
1521
- (replacement.keys - (FIXED_MERGE_BINDING_FIELDS + FIXED_MERGE_MUTABLE_FIELDS + %w[journal_commit])).empty?
1522
- raise ArgumentError, "invalid fixed merge update plan"
1523
- end
1524
- existing = fixed_merge_request(request_id)
1525
- if expected.nil?
1526
- unless event_type == "merge_admission" && replacement["state"] == "pending"
1527
- raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
1528
- end
1529
- if existing
1530
- return {record: existing, replayed: true} if fixed_replay?(existing, replacement)
1531
- raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
1532
- end
1533
- unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
1534
- raise ArgumentError, "fixed merge record needs an opaque reference"
1535
- end
1536
- if fixed_merge_request_records.any? { |other| other["reference"] == replacement.fetch("reference") }
1537
- raise AttemptErrors::Conflict, "Fixed merge reference is already bound"
1538
- end
1539
- else
1540
- unless event_type == "merge_transition"
1541
- raise AttemptErrors::InvalidState, "Admitted fixed merge request needs a transition"
1542
- end
1543
- unless existing == expected
1544
- raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed during transition"
1545
- end
1546
- unless existing.except(*FIXED_MERGE_MUTABLE_FIELDS) == replacement.except(*FIXED_MERGE_MUTABLE_FIELDS)
1547
- raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed immutable binding"
1548
- end
1549
- end
1550
- conflict = guard&.call
1551
- raise_authorization_conflict!(conflict) if conflict
1552
- if expected.nil? || replacement["state"] == "uncertain"
1553
- attempt = derived_attempts(replacement.fetch("assignment_id")).find { |item| item.attempt_id == replacement.fetch("attempt_id") }
1554
- unless attempt&.managed? && attempt.active? && attempt.binding.project_id == replacement.fetch("project_id")
1555
- raise AttemptErrors::ReceiptRejected, "Fixed merge admission requires its current active canonical attempt"
1556
- end
1557
- end
1558
- verify_fixed_proposal!(replacement) if event_type == "merge_admission"
1559
- payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
1560
- "input_digest" => replacement.fetch("input_digest"),
1561
- "record_digest" => Atoms::EvidenceDigest.digest(replacement),
1562
- "receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
1563
- {record: replacement, event: {type: event_type, payload: payload}, replayed: false}
1564
- end
1565
- private :prepare_fixed_update
1566
-
1567
- def verify_fixed_proposal!(record)
1568
- return unless record.fetch("authorization").start_with?("proposal-")
1569
- proposal_authorize!(record.fetch("authorization"), fixed_proposal_binding(record))
1570
- conflict = proposal_claim_conflict(record.fetch("authorization"),
1571
- request_id: record.fetch("request_id"), fixed: true)
1572
- raise AttemptErrors::AuthorizationConflict, "Proposal already holds an effect claim" if conflict
1573
- nil
1574
- end
1575
-
1576
- def fixed_proposal_binding(record)
1577
- record.slice("operation", "project_id", "assignment_id", "attempt_id", "input_digest", "caller_uid").merge(
1578
- "target" => {"resource" => record.fetch("request").fetch("pr_url"), "artifact_digest" => nil},
1579
- "candidate_head" => record.fetch("request").fetch("candidate_head"))
1580
- end
1581
-
1582
- def fixed_replay?(existing, replacement)
1583
- FIXED_MERGE_BINDING_FIELDS.all? do |field|
1584
- existing[field] == replacement.fetch(field)
1585
- end
1586
- end
1587
- private :fixed_replay?
1588
-
1589
- # Protected-mode parity with service records: the record must match
1590
- # the latest merge event digest in the owning attempt chain.
1591
- def verify_fixed_record!(record, commit: ref_value)
1592
- events = read_events(record.fetch("assignment_id"), commit: commit)
1593
- .select { |event| event["attempt_id"] == record.fetch("attempt_id") }
1594
- unless Models::EvidenceEvent.chain_valid?(events)
1595
- raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge event chain is unverifiable"
1596
- end
1597
- event = events.reverse.find do |entry|
1598
- %w[merge_admission merge_transition].include?(entry["type"]) &&
1599
- entry.dig("payload", "request_id") == record["request_id"]
1600
- end
1601
- unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
1602
- raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record does not match accepted event"
1603
- end
1604
- rescue KeyError, TypeError
1605
- raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record has invalid binding"
1606
- end
1607
-
1608
- # The module marks a private region above; the fixed merge primitive
1609
- # is a public coordinator-facing surface, so it is re-published here.
1610
- public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
1611
- :begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
1612
- :verify_service_attestation!
1613
-
1614
- # Latest lifecycle state implied by the events, or nil when the
1615
- # events do not describe a full attempt (intent missing).
1616
- def derive_state(events)
1617
- CanonicalAttemptState.derive(events)
1618
- end
1619
-
1620
- def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
1621
- process_start = events.reverse.find do |event|
1622
- event["type"] == "process_start" && event["attempt_id"] == attempt_id
1623
- end
1624
- candidate_head = nil
1625
- events.each do |event|
1626
- case event["type"]
1627
- when "candidate_invalidated" then candidate_head = nil
1628
- when "receipt_accepted" then candidate_head ||= event.dig("payload", "receipt", "head")
1629
- when "delivery"
1630
- candidate_head = event.dig("payload", "head") if %w[intent result].include?(event.dig("payload", "stage"))
1631
- end
1632
- end
1633
- binding = Models::AttemptBinding.new(
1634
- attempt_id: attempt_id,
1635
- assignment_id: assignment_id,
1636
- scope: intent_payload["scope"],
1637
- project_id: intent_payload["project_id"],
1638
- task_id: intent_payload["task_id"],
1639
- actor: process_start&.dig("payload", "actor") || intent_payload["actor"] || "recovered",
1640
- role: process_start&.dig("payload", "role") || intent_payload["role"] || "coordinator",
1641
- runtime: process_start&.dig("payload", "runtime") || intent_payload["runtime"] || "recovered",
1642
- base_head: intent_payload["base_head"],
1643
- evidence_git_ref: ref,
1644
- created_at: parse_event_time(intent_time(events, attempt_id))
1645
- )
1646
- Models::Attempt.new(
1647
- binding: binding,
1648
- state: state,
1649
- candidate_head: candidate_head,
1650
- journal_commit: commit
1651
- )
1652
- end
1653
-
1654
- def intent_time(events, attempt_id)
1655
- intent = events.find { |event| event["type"] == "intent" && event["attempt_id"] == attempt_id }
1656
- intent&.dig("recorded_at")
1657
- end
1658
-
1659
- def parse_event_time(value)
1660
- return Time.now.utc if value.nil?
1661
-
1662
- require "time"
1663
- Time.parse(value)
1664
- rescue ArgumentError
1665
- Time.now.utc
1666
- end
1667
-
1668
- private
1669
-
1670
- def event_files(assignment_id)
1671
- Dir.glob(File.join(checkout_dir, "execution", assignment_id, "events", "*.json")).sort
1672
- end
1673
-
1674
- # Order events by following previous_digest links from chain roots;
1675
- # orphaned events (unknown predecessor) keep filename order after the
1676
- # resolved chains.
1677
- def order_by_chain(events)
1678
- by_digest = {}
1679
- events.each { |event| by_digest[event["digest"]] = event }
1680
-
1681
- next_of = {}
1682
- events.each do |event|
1683
- previous = event["previous_digest"]
1684
- next_of[previous] = event if previous && by_digest.key?(previous)
1685
- end
1686
-
1687
- roots = events.reject do |event|
1688
- previous = event["previous_digest"]
1689
- previous && by_digest.key?(previous)
1690
- end
1691
-
1692
- ordered = []
1693
- visited = {}
1694
- roots.each do |root|
1695
- cursor = root
1696
- while cursor && !visited[cursor["digest"]]
1697
- visited[cursor["digest"]] = true
1698
- ordered << cursor
1699
- cursor = next_of[cursor["digest"]]
1700
- end
1701
- end
1702
-
1703
- ordered + events.reject { |event| visited[event["digest"]] }
1704
- end
1705
-
1706
- def event_filename(event)
1707
- "#{event["recorded_at"].to_s.tr("-:T Z", "")}-#{event["type"]}-#{event["digest"][0, 12]}.json"
1708
- end
1709
-
1710
- def write_event_files(assignment_id, events)
1711
- written = 0
1712
- events.each do |event|
1713
- path = File.join(checkout_dir, "execution", assignment_id, "events", event_filename(event))
1714
- next if File.exist?(path)
1715
-
1716
- FileUtils.mkdir_p(File.dirname(path))
1717
- File.write(path, JSON.pretty_generate(event))
1718
- written += 1
1719
- end
1720
- written
1721
- end
1722
-
1723
- def commit_events(assignment_id, attempt_id, events)
1724
- types = events.map { |event| event["type"] }.uniq.join(",")
1725
- git!("-C", checkout_dir, "add", "-A", "execution/#{assignment_id}")
1726
- git!(
1727
- "-C", checkout_dir,
1728
- "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1729
- "commit", "-m", "evidence: #{assignment_id} #{attempt_id} (#{types})"
1730
- )
1731
- git!("-C", checkout_dir, "rev-parse", "HEAD").first
1732
- end
1733
-
1734
- # Expected-old-value compare-and-swap; returns false on conflict.
1735
- # A nil expectation is the create case: the ref must not exist.
1736
- def update_ref_cas(new_commit, expected_old)
1737
- return true if !expected_old.nil? && new_commit == expected_old
1738
-
1739
- expected = expected_old || ("0" * 40)
1740
- _out, stderr, status = git("update-ref", @ref, new_commit, expected)
1741
- return true if status.success?
1742
-
1743
- raise AttemptErrors::EvidenceUnavailable, "Cannot update evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
1744
-
1745
- false
1746
- end
1747
-
1748
- def checkout_dir
1749
- File.join(@checkout_root, "journal")
1750
- end
1751
-
1752
- def lock_path
1753
- File.join(@checkout_root, ".evidence.lock")
1754
- end
1755
-
1756
- def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
1757
- raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
1758
- FileUtils.mkdir_p(@checkout_root)
1759
- lock = if owner_uid
1760
- initialization_lock!(owner_uid: owner_uid, owner_gid: owner_gid)
1761
- else
1762
- File.open(lock_path, File::RDWR | File::CREAT, 0o600)
1763
- end
1764
- begin
1765
- if deadline
1766
- unless deadline.is_a?(Numeric) && deadline.finite?
1767
- raise ArgumentError, "canonical lock deadline must be finite"
1768
- end
1769
- loop do
1770
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
1771
- raise AttemptErrors::Conflict, "canonical journal lock deadline elapsed" unless remaining.positive?
1772
- break if lock.flock(File::LOCK_EX | File::LOCK_NB)
1773
- sleep [remaining, 0.01].min
1774
- end
1775
- else
1776
- lock.flock(File::LOCK_EX)
1777
- end
1778
- begin
1779
- yield
1780
- rescue
1781
- # Ordinary mutation rollback is unchanged. Initialization must
1782
- # not adopt/reset a foreign disposable checkout on refusal.
1783
- sync_checkout(ref_value) if cleanup_on_failure && File.exist?(File.join(checkout_dir, ".git"))
1784
- raise
1785
- ensure
1786
- lock.flock(File::LOCK_UN)
1787
- end
1788
- ensure
1789
- lock.close
1790
- end
1791
- end
1792
-
1793
- def initialization_lock!(owner_uid:, owner_gid:)
1794
- created = false
1795
- begin
1796
- file = File.open(lock_path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600)
1797
- created = true
1798
- rescue Errno::EEXIST
1799
- file = File.open(lock_path, File::RDWR | File::NOFOLLOW)
1800
- end
1801
- if created
1802
- file.flush
1803
- file.fsync
1804
- end
1805
- info = file.stat
1806
- unless info.file? && info.nlink == 1 && info.size.zero? &&
1807
- [info.uid, info.gid, info.mode & 0o7777] == [owner_uid, owner_gid, 0o600]
1808
- raise AttemptErrors::EvidenceUnavailable, "canonical initialization lock protection differs"
1809
- end
1810
- file
1811
- rescue Exception
1812
- file&.close
1813
- raise
1814
- end
1815
-
1816
- def ensure_checkout!
1817
- return if detached_worktree_at?(checkout_dir)
1818
-
1819
- FileUtils.rm_rf(checkout_dir)
1820
- git!("worktree", "prune")
1821
- value = ref_value
1822
- if value.nil?
1823
- seed_ref
1824
- value = ref_value
1825
- end
1826
- git!("worktree", "add", "--detach", checkout_dir, value)
1827
- end
1828
-
1829
- # Seed the evidence ref with an empty-tree commit so a worktree can
1830
- # attach before any real evidence exists. The create is a
1831
- # compare-and-swap against the zero SHA: a writer that loses the
1832
- # race keeps the winner's ref instead of resetting it.
1833
- def seed_ref
1834
- empty_tree = git!("mktree").first
1835
- seed = git!("-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1836
- "commit-tree", empty_tree, "-m", "seed: ace-assign execution evidence").first
1837
- _out, stderr, status = git("update-ref", @ref, seed, "0" * 40)
1838
- return if status.success?
1839
-
1840
- raise AttemptErrors::EvidenceUnavailable, "Cannot seed evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
1841
- end
1842
-
1843
- def detached_worktree_at?(path)
1844
- dot_git = File.join(path, ".git")
1845
- return false unless File.exist?(dot_git)
1846
-
1847
- out, _s = git("-C", path, "rev-parse", "--abbrev-ref", "HEAD")
1848
- out.to_s.strip == "HEAD"
1849
- end
1850
-
1851
- # Sync the disposable checkout to an authoritative ref value.
1852
- def sync_checkout(target)
1853
- return if target.nil?
1854
-
1855
- git!("-C", checkout_dir, "reset", "--hard", target)
1856
- # All writers use this boundary, including CAS retries. Only the
1857
- # journal's disposable namespaces are cleaned, never the candidate.
1858
- git!("-C", checkout_dir, "clean", "-fd", "--", "execution", "evidence")
1859
- rescue AttemptErrors::EvidenceUnavailable
1860
- # Stale, broken, or unregistered worktree: rebuild from scratch.
1861
- FileUtils.rm_rf(checkout_dir)
1862
- git!("worktree", "prune")
1863
- git!("worktree", "add", "--detach", checkout_dir, target)
1864
- end
1865
-
1866
- def git(*argv)
1867
- return @read_boundary.call(argv) if @read_boundary
1868
- unless File.directory?(@repo_root)
1869
- return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
1870
- end
1871
-
1872
- out, stderr, status = Open3.capture3("git", *argv, chdir: @repo_root, stdin_data: "")
1873
- [out.to_s.strip, stderr.to_s.strip, status]
1874
- end
1875
-
1876
- def git!(*argv)
1877
- out, stderr, status = git(*argv)
1878
- unless status.success?
1879
- raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(" ")} failed: #{stderr}"
1880
- end
1881
-
1882
- [out, stderr, status]
1883
- end
1884
-
1885
- def git_ok?(*argv)
1886
- git(*argv)[2].success?
1887
- end
1888
-
1889
- # Distinguish "ref missing" (normal, quiet, no stderr) and expected
1890
- # compare-and-swap conflicts (retryable) from a broken repository or
1891
- # ref store.
1892
- def git_broken?(stderr)
1893
- message = stderr.to_s.strip
1894
- return false if message.empty?
1895
- return false if message.include?("unknown revision") || message.include?("not a valid ref")
1896
- return false if message.include?("cannot lock ref") || message.include?("but expected")
1897
-
1898
- true
1899
- end
1900
-
1901
- # Stand-in status for commands that cannot run at all.
1902
- FailedResult = Struct.new(:success?)
1903
- FAILED_RESULT = FailedResult.new(false).freeze
1904
-
1905
- def default_config(key)
1906
- section = Ace::Assign.config["attempt"]
1907
- section && section[key]
1908
- end
1909
- end
1910
- end
1911
- end
1912
- end