ace-assign 0.63.0 → 0.64.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/evidence_event"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Canonical lineage only. Live owner/boundary observation and lifecycle
10
+ # exclusion are mandatory before recording or consuming a fresh proof.
11
+ # This reader never promotes cached population into a proof event.
12
+ class ExecutionScopeLineage
13
+ BINDING_FIELDS = %w[project_id assignment_id attempt_id mapping_id slot_id reservation_generation
14
+ scope_generation deployment_digest boot_id slice_invocation_id cgroup_identity resource_mount_namespace_identity resource_identities network_installation_selection network_namespace_identity].freeze
15
+ PROOF_FIELDS = %w[scope_generation scope_binding_event_id seal_event_id boot_id slice_invocation_id
16
+ cgroup_identity populated].freeze
17
+ PROCESS_FIELDS = %w[pid uid gid groups started_at host parent_pid].freeze
18
+ RESOURCE_FIELDS = %w[host_path view_path mount_id filesystem_type device inode uid gid].freeze
19
+ CGROUP_FIELDS = %w[path mount_id filesystem_type device inode].freeze
20
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
21
+ DIGEST = /\A[0-9a-f]{64}\z/
22
+ INVOCATION = /\A[0-9a-f]{32}\z/
23
+ BOOT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
24
+
25
+ def self.validate_network_selection!(selection)
26
+ unless selection.is_a?(Hash) && selection.keys.sort == %w[installer_artifact policy_export profile report]
27
+ raise AttemptErrors::EvidenceUnavailable, "network installation selection is not closed"
28
+ end
29
+ selection.each_value do |ref|
30
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && ref["path"].is_a?(String) &&
31
+ ref["path"].bytesize.between?(1, 4096) && ref["path"].start_with?("/") && !ref["path"].include?("\0") &&
32
+ File.expand_path(ref["path"]) == ref["path"] && ref["sha256"].is_a?(String) && DIGEST.match?(ref["sha256"]) &&
33
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 1_048_576)
34
+ raise AttemptErrors::EvidenceUnavailable, "network installation artifact reference differs"
35
+ end
36
+ end
37
+ selection
38
+ end
39
+
40
+ attr_reader :binding_event, :native_event, :child_event, :seal_event, :proof_event, :admission_event
41
+
42
+ def initialize(events:, project_id:, assignment_id:, attempt_id:, mapping_id:)
43
+ events = freeze_tree(JSON.parse(JSON.generate(events)))
44
+ unless events.is_a?(Array) && Models::EvidenceEvent.chain_valid?(events) &&
45
+ events.all? { |event| event["attempt_id"] == attempt_id }
46
+ unavailable!("scope requires its intact exact attempt chain")
47
+ end
48
+ @expected = {"project_id" => project_id, "assignment_id" => assignment_id,
49
+ "attempt_id" => attempt_id, "mapping_id" => mapping_id}
50
+ generation = 0
51
+ events.each do |event|
52
+ generation += 1 if event["type"] == "authority_mutation"
53
+ accept_reservation!(event, generation) if event["type"] == "authority_mutation" &&
54
+ event.dig("payload", "operation") == "reserve_attempt"
55
+ accept_admission!(event, generation) if event["type"] == "authority_mutation" &&
56
+ event.dig("payload", "operation") == "scope_service_admission"
57
+ case event["type"]
58
+ when "scope_bound" then accept_binding!(event, generation + 1)
59
+ when "scope_native_bound" then accept_native!(event)
60
+ when "scope_child_bound" then accept_child!(event)
61
+ when "scope_sealed" then accept_seal!(event)
62
+ when "scope_closed_no_writers" then accept_proof!(event)
63
+ end
64
+ end
65
+ rescue KeyError, NoMethodError, TypeError
66
+ unavailable!("canonical scope lineage is incomplete")
67
+ end
68
+
69
+ def binding
70
+ binding_event&.fetch("payload")
71
+ end
72
+
73
+ def sealed?
74
+ !seal_event.nil?
75
+ end
76
+
77
+ def proof_id
78
+ proof_event&.fetch("digest")
79
+ end
80
+
81
+ def require_open!
82
+ unavailable!("canonical scope binding is missing") unless binding
83
+ raise AttemptErrors::Conflict, "execution generation is sealed" if sealed?
84
+ true
85
+ end
86
+
87
+ def require_launch_bound!
88
+ require_open!
89
+ unavailable!("original native and child stages are missing") unless native_event && child_event
90
+ child_event.fetch("payload").fetch("original_process_binding")
91
+ end
92
+
93
+ def require_positive!(scope_generation:, scope_binding_event_id:, seal_event_id:, proof_id:)
94
+ unless proof_event && binding.fetch("scope_generation") == scope_generation &&
95
+ binding_event.fetch("digest") == scope_binding_event_id && seal_event.fetch("digest") == seal_event_id &&
96
+ proof_event.fetch("digest") == proof_id
97
+ unavailable!("exact canonical scope proof is unavailable")
98
+ end
99
+ proof_event.fetch("payload")
100
+ end
101
+
102
+ private
103
+
104
+ def accept_reservation!(event, generation)
105
+ unavailable!("canonical reservation is repeated") if @reservation
106
+ value = event.dig("payload", "data")
107
+ unless value.is_a?(Hash) && value.slice(*@expected.keys) == @expected &&
108
+ value["reservation_generation"] == generation && value["generation"] == generation &&
109
+ value["launch_ticket"].is_a?(String) && ID.match?(value["launch_ticket"])
110
+ unavailable!("canonical reservation binding differs")
111
+ end
112
+ @reservation = value
113
+ end
114
+
115
+ def freeze_tree(value)
116
+ case value
117
+ when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
118
+ when Array then value.each { |item| freeze_tree(item) }
119
+ end
120
+ value.freeze
121
+ end
122
+
123
+ def accept_binding!(event, generation)
124
+ unavailable!("scope binding cannot be replaced") if binding_event
125
+ value = event.fetch("payload")
126
+ exact_fields!(value, BINDING_FIELDS)
127
+ unless value.slice(*@expected.keys) == @expected &&
128
+ value["slot_id"].is_a?(String) && ID.match?(value["slot_id"]) &&
129
+ value["scope_generation"] == generation && @reservation &&
130
+ value["reservation_generation"] == @reservation["reservation_generation"] &&
131
+ digest?(value["deployment_digest"]) && boot?(value["boot_id"]) &&
132
+ invocation?(value["slice_invocation_id"])
133
+ unavailable!("scope generation binding differs")
134
+ end
135
+ cgroup!(value.fetch("cgroup_identity"))
136
+ namespace!(value.fetch("resource_mount_namespace_identity"))
137
+ namespace!(value.fetch("network_namespace_identity"))
138
+ self.class.validate_network_selection!(value.fetch("network_installation_selection"))
139
+ resources = value.fetch("resource_identities")
140
+ resources!(resources)
141
+ @binding_event = event
142
+ end
143
+
144
+ def stage_reference!(value)
145
+ unavailable!("scope stage requires its original open parent") unless binding && !sealed?
146
+ unless value["scope_generation"] == binding.fetch("scope_generation") &&
147
+ value["scope_binding_event_id"] == binding_event.fetch("digest")
148
+ unavailable!("scope stage references another parent")
149
+ end
150
+ end
151
+
152
+ def accept_admission!(event, generation)
153
+ unavailable!("native admission cannot replace an existing stage") if admission_event || native_event
154
+ value = event.fetch("payload").fetch("data")
155
+ stage_reference!(value)
156
+ unless value.slice(*@expected.keys) == @expected && value["generation"] == generation &&
157
+ value["native_admission"] == "issued_uncertain"
158
+ unavailable!("native admission does not name the exact current reserved owner")
159
+ end
160
+ installation = value.fetch("network_installation")
161
+ exact_fields!(installation, %w[report_id boot_id slot_id namespace_path namespace_identity profile_sha256 policy_export_sha256 report_sha256 installer_artifact_sha256])
162
+ namespace!(installation.fetch("namespace_identity"))
163
+ selection = binding.fetch("network_installation_selection")
164
+ unless boot?(installation["report_id"]) && installation["boot_id"] == binding["boot_id"] &&
165
+ installation["slot_id"] == binding["slot_id"] && installation["namespace_identity"] == binding["network_namespace_identity"] &&
166
+ installation["namespace_path"].is_a?(String) && installation["namespace_path"].start_with?("/") &&
167
+ !installation["namespace_path"].include?("\0") && File.expand_path(installation["namespace_path"]) == installation["namespace_path"] &&
168
+ %w[profile policy_export report installer_artifact].all? { |key| installation["#{key}_sha256"] == selection.fetch(key).fetch("sha256") }
169
+ unavailable!("native admission installation differs from original parent selection")
170
+ end
171
+ @admission_event = event
172
+ end
173
+
174
+ def accept_native!(event)
175
+ unavailable!("native binding cannot be replaced") if native_event
176
+ value = event.fetch("payload")
177
+ exact_fields!(value, %w[scope_generation scope_binding_event_id service_invocation_id server_identity socket_identity workspace_id
178
+ mount_namespace_identity resource_observer_identity resource_identities network_namespace_identity network_admission_event_id])
179
+ stage_reference!(value)
180
+ namespace!(value.fetch("network_namespace_identity"))
181
+ unless admission_event && value["network_admission_event_id"] == admission_event.fetch("digest") &&
182
+ value["network_namespace_identity"] == binding.fetch("network_namespace_identity")
183
+ unavailable!("native server does not join the original admitted network installation")
184
+ end
185
+ process!(value.fetch("server_identity"), binding.fetch("boot_id"))
186
+ socket = value.fetch("socket_identity")
187
+ unless invocation?(value["service_invocation_id"]) && value["workspace_id"].is_a?(String) &&
188
+ value["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/) && socket.is_a?(Array) && socket.size == 3 &&
189
+ socket.all? { |part| part.is_a?(Integer) && part >= 0 } && socket.last == value.dig("server_identity", "uid")
190
+ unavailable!("scope native incarnation differs")
191
+ end
192
+ namespace!(value.fetch("mount_namespace_identity"))
193
+ observer = value.fetch("resource_observer_identity")
194
+ process!(observer, binding.fetch("boot_id"))
195
+ server = value.fetch("server_identity")
196
+ unless observer.values_at("uid", "gid", "groups", "host") == server.values_at("uid", "gid", "groups", "host") &&
197
+ observer["pid"] != server["pid"]
198
+ unavailable!("native resource observer is not the distinct fixed same-user hook")
199
+ end
200
+ resources!(value.fetch("resource_identities"))
201
+ parent_resources = binding.fetch("resource_identities").to_h { |resource| [resource.values_at("host_path", "view_path"), resource] }
202
+ value.fetch("resource_identities").each do |resource|
203
+ original = parent_resources[resource.values_at("host_path", "view_path")]
204
+ if original && original.slice("device", "inode", "filesystem_type", "uid", "gid") != resource.slice("device", "inode", "filesystem_type", "uid", "gid")
205
+ unavailable!("native view substitutes a parent-pinned backing object")
206
+ end
207
+ end
208
+ @native_event = event
209
+ end
210
+
211
+ def accept_child!(event)
212
+ unavailable!("child binding requires its single native predecessor") unless native_event && !child_event
213
+ value = event.fetch("payload")
214
+ exact_fields!(value, %w[scope_generation scope_binding_event_id native_binding_event_id original_process_binding])
215
+ stage_reference!(value)
216
+ unless value["native_binding_event_id"] == native_event.fetch("digest")
217
+ unavailable!("child references another native incarnation")
218
+ end
219
+ native = native_event.fetch("payload")
220
+ original = value.fetch("original_process_binding")
221
+ exact_fields!(original, %w[runtime session pane terminal_id process_identity shell_identity native_origin])
222
+ process!(original.fetch("process_identity"), binding.fetch("boot_id"))
223
+ socket = native.fetch("socket_identity")
224
+ server = native.fetch("server_identity")
225
+ child = original.fetch("process_identity")
226
+ origin = original.fetch("native_origin")
227
+ exact_fields!(origin, %w[workspace tab pane server_identity socket_identity command cwd])
228
+ command = origin.fetch("command")
229
+ unless child["parent_pid"] == server["pid"] &&
230
+ child.values_at("uid", "gid", "groups") == server.values_at("uid", "gid", "groups") &&
231
+ original["runtime"] == "herdr" && original["shell_identity"] == child &&
232
+ original["session"] == native["workspace_id"] && origin["workspace"] == native["workspace_id"] &&
233
+ origin["server_identity"] == server && origin["socket_identity"] == socket &&
234
+ origin["pane"] == original["pane"] && %w[pane terminal_id].all? { |key| original[key].is_a?(String) && !original[key].empty? } &&
235
+ origin["tab"].is_a?(String) && !origin["tab"].empty? && path?(origin["cwd"]) &&
236
+ command.is_a?(Array) && command.size == 3 && path?(command.first) && command[1] == binding["mapping_id"] &&
237
+ command.last == @reservation.fetch("launch_ticket")
238
+ unavailable!("scope original-child lineage differs")
239
+ end
240
+ @child_event = event
241
+ end
242
+
243
+ def accept_seal!(event)
244
+ unavailable!("scope seal has no immutable binding or is repeated") unless binding && !seal_event
245
+ value = event.fetch("payload")
246
+ exact_fields!(value, %w[scope_generation scope_binding_event_id])
247
+ unless value == {"scope_generation" => binding.fetch("scope_generation"),
248
+ "scope_binding_event_id" => binding_event.fetch("digest")}
249
+ unavailable!("scope seal references another incarnation")
250
+ end
251
+ @seal_event = event
252
+ end
253
+
254
+ def accept_proof!(event)
255
+ unavailable!("scope proof requires its original seal and single observation") unless sealed? && !proof_event
256
+ value = event.fetch("payload")
257
+ exact_fields!(value, PROOF_FIELDS)
258
+ expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity")
259
+ expected.merge!("scope_binding_event_id" => binding_event.fetch("digest"),
260
+ "seal_event_id" => seal_event.fetch("digest"), "populated" => 0)
261
+ unavailable!("scope proof is not exact sealed empty population") unless value == expected
262
+ @proof_event = event
263
+ end
264
+
265
+ def namespace!(value)
266
+ exact_fields!(value, %w[device inode])
267
+ unless value["device"].is_a?(Integer) && value["device"] >= 0 && positive_integer?(value["inode"])
268
+ unavailable!("scope mount namespace identity is malformed")
269
+ end
270
+ end
271
+
272
+ def resources!(resources)
273
+ unless resources.is_a?(Array) && resources.size <= 64 &&
274
+ resources.all? { |resource| resource.is_a?(Hash) } &&
275
+ resources.map { |resource| resource.values_at("host_path", "view_path") }.uniq.size == resources.size &&
276
+ resources.map { |resource| resource["view_path"] }.uniq.size == resources.size
277
+ unavailable!("scope resource observations are duplicate or oversized")
278
+ end
279
+ resources.each do |resource|
280
+ exact_fields!(resource, RESOURCE_FIELDS)
281
+ unless path?(resource["host_path"]) && path?(resource["view_path"]) && positive_integer?(resource["mount_id"]) &&
282
+ %w[device inode uid gid].all? { |key| resource[key].is_a?(Integer) && resource[key] >= 0 } &&
283
+ resource["filesystem_type"].is_a?(String) && resource["filesystem_type"].bytesize.between?(1, 64) &&
284
+ resource["filesystem_type"].ascii_only? && !resource["filesystem_type"].match?(/[\s\0]/)
285
+ unavailable!("scope resource identity is malformed")
286
+ end
287
+ end
288
+ end
289
+
290
+ def cgroup!(value)
291
+ exact_fields!(value, CGROUP_FIELDS)
292
+ unless path?(value["path"]) && value["path"].start_with?("/sys/fs/cgroup/") &&
293
+ value["filesystem_type"] == "cgroup2" &&
294
+ %w[mount_id device inode].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 }
295
+ unavailable!("scope parent object identity is malformed")
296
+ end
297
+ end
298
+
299
+ def process!(value, boot_id)
300
+ exact_fields!(value, PROCESS_FIELDS)
301
+ groups = value["groups"]
302
+ unless %w[pid uid gid parent_pid].all? { |key| positive_integer?(value[key]) } &&
303
+ groups.is_a?(Array) && groups.all? { |id| positive_integer?(id) } && groups == groups.sort.uniq &&
304
+ value["started_at"].is_a?(String) && value["started_at"].match?(/\Alinux:#{Regexp.escape(boot_id)}:[0-9]+\z/) &&
305
+ value["host"].is_a?(String) && !value["host"].empty?
306
+ unavailable!("scope process birth/credentials are malformed")
307
+ end
308
+ end
309
+
310
+ def exact_fields!(value, keys)
311
+ unavailable!("scope event schema differs") unless value.is_a?(Hash) && value.keys.sort == keys.sort
312
+ end
313
+
314
+ def path?(value)
315
+ value.is_a?(String) && value.bytesize.between?(2, 4096) && !value.include?("\0") &&
316
+ value.start_with?("/") && File.expand_path(value) == value
317
+ end
318
+
319
+ def positive_integer?(value) = value.is_a?(Integer) && value.positive?
320
+ def digest?(value) = value.is_a?(String) && DIGEST.match?(value)
321
+ def boot?(value) = value.is_a?(String) && BOOT.match?(value)
322
+ def invocation?(value) = value.is_a?(String) && INVOCATION.match?(value)
323
+ def unavailable!(message) = raise(AttemptErrors::EvidenceUnavailable, message)
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Protected authority mutations share the execution ref's lock and CAS.
10
+ # Replies live in chained attempt events, rather than an RPC ledger.
11
+ module JournalMutation
12
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
13
+
14
+ # Shared canonical projection used by admission and protected status.
15
+ # Callers hold the same owner exclusion when projecting current events.
16
+ def authority_generation(events)
17
+ unless Models::EvidenceEvent.chain_valid?(events)
18
+ raise AttemptErrors::EvidenceUnavailable, "Attempt event chain is corrupt"
19
+ end
20
+ events.count { |event| event["type"] == "authority_mutation" }
21
+ end
22
+
23
+ def canonical_attempt_state(events)
24
+ authority_generation(events)
25
+ derive_state(events)
26
+ end
27
+
28
+ # The block runs against the current ref on every CAS attempt. It
29
+ # returns events [{type:, payload:}], immutable blobs, and public data.
30
+ # Only the authority calls this internal journal API; wire requests
31
+ # cannot select blob paths, event types, or accepted response data.
32
+ def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected)
33
+ unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
34
+ (generation_mode == :recorded_completion && operation == "complete_service" && expected_generation.nil?)
35
+ raise ArgumentError, "invalid fixed mutation generation mode"
36
+ end
37
+ [assignment_id, attempt_id, mutation_id].each { |id| validate_mutation_id!(id) }
38
+ unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
39
+ raise ArgumentError, "invalid mutation parameter digest"
40
+ end
41
+ with_lock do
42
+ self.class::CAS_ATTEMPTS.times do
43
+ old = ref_value
44
+ ensure_checkout!
45
+ old = ref_value if old.nil?
46
+ sync_checkout(old)
47
+ replay = mutation_result(mutation_id)
48
+ if replay
49
+ unless replay["operation"] == operation && replay["parameters_digest"] == parameters_digest &&
50
+ replay["assignment_id"] == assignment_id && replay["attempt_id"] == attempt_id
51
+ raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
52
+ end
53
+ result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
54
+ return with_replay ? {data: result, replayed: true} : result
55
+ end
56
+ current = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
57
+ generation = authority_generation(current)
58
+ unless generation_mode == :recorded_completion || expected_generation == generation
59
+ raise AttemptErrors::Conflict, "Authority registration generation changed"
60
+ end
61
+ plan = yield(current, old, generation)
62
+ blobs = plan.fetch(:blobs, {})
63
+ events = chain_mutation_events(attempt_id, current.last&.fetch("digest"), plan.fetch(:events, []))
64
+ updates = prepare_service_updates(plan.fetch(:service_updates, []), assignment_id: assignment_id,
65
+ attempt_id: attempt_id, pending: {current_events: current, pending_events: events, blobs: blobs, commit: old,
66
+ service_inputs: service_input_context(plan)})
67
+ events.concat(chain_mutation_events(attempt_id, events.last&.fetch("digest") || current.last&.fetch("digest"),
68
+ updates.map { |prepared| prepared.fetch(:event) }))
69
+ data = plan.fetch(:data).merge("generation" => generation + 1)
70
+ receipt = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: attempt_id,
71
+ previous_digest: events.last&.fetch("digest") || current.last&.fetch("digest"),
72
+ payload: {"mutation_id" => mutation_id, "operation" => operation,
73
+ "parameters_digest" => parameters_digest, "assignment_id" => assignment_id,
74
+ "attempt_id" => attempt_id, "data" => data})
75
+ events << receipt
76
+ write_mutation_blobs(blobs, commit: old)
77
+ write_service_records(updates)
78
+ write_event_files(assignment_id, events)
79
+ git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}")
80
+ stage_mutation_blobs(blobs)
81
+ stage_service_records(updates)
82
+ git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
83
+ "-c", "core.hooksPath=/dev/null", "commit", "-m", "evidence: authority #{operation} #{mutation_id}")
84
+ commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
85
+ if update_ref_cas(commit, old)
86
+ result = data.merge("journal_commit" => commit)
87
+ return with_replay ? {data: result, replayed: false} : result
88
+ end
89
+ end
90
+ raise AttemptErrors::EvidenceUnavailable, "Authority mutation ref stayed conflicting"
91
+ end
92
+ end
93
+
94
+ def mutation_result(mutation_id)
95
+ validate_mutation_id!(mutation_id)
96
+ assignment_ids.each do |assignment_id|
97
+ events = read_events(assignment_id)
98
+ events.group_by { |event| event["attempt_id"] }.each_value do |chain|
99
+ unless Models::EvidenceEvent.chain_valid?(chain)
100
+ raise AttemptErrors::EvidenceUnavailable, "Authority mutation event chain is corrupt"
101
+ end
102
+ end
103
+ event = events.find do |entry|
104
+ entry["type"] == "authority_mutation" && entry.dig("payload", "mutation_id") == mutation_id
105
+ end
106
+ next unless event
107
+
108
+ path = "execution/#{assignment_id}/events/#{event_filename(event)}"
109
+ commit = git!("log", "--diff-filter=A", "-1", "--format=%H", ref_value, "--", path).first
110
+ raise AttemptErrors::EvidenceUnavailable, "Mutation acceptance commit is missing" if commit.empty?
111
+ return event.fetch("payload").merge("journal_commit" => commit)
112
+ end
113
+ nil
114
+ end
115
+
116
+ # Source composition supplies original body bytes only for the current
117
+ # service CAS callback. They are never part of persisted mutation data.
118
+ def service_input_context(plan)
119
+ inputs = plan.fetch(:service_inputs, {})
120
+ raise ArgumentError, "invalid ephemeral service input context" unless inputs.is_a?(Hash)
121
+ return {}.freeze if inputs.empty?
122
+ updates = plan.fetch(:service_updates, [])
123
+ unless inputs.size == 1 && updates.size == 1 && inputs.keys == [updates.first.fetch(:request_id)] &&
124
+ inputs.values.first.is_a?(String) && inputs.values.first.bytesize.between?(1, 64 * 1024)
125
+ raise ArgumentError, "invalid ephemeral service input context"
126
+ end
127
+ inputs.to_h { |key, bytes| [key.dup.freeze, bytes.dup.freeze] }.freeze
128
+ end
129
+ private :service_input_context
130
+
131
+ # Read immutable bytes directly from a selected canonical ref commit.
132
+ # Worktree projections are never consulted for protected evidence.
133
+ def blob(path, commit: ref_value)
134
+ validate_blob_path!(path)
135
+ raise AttemptErrors::EvidenceUnavailable, "Canonical evidence ref is missing" unless commit
136
+ out, _error, status = Open3.capture3("git", "show", "#{commit}:#{path}",
137
+ chdir: repo_root, stdin_data: "")
138
+ raise AttemptErrors::EvidenceUnavailable, "Canonical evidence blob is missing" unless status.success?
139
+ out.b
140
+ end
141
+
142
+ private
143
+
144
+ def validate_mutation_id!(id)
145
+ raise ArgumentError, "invalid authority ID" unless id.is_a?(String) && id.match?(ID)
146
+ end
147
+
148
+ def validate_blob_path!(path)
149
+ unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
150
+ !%w[. ..].include?(File.basename(path))
151
+ raise ArgumentError, "invalid canonical blob reference"
152
+ end
153
+ end
154
+
155
+ def chain_mutation_events(attempt_id, previous, entries)
156
+ entries.map do |entry|
157
+ recorded_at = entry.fetch(:recorded_at) { Time.now.utc }
158
+ raise ArgumentError, "internal event timestamp must be a UTC Time" unless recorded_at.is_a?(Time) && recorded_at.utc?
159
+ event = Models::EvidenceEvent.build(type: entry.fetch(:type), attempt_id: attempt_id,
160
+ payload: entry.fetch(:payload), previous_digest: previous, recorded_at: recorded_at)
161
+ previous = event.fetch("digest")
162
+ event
163
+ end
164
+ end
165
+
166
+ # Git clean/smudge filters and CRLF conversion cannot redefine accepted
167
+ # evidence bytes. Insert unfiltered objects after staging the events.
168
+ def stage_mutation_blobs(blobs)
169
+ blobs.each do |path, bytes|
170
+ oid, error, status = Open3.capture3("git", "hash-object", "-w", "--stdin", "--no-filters",
171
+ chdir: repo_root, stdin_data: bytes.b)
172
+ unless status.success?
173
+ raise AttemptErrors::EvidenceUnavailable, "Cannot write canonical evidence object: #{error.strip}"
174
+ end
175
+ git!("-C", checkout_dir, "update-index", "--add", "--cacheinfo", "100644,#{oid.strip},#{path}")
176
+ end
177
+ end
178
+
179
+ def write_mutation_blobs(blobs, commit:)
180
+ blobs.map do |path, bytes|
181
+ validate_blob_path!(path)
182
+ raise ArgumentError, "canonical blob must contain bytes" unless bytes.is_a?(String)
183
+ target = File.join(checkout_dir, path)
184
+ if File.exist?(target)
185
+ # A checkout may have smudged the file; immutability is checked
186
+ # against accepted bytes at the canonical commit, not projection.
187
+ unless blob(path, commit: commit) == bytes.b
188
+ raise AttemptErrors::Conflict, "Canonical evidence blob is immutable"
189
+ end
190
+ else
191
+ FileUtils.mkdir_p(File.dirname(target))
192
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
193
+ file.write(bytes.b)
194
+ end
195
+ end
196
+ path
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -54,6 +54,10 @@ module Ace
54
54
  "assignment:#{assignment_id}"
55
55
  end
56
56
 
57
+ def slot_key(slot_id)
58
+ "execution-slot:#{slot_id}"
59
+ end
60
+
57
61
  # Task identity is the canonical worktree identity: work-on derives
58
62
  # the worktree path from the task ref, and prune candidates carry
59
63
  # the same task id.