ace-assign 0.69.0 → 0.71.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +39 -10
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Molecules
6
+ # Closed bounded read dialect shared by isolated protected snapshots and
7
+ # trusted installed journals. Neither boundary can dispatch a Git write.
8
+ module CanonicalReadCommands
9
+ BYTE_LIMIT = 256 * 1_048_576
10
+ OID = /\A[0-9a-f]{40}\z/
11
+
12
+ attr_reader :commit
13
+
14
+ # EvidenceJournal uses this only as its existing read command boundary.
15
+ def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
16
+ refuse!("read_outside_snapshot") unless @view && @commit
17
+ unless read_command?(argv, stdin_data)
18
+ refuse!("write_or_unsupported_command")
19
+ end
20
+ result = read_result(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
21
+ [result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
22
+ end
23
+
24
+ def blob_batch(entries, output_limit:)
25
+ refuse!("read_outside_snapshot") unless @view && @commit
26
+ unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
27
+ refuse!("unsupported_command")
28
+ end
29
+ result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
30
+ limit: [output_limit, BYTE_LIMIT].min)
31
+ result.stdout.to_s.b
32
+ end
33
+
34
+ def history_diff(commits, paths, output_limit:)
35
+ refuse!("read_outside_snapshot") unless @view && @commit
36
+ unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
37
+ commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
38
+ paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
39
+ (paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
40
+ output_limit.is_a?(Integer) && output_limit.positive?
41
+ refuse!("unsupported_command")
42
+ end
43
+ result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
44
+ --no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
45
+ limit: [output_limit, BYTE_LIMIT].min)
46
+ result.stdout.to_s.b
47
+ end
48
+
49
+ def blob_sizes(oids)
50
+ refuse!("read_outside_snapshot") unless @view && @commit
51
+ unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
52
+ refuse!("unsupported_command")
53
+ end
54
+ run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
55
+ end
56
+
57
+ def blob(path, commit:, max_bytes:)
58
+ refuse!("read_outside_snapshot") unless @view && @commit
59
+ unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
60
+ refuse!("unsupported_command")
61
+ end
62
+ result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
63
+ result.stdout.to_s.b
64
+ end
65
+
66
+ private
67
+
68
+ def read_result(argv, **options)
69
+ run!(argv, **options)
70
+ end
71
+
72
+ def read_command?(argv, stdin)
73
+ return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
74
+ allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
75
+ "rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
76
+ "show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
77
+ flags = allowed[argv.first]
78
+ flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
79
+ end
80
+
81
+ end
82
+ end
83
+ end
84
+ end
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "tmpdir"
6
6
  require "ace/herdr/molecules/bounded_process"
7
7
  require_relative "evidence_journal"
8
+ require_relative "canonical_read_commands"
8
9
  require_relative "../authority/private_directory"
9
10
 
10
11
  module Ace
@@ -13,6 +14,7 @@ module Ace
13
14
  # Isolates read-only Git from an installed authority's live configuration.
14
15
  # The copied view never publishes evidence or mutates the source journal.
15
16
  class CanonicalReadSnapshot
17
+ include CanonicalReadCommands
16
18
  class Unavailable < AttemptErrors::EvidenceUnavailable; end
17
19
  BYTE_LIMIT = 256 * 1_048_576
18
20
  ENTRY_LIMIT = 4096
@@ -94,60 +96,6 @@ module Ace
94
96
  end
95
97
  private :with_snapshot
96
98
 
97
- attr_reader :commit
98
-
99
- # EvidenceJournal uses this only as its existing read command boundary.
100
- def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
101
- refuse!("read_outside_snapshot") unless @view && @commit
102
- unless read_command?(argv, stdin_data)
103
- refuse!("write_or_unsupported_command")
104
- end
105
- result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
106
- [result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
107
- end
108
-
109
- def blob_batch(entries, output_limit:)
110
- refuse!("read_outside_snapshot") unless @view && @commit
111
- unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
112
- refuse!("unsupported_command")
113
- end
114
- result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
115
- limit: [output_limit, BYTE_LIMIT].min)
116
- result.stdout.to_s.b
117
- end
118
-
119
- def history_diff(commits, paths, output_limit:)
120
- refuse!("read_outside_snapshot") unless @view && @commit
121
- unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
122
- commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
123
- paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
124
- (paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
125
- output_limit.is_a?(Integer) && output_limit.positive?
126
- refuse!("unsupported_command")
127
- end
128
- result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
129
- --no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
130
- limit: [output_limit, BYTE_LIMIT].min)
131
- result.stdout.to_s.b
132
- end
133
-
134
- def blob_sizes(oids)
135
- refuse!("read_outside_snapshot") unless @view && @commit
136
- unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
137
- refuse!("unsupported_command")
138
- end
139
- run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
140
- end
141
-
142
- def blob(path, commit:, max_bytes:)
143
- refuse!("read_outside_snapshot") unless @view && @commit
144
- unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
145
- refuse!("unsupported_command")
146
- end
147
- result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
148
- result.stdout.to_s.b
149
- end
150
-
151
99
  private
152
100
 
153
101
  def refuse!(reason)
@@ -352,15 +300,6 @@ module Ace
352
300
  File.binwrite(ref, "#{@commit}\n")
353
301
  end
354
302
 
355
- def read_command?(argv, stdin)
356
- return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
357
- allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
358
- "rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
359
- "show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
360
- flags = allowed[argv.first]
361
- flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
362
- end
363
-
364
303
  def run!(argv, stdin_data: "", limit:)
365
304
  result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
366
305
  environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
@@ -7,6 +7,7 @@ require_relative "../atoms/fixed_merge_schemas"
7
7
  require "digest"
8
8
  require "fileutils"
9
9
  require "json"
10
+ require_relative "../atoms/git_blob_batch"
10
11
  require "open3"
11
12
  require "pathname"
12
13
  require "ace/herdr/molecules/bounded_process"
@@ -62,6 +63,7 @@ module Ace
62
63
  def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
63
64
  evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
64
65
  unless %i[local protected].include?(mode) &&
66
+ (evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
65
67
  (mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
66
68
  raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
67
69
  end
@@ -128,7 +130,7 @@ module Ace
128
130
  paths.each do |path|
129
131
  parts = path.split("/", -1)
130
132
  event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
131
- request = parts.size == 3 && parts[0, 2] == %w[execution requests] && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".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"))
132
134
  # Definitions, prepared packets, candidate bundles and imports
133
135
  # retain the SAME mutation owner's closed immutable blob dialect.
134
136
  validate_blob_path!(path) unless event || request
@@ -138,6 +140,12 @@ module Ace
138
140
  unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
139
141
  raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
140
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) }
141
149
  current = File.stat(@repo_root)
142
150
  unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
143
151
  raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
@@ -201,6 +209,21 @@ module Ace
201
209
  event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
202
210
  end
203
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
+
204
227
  # One immutable walk for an operation selecting several assignments.
205
228
  # Every selected assignment retains its complete chain and exact raw
206
229
  # blob proof; the result is returned, never cached on this owner.
@@ -353,13 +376,10 @@ module Ace
353
376
  else
354
377
  bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
355
378
  end
356
- lines = raw.lines
357
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata count differs" unless lines.size == oids.size
358
- lines.zip(oids).map do |line, oid|
359
- match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
360
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
361
- match[2].to_i
362
- 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
363
383
  end
364
384
 
365
385
  def bounded_history_read!(argv, stdin, limit)
@@ -760,7 +780,9 @@ module Ace
760
780
  %w[succeeded failed failed-settled].include?(state)
761
781
  end
762
782
 
763
- def validate_terminal_receipt!(current, state, receipt, pending: nil)
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)
764
786
  unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
765
787
  raise AttemptErrors::ReceiptRejected,
766
788
  "Service terminal receipt has invalid fields"
@@ -787,7 +809,14 @@ module Ace
787
809
  raise AttemptErrors::ReceiptRejected,
788
810
  "Service settlement requires a no-effect attestation artifact"
789
811
  end
790
- if @mode == :protected
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
791
820
  if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
792
821
  (current["operation"] == "publish" && %w[succeeded failed].include?(state))
793
822
  # The same installed evidence reader owns the entire imported
@@ -922,6 +951,9 @@ module Ace
922
951
  def authorization_conflict(binding)
923
952
  authorization = binding["authorization"]
924
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
925
957
  service_request_records.find do |other|
926
958
  next false if other["request_id"] == binding.fetch("request_id")
927
959
  next false if other["state"] == "rejected" && other["consumed"] == false
@@ -994,8 +1026,8 @@ module Ace
994
1026
  #
995
1027
  # @param assignment_id [String] Assignment ID
996
1028
  # @return [Array<Models::Attempt>] Derived attempts
997
- def derived_attempts(assignment_id)
998
- events = read_events(assignment_id)
1029
+ def derived_attempts(assignment_id, commit: ref_value)
1030
+ events = read_events(assignment_id, commit: commit)
999
1031
  by_attempt = events.group_by { |event| event["attempt_id"] }
1000
1032
  by_attempt.delete(nil)
1001
1033
 
@@ -1006,7 +1038,7 @@ module Ace
1006
1038
  state = derive_state(attempt_events)
1007
1039
  next if state.nil?
1008
1040
 
1009
- build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
1041
+ build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
1010
1042
  end
1011
1043
  end
1012
1044
 
@@ -1052,26 +1084,11 @@ module Ace
1052
1084
  end
1053
1085
 
1054
1086
  def decode_event_blobs!(entries, output)
1055
- output = output.b
1056
- offset = 0
1057
- entries.map do |oid, size|
1058
- ending = output.index("\n", offset)
1059
- unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
1060
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
1061
- end
1062
- offset = ending + 1
1063
- content = output.byteslice(offset, size)
1064
- unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
1065
- Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
1066
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
1067
- end
1068
- offset += size + 1
1069
- content
1070
- end.tap do
1071
- unless offset == output.bytesize
1072
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
1073
- end
1074
- end
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
1075
1092
  end
1076
1093
 
1077
1094
  def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
@@ -1271,7 +1288,7 @@ module Ace
1271
1288
  # opaque reference is receiver-issued, never caller-supplied, and
1272
1289
  # replays return the originally issued one.
1273
1290
  FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
1274
- queue_entry operation authorization input_digest request principal receiver_id
1291
+ queue_entry operation authorization input_digest request principal caller_uid receiver_id
1275
1292
  configuration_sha256 execution_identity].freeze
1276
1293
  FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
1277
1294
 
@@ -1324,25 +1341,27 @@ module Ace
1324
1341
 
1325
1342
  # Claim the start of the fixed effect: pending -> uncertain in one
1326
1343
  # CAS transition, so a receiver restart can never dispatch a second
1327
- # merge for the same admitted record — begin replays as already
1328
- # started, and only the owning effect may later settle it.
1329
- def begin_fixed_merge_request(request_id)
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)
1330
1347
  current = fixed_merge_request(request_id)
1331
1348
  raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
1332
- return current if current["state"] == "uncertain"
1349
+ if current["state"] == "uncertain"
1350
+ raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
1351
+ end
1333
1352
  unless current["state"] == "pending"
1334
1353
  raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1335
1354
  end
1336
1355
  update_fixed_merge_request(request_id, expected: current,
1337
1356
  replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
1338
- event_type: "merge_transition")
1357
+ event_type: "merge_transition", guard: -> { guard&.call; verify_fixed_proposal!(current) })
1339
1358
  end
1340
1359
 
1341
1360
  # Terminal settlement: succeeded/failed with the exact receipt
1342
1361
  # validated HERE at the journal boundary — structure, the closed
1343
1362
  # terminal invariants, and the binding against the current record.
1344
1363
  # Failure is terminal only with immutable no-effect evidence.
1345
- def settle_fixed_merge_request(request_id, terminal_status:, receipt:)
1364
+ def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
1346
1365
  unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
1347
1366
  raise ArgumentError, "invalid fixed merge terminal status"
1348
1367
  end
@@ -1351,17 +1370,40 @@ module Ace
1351
1370
  unless %w[pending uncertain].include?(current["state"])
1352
1371
  raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1353
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
1354
1391
  validate_fixed_receipt!(current, terminal_status, receipt)
1355
1392
  update_fixed_merge_request(request_id, expected: current,
1356
1393
  replacement: current.merge("state" => terminal_status, "receipt" => receipt,
1357
1394
  "finished_at" => Time.now.utc.iso8601(9)),
1358
- event_type: "merge_transition")
1395
+ event_type: "merge_transition", evidence_files: evidence_files)
1359
1396
  end
1360
1397
 
1361
1398
  def fixed_merge_request_path(request_id)
1362
1399
  "execution/merge-requests/#{request_id}.json"
1363
1400
  end
1364
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
+
1365
1407
  def validate_fixed_receipt!(current, terminal_status, receipt)
1366
1408
  Atoms::FixedMergeSchemas.validate_receipt!(receipt)
1367
1409
  Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
@@ -1388,25 +1430,39 @@ module Ace
1388
1430
  end
1389
1431
  end
1390
1432
 
1391
- def fixed_no_effect_intact?(record)
1433
+ def fixed_no_effect_intact?(record, commit: ref_value)
1392
1434
  receipt = record["receipt"]
1393
1435
  return false unless receipt.is_a?(Hash)
1394
1436
  evidence = receipt.dig("effect", "no_effect_evidence")
1395
1437
  return false unless evidence.is_a?(Hash)
1396
- repo_root = File.realpath(@repo_root)
1397
- path = File.expand_path(evidence["reference"].to_s, repo_root)
1398
- real = begin
1399
- File.realpath(path)
1400
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
1401
- return false
1402
- end
1403
- real.start_with?(repo_root + File::SEPARATOR) &&
1404
- Digest::SHA256.file(real).hexdigest == evidence.fetch("sha256")
1405
- rescue Errno::ENOENT, AttemptErrors::EvidenceUnavailable
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
1406
1446
  false
1407
1447
  end
1408
1448
 
1409
- def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil)
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: {})
1410
1466
  unless %w[merge_admission merge_transition].include?(event_type)
1411
1467
  raise ArgumentError, "invalid fixed merge event type"
1412
1468
  end
@@ -1419,10 +1475,8 @@ module Ace
1419
1475
  prepared = prepare_fixed_update(request_id: request_id, expected: expected,
1420
1476
  replacement: replacement, event_type: event_type, guard: guard)
1421
1477
  return prepared.fetch(:record) if prepared[:replayed]
1478
+ write_mutation_blobs(evidence_files, commit: old)
1422
1479
  path = fixed_merge_request_path(request_id)
1423
- full = File.join(checkout_dir, path)
1424
- FileUtils.mkdir_p(File.dirname(full))
1425
- File.write(full, JSON.pretty_generate(prepared.fetch(:record)))
1426
1480
  assignment_id = replacement.fetch("assignment_id")
1427
1481
  attempt_id = replacement.fetch("attempt_id")
1428
1482
  prior = read_events(assignment_id).reverse
@@ -1430,7 +1484,8 @@ module Ace
1430
1484
  event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
1431
1485
  payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
1432
1486
  write_event_files(assignment_id, [event])
1433
- git!("-C", checkout_dir, "add", "--", path, "execution/#{assignment_id}/events")
1487
+ git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
1488
+ stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
1434
1489
  state = replacement.fetch("state")
1435
1490
  git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1436
1491
  "commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
@@ -1455,7 +1510,11 @@ module Ace
1455
1510
  raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
1456
1511
  end
1457
1512
  if existing
1458
- return {record: existing, replayed: true} if fixed_replay?(existing, replacement)
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
1459
1518
  raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
1460
1519
  end
1461
1520
  unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
@@ -1477,6 +1536,13 @@ module Ace
1477
1536
  end
1478
1537
  conflict = guard&.call
1479
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"
1480
1546
  payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
1481
1547
  "input_digest" => replacement.fetch("input_digest"),
1482
1548
  "record_digest" => Atoms::EvidenceDigest.digest(replacement),
@@ -1485,6 +1551,21 @@ module Ace
1485
1551
  end
1486
1552
  private :prepare_fixed_update
1487
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
+
1488
1569
  def fixed_replay?(existing, replacement)
1489
1570
  FIXED_MERGE_BINDING_FIELDS.all? do |field|
1490
1571
  existing[field] == replacement.fetch(field)
@@ -1514,7 +1595,8 @@ module Ace
1514
1595
  # The module marks a private region above; the fixed merge primitive
1515
1596
  # is a public coordinator-facing surface, so it is re-published here.
1516
1597
  public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
1517
- :begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict
1598
+ :begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
1599
+ :verify_service_attestation!
1518
1600
 
1519
1601
  # Latest lifecycle state implied by the events, or nil when the
1520
1602
  # events do not describe a full attempt (intent missing).
@@ -1522,7 +1604,7 @@ module Ace
1522
1604
  CanonicalAttemptState.derive(events)
1523
1605
  end
1524
1606
 
1525
- def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
1607
+ def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
1526
1608
  process_start = events.reverse.find do |event|
1527
1609
  event["type"] == "process_start" && event["attempt_id"] == attempt_id
1528
1610
  end
@@ -1552,7 +1634,7 @@ module Ace
1552
1634
  binding: binding,
1553
1635
  state: state,
1554
1636
  candidate_head: candidate_head,
1555
- journal_commit: ref_value
1637
+ journal_commit: commit
1556
1638
  )
1557
1639
  end
1558
1640
 
@@ -1658,7 +1740,7 @@ module Ace
1658
1740
  File.join(@checkout_root, ".evidence.lock")
1659
1741
  end
1660
1742
 
1661
- def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
1743
+ def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
1662
1744
  raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
1663
1745
  FileUtils.mkdir_p(@checkout_root)
1664
1746
  lock = if owner_uid
@@ -1667,7 +1749,19 @@ module Ace
1667
1749
  File.open(lock_path, File::RDWR | File::CREAT, 0o600)
1668
1750
  end
1669
1751
  begin
1670
- lock.flock(File::LOCK_EX)
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
1671
1765
  begin
1672
1766
  yield
1673
1767
  rescue