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