ace-assign 0.70.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  4. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  5. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  6. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  7. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  8. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  9. data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
  10. data/lib/ace/assign/authority/command_execution.rb +180 -0
  11. data/lib/ace/assign/authority/endcap.rb +4 -2
  12. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  13. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  14. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  15. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  16. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  17. data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
  18. data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
  19. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  20. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  21. data/lib/ace/assign/authority/ordinary_results.rb +11 -1
  22. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  23. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  24. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  25. data/lib/ace/assign/authority/prepared_work.rb +7 -2
  26. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  27. data/lib/ace/assign/authority/retained_input.rb +53 -0
  28. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  29. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  30. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  31. data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
  32. data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
  33. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
  36. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +19 -4
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "stringio"
6
+ require "zlib"
7
+ require "rubygems/package"
8
+ require_relative "../molecules/canonical_evidence"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Authority
13
+ # One retained gem and the source-owned publication input it binds.
14
+ # Inspect bounded metadata only; never extract or evaluate build files.
15
+ module ReleaseArtifactManifest
16
+ FIELDS = %w[gem_name version platform registry sha256 bytes].freeze
17
+ NAME = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
18
+ MAX_METADATA_BYTES = 1024 * 1024
19
+ REGISTRY = "https://rubygems.org"
20
+
21
+ def self.validate!(value)
22
+ unless value.is_a?(Hash) && value.keys.sort == FIELDS.sort &&
23
+ value["gem_name"].is_a?(String) && NAME.match?(value["gem_name"]) &&
24
+ value["version"].is_a?(String) && value["version"].bytesize.between?(1, 128) && Gem::Version.correct?(value["version"]) &&
25
+ value["platform"].is_a?(String) && NAME.match?(value["platform"]) &&
26
+ value["registry"] == REGISTRY && value["sha256"].is_a?(String) && value["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
27
+ value["bytes"].is_a?(Integer) && value["bytes"].between?(1, Molecules::CanonicalEvidence::MAX_RELEASE_ARTIFACT_BYTES)
28
+ raise ArgumentError, "invalid release artifact manifest"
29
+ end
30
+ value
31
+ end
32
+
33
+ def self.verify!(bytes, manifest)
34
+ validate!(manifest)
35
+ unless bytes.is_a?(String) && bytes.bytesize == manifest.fetch("bytes") &&
36
+ Digest::SHA256.hexdigest(bytes.b) == manifest.fetch("sha256")
37
+ raise AttemptErrors::ReceiptRejected, "release artifact bytes differ from the manifest"
38
+ end
39
+ metadata = metadata!(bytes)
40
+ unless metadata == manifest.slice("gem_name", "version", "platform")
41
+ raise AttemptErrors::ReceiptRejected, "release artifact gem metadata differs from the manifest"
42
+ end
43
+ true
44
+ end
45
+
46
+ # Shared immutable input for retention and the later fixed publisher.
47
+ # Candidate/reference come from the installed canonical owner, not wire
48
+ # claims. Its digest is the existing service authorization input_digest.
49
+ def self.publication_input(manifest:, candidate:, reference:)
50
+ validate!(manifest)
51
+ {"target" => {"resource" => "rubygems:#{manifest.fetch('gem_name')}:#{manifest.fetch('version')}",
52
+ "artifact_digest" => manifest.fetch("sha256")},
53
+ "publication" => manifest.slice("gem_name", "version", "platform", "registry").merge(
54
+ "head" => candidate.fetch("head"), "tree" => candidate.fetch("tree"), "candidate_reference" => reference,
55
+ "artifact_bytes" => manifest.fetch("bytes"), "artifact_relative_path" => "artifacts/#{manifest.fetch('sha256')}.gem")}
56
+ end
57
+
58
+ def self.metadata!(bytes)
59
+ metadata = nil
60
+ Gem::Package::TarReader.new(StringIO.new(bytes)) do |archive|
61
+ archive.each do |entry|
62
+ next unless entry.full_name == "metadata.gz"
63
+ unless metadata.nil? && entry.file? && entry.header.size.between?(1, MAX_METADATA_BYTES)
64
+ raise AttemptErrors::ReceiptRejected, "release artifact metadata is missing, repeated or oversized"
65
+ end
66
+ compressed = entry.read
67
+ Zlib::GzipReader.wrap(StringIO.new(compressed)) do |reader|
68
+ metadata = reader.read(MAX_METADATA_BYTES + 1)
69
+ if metadata.bytesize > MAX_METADATA_BYTES
70
+ raise AttemptErrors::ReceiptRejected, "release artifact metadata exceeds its bound"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ raise AttemptErrors::ReceiptRejected, "release artifact metadata is unavailable" unless metadata
76
+ specification = Gem::Specification.from_yaml(metadata)
77
+ unless specification.is_a?(Gem::Specification)
78
+ raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
79
+ end
80
+ {"gem_name" => specification.name, "version" => specification.version.to_s,
81
+ "platform" => specification.platform.to_s}
82
+ rescue AttemptErrors::ReceiptRejected
83
+ raise
84
+ rescue StandardError
85
+ raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
86
+ end
87
+ private_class_method :metadata!
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "private_directory"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Immutable requester inputs, never evidence that an operation executed.
11
+ module RetainedInput
12
+ module_function
13
+
14
+ def read_file(path, limit)
15
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
16
+ before = file.stat
17
+ raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
18
+ bytes = (file.read(limit + 1) || "").b
19
+ after, selected = file.stat, File.lstat(path)
20
+ identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
21
+ unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
22
+ identity.call(after) == identity.call(selected) && !selected.symlink?
23
+ raise ArgumentError, "retained input changed during read"
24
+ end
25
+ bytes.freeze
26
+ end
27
+ end
28
+
29
+ def json(path, limit = 16_384)
30
+ JSON.parse(read_file(path, limit), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
31
+ end
32
+
33
+ def create_output(path)
34
+ raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
35
+ directory = File.expand_path(path)
36
+ Dir.mkdir(directory, 0o700)
37
+ PrivateDirectory.verify!(directory)
38
+ directory
39
+ end
40
+
41
+ def write_file(directory, name, bytes)
42
+ raise ArgumentError, "retained filename is invalid" unless File.basename(name) == name && !%w[. ..].include?(name)
43
+ File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
44
+ file.write(bytes)
45
+ file.flush
46
+ file.fsync
47
+ end
48
+ File.open(directory) { |file| file.fsync }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -5,6 +5,7 @@ require "tempfile"
5
5
  require "timeout"
6
6
  require "ace/runtime/molecules/protected_socket"
7
7
  require_relative "private_directory"
8
+ require_relative "../atoms/candidate_limits"
8
9
 
9
10
  module Ace
10
11
  module Assign
@@ -13,10 +14,11 @@ module Ace
13
14
  # Server selects purpose from a source handler's fixed operation table,
14
15
  # admits a transfer slot before calling, and holds no journal lock here.
15
16
  class TransferCodec
16
- LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
17
+ LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES],
18
+ release_artifact: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
17
19
  artifacts: [256 * 1024, 16, 64 * 1024],
18
20
  receipt_artifacts: [272 * 1024, 17, 64 * 1024],
19
- service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [64 * 1024 * 1024, 1, 64 * 1024 * 1024]}.freeze
21
+ service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES]}.freeze
20
22
  SHA256 = /\A[0-9a-f]{64}\z/
21
23
 
22
24
  class Input
@@ -149,7 +151,7 @@ module Ace
149
151
  part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
150
152
  part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
151
153
  descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
152
- (!%i[candidate service_input prompt_text assignment_snapshot].include?(purpose) || descriptor["bytes"].positive?)
154
+ (!%i[candidate service_input prompt_text assignment_snapshot release_artifact].include?(purpose) || descriptor["bytes"].positive?)
153
155
  reject!("Invalid or oversized transfer descriptor")
154
156
  end
155
157
  if purpose == :receipt_artifacts
@@ -23,7 +23,7 @@ module Ace
23
23
  head = require_option(options, :head, usage)
24
24
  raise Ace::Support::Cli::Error, "Head must be an exact SHA-1" unless head.match?(/\A[0-9a-f]{40}\z/)
25
25
  params.merge!("head" => head, "candidate_generation" => integer_option(options, :candidate_generation, usage))
26
- bytes = input_bytes(require_option(options, :bundle, usage), limit: 64 * 1024 * 1024)
26
+ bytes = input_bytes(require_option(options, :bundle, usage), limit: Ace::Assign::Authority::CandidateTransfer::MAX_BYTES)
27
27
  parts = [bytes]
28
28
  emit_json(upload_call(client, "submit_candidate", params, mutation, parts, :candidate))
29
29
  end
@@ -14,8 +14,12 @@ module Ace
14
14
  MAX_TOTAL_BYTES = 256 * 1024
15
15
  MAX_ARTIFACTS = 16
16
16
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
+ MAX_RELEASE_ARTIFACT_BYTES = 64 * 1024 * 1024
17
18
  ROLES = {"service" => "executor", "review" => "reviewer",
18
- "result" => "worker"}.freeze
19
+ "result" => "worker", "release-artifact" => "worker"}.freeze
20
+ REPORT_LIMITS = [MAX_ARTIFACT_BYTES, MAX_TOTAL_BYTES, MAX_ARTIFACTS].freeze
21
+ LIMITS = {"service" => REPORT_LIMITS, "review" => REPORT_LIMITS, "result" => REPORT_LIMITS,
22
+ "release-artifact" => [MAX_RELEASE_ARTIFACT_BYTES, MAX_RELEASE_ARTIFACT_BYTES, 1].freeze}.freeze
19
23
  DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
20
24
  binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
21
25
  candidate_generation_or_claim_generation].freeze
@@ -27,7 +31,8 @@ module Ace
27
31
  %w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
28
32
  %w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
29
33
  descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
30
- descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0, MAX_ARTIFACT_BYTES) &&
34
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(descriptor["kind"] == "release-artifact" ? 1 : 0,
35
+ LIMITS.fetch(descriptor["kind"]).first) &&
31
36
  descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
32
37
  descriptor["admitted_at"].is_a?(String)
33
38
  Time.iso8601(descriptor.fetch("admitted_at"))
@@ -46,13 +51,15 @@ module Ace
46
51
  def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
47
52
  request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
48
53
  role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
54
+ max_each, max_total, max_count = LIMITS.fetch(kind)
55
+ minimum = kind == "release-artifact" ? 1 : 0
49
56
  ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
50
57
  unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
51
58
  admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
52
59
  peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
53
- artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= MAX_ARTIFACTS &&
54
- artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES } &&
55
- artifacts.sum(&:bytesize) <= MAX_TOTAL_BYTES
60
+ artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= max_count &&
61
+ artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize.between?(minimum, max_each) } &&
62
+ artifacts.sum(&:bytesize) <= max_total
56
63
  raise ArgumentError, "invalid or oversized canonical evidence import"
57
64
  end
58
65
  events = []
@@ -85,7 +92,11 @@ module Ace
85
92
  verified_bytes(reference, events: events, kind: kind, project_id: project_id,
86
93
  assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
87
94
  request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
88
- @journal.blob(path, commit: commit)
95
+ if kind == "release-artifact"
96
+ @journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
97
+ else
98
+ @journal.blob(path, commit: commit)
99
+ end
89
100
  end
90
101
  end
91
102
 
@@ -102,7 +113,13 @@ module Ace
102
113
  verified_bytes(reference, events: events, kind: kind, project_id: project_id,
103
114
  assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
104
115
  request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
105
- blobs.key?(path) ? blobs.fetch(path) : @journal.blob(path, commit: commit)
116
+ if blobs.key?(path)
117
+ blobs.fetch(path)
118
+ elsif kind == "release-artifact"
119
+ @journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
120
+ else
121
+ @journal.blob(path, commit: commit)
122
+ end
106
123
  end
107
124
  end
108
125
 
@@ -137,7 +154,7 @@ module Ace
137
154
  end
138
155
  Time.iso8601(descriptor.fetch("admitted_at"))
139
156
  bytes = yield(reference.fetch("ref"))
140
- unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
157
+ unavailable! unless bytes.is_a?(String) && bytes.bytesize <= LIMITS.fetch(kind).first && bytes.bytesize == descriptor["bytes"] &&
141
158
  Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
142
159
  bytes
143
160
  rescue ArgumentError, KeyError, TypeError
@@ -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"
@@ -375,13 +376,10 @@ module Ace
375
376
  else
376
377
  bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
377
378
  end
378
- lines = raw.lines
379
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata count differs" unless lines.size == oids.size
380
- lines.zip(oids).map do |line, oid|
381
- match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
382
- raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
383
- match[2].to_i
384
- end
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
385
383
  end
386
384
 
387
385
  def bounded_history_read!(argv, stdin, limit)
@@ -1086,26 +1084,11 @@ module Ace
1086
1084
  end
1087
1085
 
1088
1086
  def decode_event_blobs!(entries, output)
1089
- output = output.b
1090
- offset = 0
1091
- entries.map do |oid, size|
1092
- ending = output.index("\n", offset)
1093
- unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
1094
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
1095
- end
1096
- offset = ending + 1
1097
- content = output.byteslice(offset, size)
1098
- unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
1099
- Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
1100
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
1101
- end
1102
- offset += size + 1
1103
- content
1104
- end.tap do
1105
- unless offset == output.bytesize
1106
- raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
1107
- end
1108
- end
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
1109
1092
  end
1110
1093
 
1111
1094
  def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
@@ -1360,7 +1343,7 @@ module Ace
1360
1343
  # CAS transition, so a receiver restart can never dispatch a second
1361
1344
  # merge for the same admitted record. An already-started claim refuses;
1362
1345
  # only the winning effect may later settle it.
1363
- def begin_fixed_merge_request(request_id)
1346
+ def begin_fixed_merge_request(request_id, guard: nil)
1364
1347
  current = fixed_merge_request(request_id)
1365
1348
  raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
1366
1349
  if current["state"] == "uncertain"
@@ -1371,7 +1354,7 @@ module Ace
1371
1354
  end
1372
1355
  update_fixed_merge_request(request_id, expected: current,
1373
1356
  replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
1374
- event_type: "merge_transition", guard: -> { verify_fixed_proposal!(current) })
1357
+ event_type: "merge_transition", guard: -> { guard&.call; verify_fixed_proposal!(current) })
1375
1358
  end
1376
1359
 
1377
1360
  # Terminal settlement: succeeded/failed with the exact receipt
@@ -1527,7 +1510,11 @@ module Ace
1527
1510
  raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
1528
1511
  end
1529
1512
  if existing
1530
- 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
1531
1518
  raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
1532
1519
  end
1533
1520
  unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
@@ -4,6 +4,7 @@ require "json"
4
4
  require "digest"
5
5
  require "ace/herdr/molecules/bounded_process"
6
6
  require_relative "journal_prompt_mutation"
7
+ require_relative "../atoms/candidate_limits"
7
8
  require_relative "journal_input_inhibition"
8
9
 
9
10
  module Ace
@@ -192,7 +193,7 @@ module Ace
192
193
  def bounded_blob(path, commit:, max_bytes:)
193
194
  validate_blob_path!(path)
194
195
  unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) &&
195
- max_bytes.is_a?(Integer) && max_bytes.between?(1, 64 * 1024 * 1024)
196
+ max_bytes.is_a?(Integer) && max_bytes.between?(1, Atoms::CandidateLimits::BYTES)
196
197
  raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob selector differs"
197
198
  end
198
199
  return @read_boundary.blob(path, commit: commit, max_bytes: max_bytes) if @read_boundary
@@ -24,7 +24,7 @@ module Ace
24
24
  # @param identity_resolver [ExecutionIdentityResolver] Trust boundary
25
25
  # @param artifact_reader [#call, nil] Source-owned canonical byte
26
26
  # reader for protected composition; nil selects explicit local files
27
- def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
27
+ def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil, review_independence_verifier: nil)
28
28
  @identity_resolver = identity_resolver || ExecutionIdentityResolver.new
29
29
  unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
30
30
  raise ArgumentError, "artifact reader must be a source-owned callable"
@@ -34,6 +34,10 @@ module Ace
34
34
  raise ArgumentError, "campaign verifier must be a source-owned callable"
35
35
  end
36
36
  @campaign_verifier = campaign_verifier
37
+ unless review_independence_verifier.nil? || review_independence_verifier.respond_to?(:call)
38
+ raise ArgumentError, "review independence verifier must be a source-owned callable"
39
+ end
40
+ @review_independence_verifier = review_independence_verifier
37
41
  end
38
42
 
39
43
  # Validate a submitted receipt against an attempt.
@@ -222,7 +226,9 @@ module Ace
222
226
  reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
223
227
 
224
228
  producer_actor = data.dig("producer", "actor").to_s.strip
225
- if reviewer["actor"].to_s.strip == producer_actor
229
+ if @review_independence_verifier
230
+ reject("canonical reviewer independence is unverifiable") unless @review_independence_verifier.call(data) == true
231
+ elsif reviewer["actor"].to_s.strip == producer_actor
226
232
  reject("reviewer and producer must differ (self-approval rejected)")
227
233
  end
228
234
 
@@ -12,9 +12,10 @@ module Ace
12
12
  # operation delegates to the resolved Ace::Runtime adapter; assign
13
13
  # never calls a runtime-native API directly (k86.3 interface rule).
14
14
  class RuntimeControlSurfaceRunner
15
- def initialize(runtime: nil, env: ENV)
15
+ def initialize(runtime: nil, env: ENV, adapter: nil)
16
16
  @runtime_name = runtime
17
17
  @env = env
18
+ @runtime = adapter
18
19
  end
19
20
 
20
21
  attr_reader :runtime_name, :env
@@ -37,7 +37,7 @@ module Ace
37
37
  # @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
38
38
  # @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
39
39
  def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
40
- lifecycle_exclusion: nil, recorded_binding_observer: nil)
40
+ lifecycle_exclusion: nil, recorded_binding_observer: nil, ordinary_merge_evidence: nil)
41
41
  unless recorded_binding_observer.nil? || recorded_binding_observer.respond_to?(:call)
42
42
  raise ArgumentError, "recorded native binding observer must be callable"
43
43
  end
@@ -49,6 +49,7 @@ module Ace
49
49
  @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
50
50
  @lifecycle_exclusion = lifecycle_exclusion
51
51
  @recorded_binding_observer = recorded_binding_observer
52
+ @ordinary_merge_evidence = ordinary_merge_evidence
52
53
  end
53
54
 
54
55
  attr_reader :store
@@ -328,6 +329,7 @@ module Ace
328
329
  # never need message-string discrimination: replay/binding conflicts
329
330
  # stay Conflict, a consumed authorization is AuthorizationConflict.
330
331
  guard = lambda do
332
+ verify_fixed_merge_evidence!(record)
331
333
  conflict = journal_for.fixed_authorization_conflict(record)
332
334
  if conflict
333
335
  raise AttemptErrors::AuthorizationConflict,
@@ -335,15 +337,50 @@ module Ace
335
337
  end
336
338
  nil
337
339
  end
338
- journal_for.admit_fixed_merge_request(record, guard: guard)
340
+ with_fixed_merge_scope(record) do
341
+ journal_for.admit_fixed_merge_request(record, guard: guard)
342
+ end
339
343
  end
340
344
 
341
345
  # Begin the fixed effect (pending -> uncertain). The single CAS
342
346
  # transition is the at-most-one-merge proof: a restarted receiver
343
347
  # refuses an already-started claim and must not dispatch again.
344
348
  def begin_fixed_merge_effect(request_id)
345
- journal_for.begin_fixed_merge_request(request_id)
349
+ record = journal_for.fixed_merge_request(request_id)
350
+ raise AttemptErrors::NotFound, "fixed merge record is unavailable" unless record
351
+ with_fixed_merge_scope(record) do
352
+ journal_for.begin_fixed_merge_request(request_id, guard: -> { verify_fixed_merge_evidence!(record) })
353
+ end
354
+ end
355
+
356
+ # Hold the existing assignment exclusion through the external effect.
357
+ # Review cancellation/head changes/finish cannot race this interval.
358
+ def with_fixed_merge_evidence(record)
359
+ with_fixed_merge_scope(record, exclusive: true) do
360
+ verify_fixed_merge_evidence!(record)
361
+ yield
362
+ end
363
+ end
364
+
365
+ def verify_fixed_merge_evidence!(record)
366
+ unless @ordinary_merge_evidence
367
+ raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
368
+ end
369
+ @ordinary_merge_evidence.verify_merge_evidence_at!(record: record, commit: journal_for.ref_value,
370
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
371
+ nil
372
+ rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::UnauthorizedIdentity, AttemptErrors::Conflict, AttemptErrors::NotFound
373
+ raise AttemptErrors::ReceiptRejected, "canonical ordinary review or executed test evidence is unavailable"
374
+ end
375
+
376
+ def with_fixed_merge_scope(record, exclusive: false, &block)
377
+ unless @ordinary_merge_evidence
378
+ raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
379
+ end
380
+ @ordinary_merge_evidence.with_merge_scope(record: record, exclusive: exclusive,
381
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30, &block)
346
382
  end
383
+ private :with_fixed_merge_scope
347
384
 
348
385
  # Settle the fixed effect with the exact terminal receipt. The
349
386
  # journal validates the closed receipt schema and its binding to the