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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr/molecules/bounded_process"
4
+ require_relative "evidence_journal"
5
+ require_relative "canonical_read_commands"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Read the installed owner's immutable Git objects without opening its
11
+ # private writer lock/cache, copying the repository, or contacting it.
12
+ # Source selection is supplied only by verified installation policy.
13
+ class InstalledJournalRead
14
+ include CanonicalReadCommands
15
+ ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
16
+ "GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
17
+ "GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
18
+ "GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
19
+ "GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
20
+ ENTRY_LIMIT = 100_000
21
+
22
+ def initialize(repo_root:, ref:, owner_uid:)
23
+ unless repo_root.is_a?(String) && repo_root.start_with?("/") &&
24
+ !repo_root.include?("\0") && File.expand_path(repo_root) == repo_root &&
25
+ ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z}) &&
26
+ owner_uid.is_a?(Integer) && owner_uid.positive?
27
+ raise ArgumentError, "installed journal selection is malformed"
28
+ end
29
+ @repo_root, @ref, @owner_uid = repo_root.dup.freeze, ref.dup.freeze, owner_uid
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ def with(deadline:)
34
+ refuse!("read_busy") unless @mutex.try_lock
35
+ begin
36
+ @deadline = deadline
37
+ remaining
38
+ verify_source!
39
+ @view = File.join(@repo_root, ".git")
40
+ @commit = run!(["rev-parse", "--verify", @ref], limit: 128).stdout.strip
41
+ refuse!("ref_unavailable") unless @commit.match?(OID)
42
+ result = yield self
43
+ verify_source!
44
+ result
45
+ ensure
46
+ @view = @commit = @deadline = nil
47
+ @mutex.unlock
48
+ end
49
+ rescue SystemCallError, IOError, Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError
50
+ refuse!("source_unavailable")
51
+ end
52
+
53
+ private
54
+
55
+ def refuse!(reason)
56
+ raise AttemptErrors::EvidenceUnavailable, "installed journal read unavailable: #{reason}"
57
+ end
58
+
59
+ def remaining
60
+ refuse!("deadline") unless @deadline.is_a?(Numeric) && @deadline.finite?
61
+ value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ refuse!("deadline") unless value.positive?
63
+ value
64
+ end
65
+
66
+ def verify_source!
67
+ # Caller-writable ancestors can replace even a correctly owned leaf.
68
+ current = File.join(@repo_root, ".git")
69
+ loop do
70
+ stat = File.lstat(current)
71
+ unless stat.directory? && !stat.symlink? && [0, @owner_uid].include?(stat.uid) &&
72
+ (stat.mode & 0o022).zero?
73
+ refuse!("storage_owner")
74
+ end
75
+ if [@repo_root, File.join(@repo_root, ".git")].include?(current) && stat.uid != @owner_uid
76
+ refuse!("repository_owner")
77
+ end
78
+ break if current == "/"
79
+ current = File.dirname(current)
80
+ end
81
+ %w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
82
+ path = File.join(@repo_root, ".git", name)
83
+ refuse!("redirected_storage") if File.exist?(path) || File.symlink?(path)
84
+ end
85
+ @entries = 0
86
+ %w[objects refs config HEAD packed-refs].each do |name|
87
+ path = File.join(@repo_root, ".git", name)
88
+ next if %w[HEAD packed-refs].include?(name) && !File.exist?(path) && !File.symlink?(path)
89
+ verify_tree!(path)
90
+ end
91
+ verify_config!
92
+ end
93
+
94
+ def verify_tree!(path)
95
+ remaining
96
+ @entries += 1
97
+ refuse!("storage_inventory_limit") if @entries > ENTRY_LIMIT
98
+ stat = File.lstat(path)
99
+ unless !stat.symlink? && (stat.file? || stat.directory?) &&
100
+ [0, @owner_uid].include?(stat.uid) && (stat.mode & 0o022).zero?
101
+ refuse!("storage_owner")
102
+ end
103
+ Dir.each_child(path) { |name| verify_tree!(File.join(path, name)) } if stat.directory?
104
+ end
105
+
106
+ def verify_config!
107
+ bytes = File.open(File.join(@repo_root, ".git/config"), File::RDONLY | File::NOFOLLOW) { |io| io.read(65_537) }
108
+ refuse!("config_limit") if bytes.bytesize > 65_536
109
+ result = Herdr::Molecules::BoundedProcess.call(
110
+ ["/usr/bin/git", "config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
111
+ environment: ENVIRONMENT, chdir: "/", stdin_data: bytes,
112
+ timeout_s: remaining, output_limit: 65_536)
113
+ refuse!("config_unavailable") unless result.status.success? && !result.oversized
114
+ result.stdout.split("\0").each do |entry|
115
+ key, value = entry.split("\n", 2)
116
+ if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
117
+ key == "core.repositoryformatversion" && value != "0"
118
+ refuse!("redirected_configuration")
119
+ end
120
+ end
121
+ end
122
+
123
+ def read_result(argv, **options)
124
+ run!(argv, **options, allow_failure: true)
125
+ end
126
+
127
+ def run!(argv, stdin_data: "", limit:, allow_failure: false)
128
+ # All ref-based reads in one observation use the same immutable tip.
129
+ selected = argv.map { |arg| arg == @ref && @commit ? @commit : arg }
130
+ result = Herdr::Molecules::BoundedProcess.call([
131
+ "/usr/bin/git", "--git-dir=#{@view}", "--no-pager",
132
+ "-c", "safe.directory=#{@repo_root}", "-c", "core.hooksPath=/dev/null",
133
+ "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *selected],
134
+ environment: ENVIRONMENT, chdir: @repo_root, stdin_data: stdin_data,
135
+ timeout_s: remaining, output_limit: [limit, BYTE_LIMIT].min, stderr_limit: 65_536)
136
+ refuse!("resource_limit") if result.oversized
137
+ refuse!("git_unavailable") unless allow_failure || result.status.success?
138
+ result
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -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
@@ -34,7 +35,7 @@ module Ace
34
35
  # returns events [{type:, payload:}], immutable blobs, and public data.
35
36
  # Only the authority calls this internal journal API; wire requests
36
37
  # cannot select blob paths, event types, or accepted response data.
37
- def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil)
38
+ def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil, deadline: nil, replay_authorizer: nil)
38
39
  unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
39
40
  (generation_mode == :recorded_completion && %w[complete_service complete_no_effect].include?(operation) && expected_generation.nil?) ||
40
41
  (generation_mode == :prompt_completion && operation == "prompt_attempt" && expected_generation.nil? && prompt_completion.is_a?(Hash)) ||
@@ -71,7 +72,8 @@ module Ace
71
72
  unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
72
73
  raise ArgumentError, "invalid mutation parameter digest"
73
74
  end
74
- with_lock do
75
+ raise ArgumentError, "replay authorizer must be source owned" if replay_authorizer && !replay_authorizer.respond_to?(:call)
76
+ with_lock(deadline: deadline) do
75
77
  self.class::CAS_ATTEMPTS.times do
76
78
  old = ref_value
77
79
  ensure_checkout!
@@ -96,6 +98,7 @@ module Ace
96
98
  raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
97
99
  end
98
100
  result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
101
+ replay_authorizer&.call
99
102
  return with_replay ? {data: result, replayed: true} : result
100
103
  end
101
104
  current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
@@ -190,7 +193,7 @@ module Ace
190
193
  def bounded_blob(path, commit:, max_bytes:)
191
194
  validate_blob_path!(path)
192
195
  unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) &&
193
- 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)
194
197
  raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob selector differs"
195
198
  end
196
199
  return @read_boundary.blob(path, commit: commit, max_bytes: max_bytes) if @read_boundary
@@ -215,7 +218,7 @@ module Ace
215
218
  end
216
219
 
217
220
  def validate_blob_path!(path)
218
- unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
221
+ unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|execution/merge-evidence|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
219
222
  !%w[. ..].include?(File.basename(path))
220
223
  raise ArgumentError, "invalid canonical blob reference"
221
224
  end
@@ -214,14 +214,25 @@ module Ace
214
214
  # given order, released in reverse — callers must use one consistent
215
215
  # global order, task before assignment, to avoid deadlock). Fails
216
216
  # closed when any identity was pruned.
217
- def with_shared_multi(keys, reset_removed: false)
217
+ def with_shared_multi(keys, reset_removed: false, deadline: nil)
218
218
  ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
219
219
  raise ArgumentError, "exclusion keys are required" if ordered.empty?
220
220
 
221
221
  locks = []
222
222
  begin
223
+ maintenance_deadline!(deadline) if deadline
223
224
  ordered.each { |key| locks << open_lock(key) }
224
- locks.each { |lock| lock.flock(File::LOCK_SH) }
225
+ locks.each do |lock|
226
+ if deadline
227
+ until lock.flock(File::LOCK_SH | File::LOCK_NB)
228
+ maintenance_deadline!(deadline)
229
+ sleep 0.01
230
+ end
231
+ maintenance_deadline!(deadline)
232
+ else
233
+ lock.flock(File::LOCK_SH)
234
+ end
235
+ end
225
236
  ordered.each do |key|
226
237
  if removed?(key)
227
238
  unless reset_removed
@@ -11,7 +11,7 @@ module Ace
11
11
  PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
12
12
  PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
13
13
 
14
- PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision]).freeze
14
+ PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision silence_delegated]).freeze
15
15
 
16
16
  def proposal_history(id)
17
17
  assignment_ids.flat_map do |assignment|
@@ -110,8 +110,7 @@ module Ace
110
110
  valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
111
111
  end
112
112
  unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
113
- claim["state"] != "succeeded" &&
114
- !(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
113
+ claim["state"] != "succeeded" && !proposal_no_effect?(claim)
115
114
  end
116
115
  raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
117
116
  raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
@@ -119,10 +118,39 @@ module Ace
119
118
  private :validate_proposal_revision!
120
119
 
121
120
  def proposal_claims(reference)
122
- service_request_records.select do |request|
121
+ claims = (service_request_records + fixed_merge_request_records).select do |request|
123
122
  request["authorization"] == reference &&
124
123
  !(request["state"] == "rejected" && request["consumed"] == false)
125
124
  end
125
+ return claims if claims.empty?
126
+ events = read_events(claims.first.fetch("assignment_id"))
127
+ claims.sort_by do |claim|
128
+ kind = claim.key?("queue_entry") ? "merge_admission" : "service_claim"
129
+ events.index do |event|
130
+ event["type"] == kind && event["attempt_id"] == claim.fetch("attempt_id") &&
131
+ event.dig("payload", "request_id") == claim.fetch("request_id")
132
+ end || raise(AttemptErrors::EvidenceUnavailable, "Proposal claim has no canonical admission event")
133
+ end
134
+ end
135
+
136
+ # Request IDs are scoped by record family. A same-text ID in the other
137
+ # family is another effect, never an idempotent replay.
138
+ def proposal_claim_conflict(reference, request_id:, fixed:)
139
+ [[service_request_records, false], [fixed_merge_request_records, true]].each do |records, family|
140
+ conflict = records.find do |claim|
141
+ claim["authorization"] == reference &&
142
+ !(family == fixed && claim["request_id"] == request_id) &&
143
+ !(claim["state"] == "rejected" && claim["consumed"] == false) &&
144
+ !proposal_no_effect?(claim)
145
+ end
146
+ return conflict if conflict
147
+ end
148
+ nil
149
+ end
150
+
151
+ def proposal_no_effect?(claim)
152
+ (claim["state"] == "failed-settled" && settlement_evidence_intact?(claim)) ||
153
+ (claim["state"] == "failed" && claim.key?("queue_entry") && fixed_no_effect_intact?(claim))
126
154
  end
127
155
 
128
156
  def proposal_authorize!(reference, binding)
@@ -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,10 @@ 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)
40
+ lifecycle_exclusion: nil, recorded_binding_observer: nil, ordinary_merge_evidence: nil)
41
+ unless recorded_binding_observer.nil? || recorded_binding_observer.respond_to?(:call)
42
+ raise ArgumentError, "recorded native binding observer must be callable"
43
+ end
41
44
  @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
42
45
  @store = @manager.attempt_store
43
46
  @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
@@ -45,6 +48,8 @@ module Ace
45
48
  @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
46
49
  @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
47
50
  @lifecycle_exclusion = lifecycle_exclusion
51
+ @recorded_binding_observer = recorded_binding_observer
52
+ @ordinary_merge_evidence = ordinary_merge_evidence
48
53
  end
49
54
 
50
55
  attr_reader :store
@@ -59,16 +64,17 @@ module Ace
59
64
  {events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
60
65
  end
61
66
 
62
- # The protected result owner authenticates imported bytes, candidate,
67
+ # The source-selected result owner authenticates imported bytes, candidate,
63
68
  # closure and independent settlements at the same CAS prefix.
64
- def self.finished_transition_plan(events:, receipt:)
69
+ def self.finished_transition_plan(events:, receipt:, authority:)
70
+ raise ArgumentError, "unknown result authority" unless %i[ordinary protected].include?(authority)
65
71
  state = Molecules::CanonicalAttemptState.derive(events)
66
72
  unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
67
73
  raise AttemptErrors::Conflict, "Result finish requires a running attempt"
68
74
  end
69
75
  Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
70
76
  {events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
71
- {type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"}}], blobs: {}}
77
+ {type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "#{authority}_result_accepted"}}], blobs: {}}
72
78
  end
73
79
 
74
80
  def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
@@ -113,21 +119,13 @@ module Ace
113
119
  raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
114
120
  end
115
121
  binding = reconciler.recorded_binding(attempt)
116
- observation = reconciler.observation(attempt)
117
- unless binding && observation["liveness"] == "live" &&
118
- binding["process_identity"] == observation["identity"] &&
119
- %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
122
+ unless binding && %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
120
123
  raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
121
124
  end
122
- unless caller_pid.nil?
123
- unless caller_pid.is_a?(Integer) && caller_pid.positive?
124
- raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
125
- end
126
- caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
127
- unless caller == binding
128
- raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
129
- end
125
+ unless caller_pid.nil? || (caller_pid.is_a?(Integer) && caller_pid.positive?)
126
+ raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
130
127
  end
128
+ observe_runtime_binding!(attempt, binding, caller_pid)
131
129
  latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
132
130
  unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
133
131
  raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
@@ -137,6 +135,28 @@ module Ace
137
135
  raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
138
136
  end
139
137
 
138
+ # An installed consumer selects its own bounded native reader. It must
139
+ # never fall back to the service process's ambient runtime on failure.
140
+ def observe_runtime_binding!(attempt, binding, caller_pid)
141
+ if @recorded_binding_observer
142
+ actual = @recorded_binding_observer.call(binding: binding, caller_pid: caller_pid)
143
+ unless actual == binding
144
+ raise AttemptErrors::UnauthorizedIdentity, "Installed native owner observation differs"
145
+ end
146
+ return
147
+ end
148
+ observation = reconciler.observation(attempt)
149
+ unless observation["liveness"] == "live" && binding["process_identity"] == observation["identity"]
150
+ raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
151
+ end
152
+ return if caller_pid.nil?
153
+ caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
154
+ unless caller == binding
155
+ raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
156
+ end
157
+ end
158
+ private :observe_runtime_binding!
159
+
140
160
  # Claim an external service effect before dispatch. Only a managed,
141
161
  # active attempt for the exact project and candidate may own it. The
142
162
  # guard re-runs inside the journal lock per CAS attempt, so a
@@ -174,7 +194,10 @@ module Ace
174
194
  end
175
195
 
176
196
  def service_attempt(binding)
177
- attempt = authoritative_attempt(binding)
197
+ # An effect can never acquire authority from a requester cache, even
198
+ # when the assignment definition is missing from this consumer.
199
+ attempt = journal_for.derived_attempts(binding.fetch("assignment_id"))
200
+ .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
178
201
  raise AttemptErrors::NotFound, "Attempt not found" unless attempt
179
202
  unless attempt.managed? && attempt.active? &&
180
203
  attempt.binding.assignment_id == binding["assignment_id"] &&
@@ -306,28 +329,65 @@ module Ace
306
329
  # never need message-string discrimination: replay/binding conflicts
307
330
  # stay Conflict, a consumed authorization is AuthorizationConflict.
308
331
  guard = lambda do
309
- conflict = @journal.fixed_authorization_conflict(record)
332
+ verify_fixed_merge_evidence!(record)
333
+ conflict = journal_for.fixed_authorization_conflict(record)
310
334
  if conflict
311
335
  raise AttemptErrors::AuthorizationConflict,
312
336
  "Authorization reference is not currently valid"
313
337
  end
314
338
  nil
315
339
  end
316
- @journal.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
317
343
  end
318
344
 
319
345
  # Begin the fixed effect (pending -> uncertain). The single CAS
320
346
  # transition is the at-most-one-merge proof: a restarted receiver
321
- # replays "already started" and must not dispatch again.
347
+ # refuses an already-started claim and must not dispatch again.
322
348
  def begin_fixed_merge_effect(request_id)
323
- @journal.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)
324
382
  end
383
+ private :with_fixed_merge_scope
325
384
 
326
385
  # Settle the fixed effect with the exact terminal receipt. The
327
386
  # journal validates the closed receipt schema and its binding to the
328
387
  # admitted record before the transition lands.
329
- def settle_fixed_merge_effect(request_id, terminal_status:, receipt:)
330
- @journal.settle_fixed_merge_request(request_id, terminal_status: terminal_status, receipt: receipt)
388
+ def settle_fixed_merge_effect(request_id, terminal_status:, receipt:, attestation: nil)
389
+ journal_for.settle_fixed_merge_request(request_id, terminal_status: terminal_status,
390
+ receipt: receipt, attestation: attestation)
331
391
  end
332
392
 
333
393
  # Read-only canonical record lookup by request ID: lets the producer
@@ -352,7 +412,7 @@ module Ace
352
412
  # without revealing whether another project holds the reference.
353
413
  def fixed_merge_status(reference:, request_id:, project_id:, assignment_id:, attempt_id:,
354
414
  queue_entry:, input_digest:)
355
- record = @journal.fixed_merge_request(request_id)
415
+ record = journal_for.fixed_merge_request(request_id)
356
416
  return nil unless record
357
417
  matches = reference == record["reference"] && input_digest == record["input_digest"] &&
358
418
  [project_id, assignment_id, attempt_id, queue_entry].zip(
@@ -603,7 +663,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
603
663
 
604
664
  active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
605
665
  active_scopes.concat(journal_actives.map(&:binding))
606
- blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
666
+ blocker = active_scopes.find { |binding| Atoms::AssignmentScope.overlap?(binding.scope, scope) }
607
667
  if blocker
608
668
  raise AttemptErrors::Conflict,
609
669
  "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
@@ -842,9 +902,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
842
902
  unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
843
903
  raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
844
904
  end
845
- if !historical_head.nil? && (!%w[review-collection review-approval].include?(kind) || !historical_head.is_a?(String) ||
905
+ if !historical_head.nil? && (!historical_head.is_a?(String) ||
846
906
  !historical_head.match?(/\A[0-9a-f]{40,64}\z/))
847
- raise AttemptErrors::ReceiptRejected, "Historical evidence requires review collection/approval and an exact recorded head"
907
+ raise AttemptErrors::ReceiptRejected, "Historical evidence requires an exact recorded head"
848
908
  end
849
909
  unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
850
910
  receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
@@ -994,14 +1054,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
994
1054
  raise Error, "Failed to generate a unique attempt ID for #{assignment_id} after 100 attempts"
995
1055
  end
996
1056
 
997
- # Ancestors and descendants overlap: an active attempt for 010 owns
998
- # its 010.01 subtree, and vice versa.
999
- def scopes_overlap?(left, right)
1000
- return true if Atoms::AssignmentScope.equal?(left, right)
1001
-
1002
- left.start_with?("#{right}.") || right.start_with?("#{left}.")
1003
- end
1004
-
1005
1057
  # Active attempts derived from the authoritative journal, used when
1006
1058
  # the local record may be missing (lost or wiped cache). Derivation
1007
1059
  # lives in the journal (owner of attempt-state interpretation).
@@ -1059,7 +1111,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
1059
1111
  # merges both). The loser records a stopped transition and refuses.
1060
1112
  def yield_lost_ownership_race(attempt, assignment_id, scope)
1061
1113
  rival = journal_for.active_attempts(assignment_id).find do |other|
1062
- other.attempt_id != attempt.attempt_id && scopes_overlap?(other.binding.scope, scope)
1114
+ other.attempt_id != attempt.attempt_id && Atoms::AssignmentScope.overlap?(other.binding.scope, scope)
1063
1115
  end
1064
1116
  return attempt if rival.nil?
1065
1117