ace-assign 0.58.0 → 0.61.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "json"
4
5
 
5
6
  module Ace
6
7
  module Assign
@@ -55,12 +56,21 @@ module Ace
55
56
  verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
56
57
  verify_checks(data)
57
58
  verify_review(data)
59
+ verify_campaign(data, repo_root: repo_root, live_head: live_head)
58
60
 
59
61
  receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
60
62
  verify_digest(receipt)
61
63
 
62
64
  receipt
63
65
  end
66
+ # Recheck the source artifacts/checks of an already accepted receipt.
67
+ def verify_accepted_evidence!(data, live_head:, repo_root:)
68
+ verify_head(data, live_head)
69
+ verify_artifacts(data, repo_root)
70
+ verify_checks(data)
71
+ verify_review(data)
72
+ end
73
+
64
74
  # @param operation [String] Receipt operation
65
75
  # @return [Boolean] True when the operation acts outside the repository
66
76
  def external_effect?(operation)
@@ -196,6 +206,41 @@ module Ace
196
206
  end
197
207
  end
198
208
 
209
+ # Consume ace-review's single campaign authority through the existing
210
+ # receipt boundary; a worker-supplied local result grants no authority.
211
+ def verify_campaign(data, repo_root:, live_head:)
212
+ campaign = data["campaign"]
213
+ return unless campaign
214
+ reject_unless(campaign.is_a?(Hash), "campaign must be an object")
215
+ reject("campaign result requires a succeeded review receipt") unless
216
+ data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
217
+ reference = campaign["result"]
218
+ reject_unless(reference.is_a?(Hash), "campaign requires a result artifact reference")
219
+ unless Array(data["artifacts"]).include?(reference)
220
+ reject("campaign result must be included in verified receipt artifacts")
221
+ end
222
+ resolved = safe_resolve(repo_root, reference["path"].to_s)
223
+ reject("campaign result path escapes repository or is missing") unless resolved
224
+ result = JSON.parse(File.read(resolved))
225
+ reject_unless(result.is_a?(Hash), "campaign result must be an object")
226
+ require "ace/review"
227
+ current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
228
+ unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
229
+ result["dry_run"] == false && current["accepted"] == true &&
230
+ result["result_identity"] == current["result_identity"] &&
231
+ result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
232
+ reject("campaign result is stale, incomplete, blocked or does not match live authority")
233
+ end
234
+ approval = current["rounds"].last["approval"]
235
+ unless approval["producer"] == data.dig("producer", "actor") &&
236
+ approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
237
+ approval["head"] == data["head"]
238
+ reject("campaign producer/reviewer attribution does not match independently validated receipt")
239
+ end
240
+ rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
241
+ reject("invalid campaign result: #{e.message}")
242
+ end
243
+
199
244
  # Resolve a project-relative artifact path to its real location,
200
245
  # rejecting traversal and symlink escapes (an in-repo symlink must
201
246
  # not smuggle in an external file); returns nil when the path leaves
@@ -114,7 +114,9 @@ module Ace
114
114
  updated_at: assignment.updated_at,
115
115
  source_config: archived_path,
116
116
  cache_dir: assignment.cache_dir,
117
- parent: assignment.parent
117
+ parent: assignment.parent,
118
+ task_id: assignment.task_id,
119
+ project_id: assignment.project_id
118
120
  )
119
121
  assignment_manager.update(assignment)
120
122
 
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
4
5
  require "open3"
6
+ require "pathname"
5
7
 
6
8
  module Ace
7
9
  module Assign
@@ -21,22 +23,282 @@ module Ace
21
23
  # to the candidate revision invalidates prior candidate authorization;
22
24
  # `journal_commit` is tracked separately from both.
23
25
  class AttemptCoordinator
26
+ SERVICE_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
27
+ input_digest operation outcome project_id request_id target transport].freeze
28
+ SERVICE_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
29
+ input_digest target candidate_head executor_uid transport].freeze
24
30
  # @param cache_base [String, nil] Assignment cache base
25
31
  # @param repo_root [String, nil] Candidate repository root (default: project root)
26
32
  # @param journal [Molecules::EvidenceJournal, nil] Evidence journal (default built per repo)
27
33
  # @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
28
34
  # @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
29
- def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil)
35
+ def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
36
+ lifecycle_exclusion: nil)
30
37
  @manager = Molecules::AssignmentManager.new(cache_base: cache_base)
31
38
  @store = @manager.attempt_store
32
39
  @repo_root = repo_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
33
40
  @journal = journal
34
41
  @identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
35
42
  @verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
43
+ @lifecycle_exclusion = lifecycle_exclusion
36
44
  end
37
45
 
38
46
  attr_reader :store
39
47
 
48
+ # Claim an external service effect before dispatch. Only a managed,
49
+ # active attempt for the exact project and candidate may own it. The
50
+ # guard re-runs inside the journal lock per CAS attempt, so a
51
+ # terminal event landing between validation and commit still blocks
52
+ # the claim.
53
+ def claim_service_request(binding)
54
+ attempt = service_attempt(binding)
55
+ head = candidate_head!
56
+ valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
57
+ unless valid_head
58
+ raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
59
+ end
60
+ require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
61
+ # Dispatching claims record uncertain directly: the effect may
62
+ # happen as soon as the claim lands, and a crash before the
63
+ # executor response must leave uncertainty, not a stranded
64
+ # accepted state that a retry would return without dispatching.
65
+ journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
66
+ end
67
+
68
+ # Rechecked against the authoritative ref for every locked claim
69
+ # attempt: the attempt must still be journal-active and the exact
70
+ # authorization must still be unconsumed.
71
+ def claim_guard(binding)
72
+ derived = journal_for.derived_attempts(binding.fetch("assignment_id"))
73
+ .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
74
+ unless derived&.active?
75
+ raise AttemptErrors::ReceiptRejected, "Attempt is no longer active in the authoritative journal"
76
+ end
77
+ journal_for.authorization_conflict(binding)
78
+ end
79
+
80
+ def service_attempt(binding)
81
+ attempt = authoritative_attempt(binding)
82
+ raise AttemptErrors::NotFound, "Attempt not found" unless attempt
83
+ unless attempt.managed? && attempt.active? &&
84
+ attempt.binding.assignment_id == binding["assignment_id"] &&
85
+ attempt.binding.project_id == binding["project_id"]
86
+ raise AttemptErrors::ReceiptRejected, "Service request does not match an active managed attempt"
87
+ end
88
+ attempt
89
+ end
90
+
91
+ # The authoritative journal wins for managed assignments, and a
92
+ # managed service claim REQUIRES the journal-derived attempt: a lost
93
+ # or unavailable evidence ref fails closed instead of trusting a
94
+ # local cache record that the journal cannot corroborate.
95
+ def authoritative_attempt(binding)
96
+ assignment_id = binding.fetch("assignment_id")
97
+ assignment = @manager.load(assignment_id)
98
+ if assignment&.managed?
99
+ journal_for.derived_attempts(assignment_id)
100
+ .find { |candidate| candidate.attempt_id == binding.fetch("attempt_id") }
101
+ else
102
+ @store.find(binding.fetch("attempt_id")) ||
103
+ recover_managed_attempt(assignment_id, binding.fetch("attempt_id"))
104
+ end
105
+ end
106
+
107
+ def reject_service_request(binding, reason:)
108
+ service_attempt(binding)
109
+ journal_for.reject_service_request(binding, reason: reason)
110
+ end
111
+
112
+ def service_request_status(request_id)
113
+ journal_for.service_request(request_id)
114
+ end
115
+
116
+ def transition_service_request(request_id, state:, receipt: nil)
117
+ if %w[succeeded failed].include?(state)
118
+ request = journal_for.service_request(request_id)
119
+ raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
120
+ validate_service_receipt!(request, state, receipt)
121
+ elsif receipt
122
+ raise AttemptErrors::ReceiptRejected, "Non-terminal service transition cannot carry a receipt"
123
+ end
124
+ journal_for.transition_service_request(request_id, state: state, receipt: receipt, validated: true)
125
+ end
126
+
127
+ # Settle a failed effect as "no effect occurred". The settlement is
128
+ # evidence-validated like a terminal receipt — the executor must
129
+ # attest this request with outcome:failed and attributable evidence
130
+ # — and the original failure event stays in the journal history.
131
+ def reconcile_service_failure(request_id, receipt:)
132
+ request = journal_for.service_request(request_id)
133
+ raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
134
+ unless %w[failed rejected uncertain].include?(request["state"]) &&
135
+ (request["state"] != "rejected" || request["consumed"] != false)
136
+ raise AttemptErrors::InvalidState,
137
+ "Only dispatched (failed, uncertain, or rejected) service requests can be reconciled"
138
+ end
139
+ settle_no_effect(request_id, request, receipt)
140
+ end
141
+
142
+ # Settle an uncertain claim whose handler was never invoked (a
143
+ # pre-dispatch refusal observed by the trusted local service). Same
144
+ # evidence rules as a failure settlement: a distinct, executor-owned
145
+ # no-effect attestation bound to this request.
146
+ def reconcile_service_no_effect(request_id, receipt:)
147
+ request = journal_for.service_request(request_id)
148
+ raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless request
149
+ unless request["state"] == "uncertain"
150
+ raise AttemptErrors::InvalidState, "Only uncertain service requests can settle as no-effect"
151
+ end
152
+ settle_no_effect(request_id, request, receipt)
153
+ end
154
+
155
+ def settle_no_effect(request_id, request, receipt)
156
+ # The settlement must be a distinct, later executor attestation:
157
+ # replaying the already-recorded failure receipt proves nothing
158
+ # about whether the effect took place.
159
+ if request["receipt"] && Atoms::EvidenceDigest.digest(receipt) ==
160
+ Atoms::EvidenceDigest.digest(request["receipt"])
161
+ raise AttemptErrors::ReceiptRejected,
162
+ "Service failure settlement requires a new attestation, not the recorded receipt"
163
+ end
164
+ validate_service_receipt!(request, "failed", receipt, require_no_effect: true)
165
+ journal_for.transition_service_request(request_id, state: "failed-settled",
166
+ receipt: receipt, validated: true)
167
+ end
168
+
169
+ def validate_service_receipt!(request, state, receipt, require_no_effect: false)
170
+ unless receipt.is_a?(Hash) && receipt.keys.sort == SERVICE_RECEIPT_FIELDS.sort
171
+ raise AttemptErrors::ReceiptRejected, "Service receipt has invalid fields"
172
+ end
173
+ SERVICE_BINDING_FIELDS.each do |key|
174
+ unless receipt[key] == request[key]
175
+ raise AttemptErrors::ReceiptRejected, "Service receipt does not match #{key}"
176
+ end
177
+ end
178
+ unless receipt["outcome"] == state && receipt["executor_uid"].is_a?(Integer) &&
179
+ receipt["executor_uid"] >= 0 && valid_service_evidence?(receipt["evidence"])
180
+ raise AttemptErrors::ReceiptRejected, "Service receipt has invalid executor or evidence"
181
+ end
182
+ unless receipt["executor_uid"] == request["executor_uid"]
183
+ raise AttemptErrors::ReceiptRejected, "Service receipt executor does not match the claimed executor"
184
+ end
185
+ # Local transport runs the executor in the submitting process, so
186
+ # the submitter must be the configured executor identity itself.
187
+ if request["transport"] == "local" && Process.uid != request["executor_uid"]
188
+ raise AttemptErrors::ReceiptRejected, "Service receipt submitter is not the configured executor"
189
+ end
190
+ verify_service_evidence!(receipt["evidence"], request, state, require_no_effect: require_no_effect)
191
+ end
192
+
193
+ def valid_service_evidence?(evidence)
194
+ evidence.is_a?(Array) && !evidence.empty? && evidence.all? do |item|
195
+ item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
196
+ item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
197
+ item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
198
+ end
199
+ end
200
+
201
+ # Evidence must live inside the candidate repository (symlinks
202
+ # resolved), be owned by — and not writable by anyone but — the
203
+ # claimed executor identity, postdate the claim, and carry a
204
+ # structured attestation naming the claimed request, input digest,
205
+ # and attested outcome. All checks read one open handle, so a swap
206
+ # on a caller-writable path cannot mix files between checks.
207
+ def verify_service_evidence!(evidence, request, state, require_no_effect: false)
208
+ repo_root = File.realpath(@repo_root)
209
+ claimed_at = parse_claimed_at(request)
210
+ evidence.each do |item|
211
+ ref = item["ref"]
212
+ unless Pathname.new(ref).relative?
213
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence must be repository-relative: #{ref}"
214
+ end
215
+ path = File.expand_path(ref, repo_root)
216
+ unless path.start_with?(repo_root + File::SEPARATOR)
217
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
218
+ end
219
+ begin
220
+ file = File.open(path, "rb")
221
+ rescue Errno::ENOENT
222
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence is unavailable: #{ref}"
223
+ end
224
+ begin
225
+ real = File.realpath(path)
226
+ unless real.start_with?(repo_root + File::SEPARATOR)
227
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence must live inside the repository: #{ref}"
228
+ end
229
+ stat = file.stat
230
+ # The open handle must still be the file at the validated
231
+ # location: a swap between resolution and open fails here.
232
+ opened = File.stat(real)
233
+ unless opened.dev == stat.dev && opened.ino == stat.ino
234
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence changed during verification: #{ref}"
235
+ end
236
+ unless stat.uid == request["executor_uid"] && (stat.mode & 0o022).zero?
237
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence is not executor-owned: #{ref}"
238
+ end
239
+ unless claimed_at.nil? || stat.mtime >= claimed_at
240
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the claim: #{ref}"
241
+ end
242
+ failed_at = parse_failed_at(request)
243
+ unless failed_at.nil? || stat.mtime >= failed_at
244
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence predates the failure: #{ref}"
245
+ end
246
+ content = file.read
247
+ unless attestation_line(content, request, state, require_no_effect: require_no_effect)
248
+ raise AttemptErrors::ReceiptRejected,
249
+ "Service receipt evidence does not bind the claimed request: #{ref}"
250
+ end
251
+ unless Digest::SHA256.hexdigest(content) == item["sha256"]
252
+ raise AttemptErrors::ReceiptRejected, "Service receipt evidence digest mismatch: #{ref}"
253
+ end
254
+ ensure
255
+ file.close
256
+ end
257
+ end
258
+ end
259
+
260
+ # The evidence artifact must carry a structured attestation line
261
+ # naming the claimed request, input digest, and attested outcome
262
+ # exactly: an unrelated executor-owned file cannot attest this
263
+ # effect, prose cannot substitute for the attested outcome, and its
264
+ # result cannot be recorded under another terminal state.
265
+ def attestation_line(content, request, state, require_no_effect: false)
266
+ attestation = /^ace-service-attestation request:#{Regexp.escape(request.fetch("request_id"))} \
267
+ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(\S+))?$/
268
+ line = content.scan(attestation).first
269
+ return false unless line
270
+ return false if require_no_effect && line[2] != "true"
271
+ %w[succeeded failed].include?(state) ? line.first == state : true
272
+ end
273
+
274
+ def parse_claimed_at(request)
275
+ return nil if request["claimed_at"].nil?
276
+ Time.iso8601(request["claimed_at"])
277
+ rescue ArgumentError
278
+ raise AttemptErrors::ReceiptRejected, "Service request has an invalid claim timestamp"
279
+ end
280
+
281
+ def parse_failed_at(request)
282
+ return nil if request["failed_at"].nil?
283
+ Time.iso8601(request["failed_at"])
284
+ rescue ArgumentError
285
+ raise AttemptErrors::ReceiptRejected, "Service request has an invalid failure timestamp"
286
+ end
287
+
288
+ # Read-only eligibility check for dry-run: applies the same review
289
+ # evidence and candidate-head requirements as the claim without
290
+ # reserving anything.
291
+ def request_eligible!(binding)
292
+ attempt = service_attempt(binding)
293
+ head = candidate_head!
294
+ valid_head = binding["candidate_head"] == head && [nil, head].include?(attempt.candidate_head)
295
+ unless valid_head
296
+ raise AttemptErrors::ReceiptRejected, "Service request candidate head is stale"
297
+ end
298
+ require_review_evidence(attempt, head) if @verifier.external_effect?(binding["operation"])
299
+ true
300
+ end
301
+
40
302
  # Start a scoped attempt for an assignment.
41
303
  #
42
304
  # A repeated identical start returns the existing active attempt; a
@@ -62,49 +324,61 @@ module Ace
62
324
  identity ||= @identity_resolver.resolve
63
325
  base_head = candidate_head!
64
326
 
65
- @store.with_lock(assignment_id) do
66
- journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
67
-
68
- existing = @store.active(assignment_id, scope) ||
69
- journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
70
- if existing
71
- if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
72
- existing.binding.actor == identity.actor && existing.binding.role == identity.role
73
- return existing
327
+ # Serialize writer registration against prune: prune holds the
328
+ # exclusive exclusion from its final evidence reads through removal.
329
+ # A worktree-backed task identity shares the same protocol (task
330
+ # key first, then assignment key — the one consistent global
331
+ # order), so a worktree prune and an attempt start cannot interleave.
332
+ start_keys = []
333
+ unless assignment.task_id.to_s.strip.empty?
334
+ start_keys << lifecycle_exclusion.task_key(assignment.task_id)
335
+ end
336
+ start_keys << lifecycle_exclusion.assignment_key(assignment_id)
337
+ lifecycle_exclusion.with_shared_multi(start_keys) do
338
+ @store.with_lock(assignment_id) do
339
+ journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
340
+
341
+ existing = @store.active(assignment_id, scope) ||
342
+ journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
343
+ if existing
344
+ if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
345
+ existing.binding.actor == identity.actor && existing.binding.role == identity.role
346
+ return existing
347
+ end
348
+
349
+ raise AttemptErrors::Conflict,
350
+ "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
351
+ "(project #{existing.binding.project_id}); refusing to launch a second writer"
74
352
  end
75
353
 
76
- raise AttemptErrors::Conflict,
77
- "Active attempt #{existing.attempt_id} already owns #{assignment_id}@#{scope} " \
78
- "(project #{existing.binding.project_id}); refusing to launch a second writer"
79
- end
354
+ active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
355
+ active_scopes.concat(journal_actives.map(&:binding))
356
+ blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
357
+ if blocker
358
+ raise AttemptErrors::Conflict,
359
+ "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
360
+ "refusing overlapping writers"
361
+ end
80
362
 
81
- active_scopes = @store.list(assignment_id).select(&:active?).map(&:binding)
82
- active_scopes.concat(journal_actives.map(&:binding))
83
- blocker = active_scopes.find { |binding| scopes_overlap?(binding.scope, scope) }
84
- if blocker
85
- raise AttemptErrors::Conflict,
86
- "Active attempt scope #{blocker.scope} overlaps requested scope #{scope} on #{assignment_id}; " \
87
- "refusing overlapping writers"
88
- end
363
+ attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
364
+ events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
365
+ attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
366
+ if attempt.managed?
367
+ commit = journal_for.append(
368
+ assignment_id: assignment_id,
369
+ attempt_id: attempt.attempt_id,
370
+ events: events
371
+ )
372
+ attempt = attempt.with(journal_commit: commit)
373
+ attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
374
+ else
375
+ attempt = attempt.with(events: events)
376
+ end
89
377
 
90
- attempt_id = unique_attempt_id(assignment_id, journal_actives.map(&:attempt_id))
91
- events = start_events(assignment_id, assignment, scope, project, identity, base_head, attempt_id)
92
- attempt = reserve(assignment, scope, project, identity, base_head, attempt_id)
93
- if attempt.managed?
94
- commit = journal_for.append(
95
- assignment_id: assignment_id,
96
- attempt_id: attempt.attempt_id,
97
- events: events
98
- )
99
- attempt = attempt.with(journal_commit: commit)
100
- attempt = yield_lost_ownership_race(attempt, assignment_id, scope)
101
- else
102
- attempt = attempt.with(events: events)
378
+ @store.save(attempt)
379
+ @store.claim(assignment_id, scope, attempt)
380
+ attempt
103
381
  end
104
-
105
- @store.save(attempt)
106
- @store.claim(assignment_id, scope, attempt)
107
- attempt
108
382
  end
109
383
  end
110
384
 
@@ -165,6 +439,61 @@ module Ace
165
439
  attempt.projection
166
440
  end
167
441
 
442
+ # Read accepted check evidence without transitions, locks, cache writes
443
+ # or audit checkout creation. Managed history comes from the Git ref.
444
+ def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
445
+ unless %w[check review-collection review-approval].include?(kind) && check_name.is_a?(String) && !check_name.strip.empty?
446
+ raise AttemptErrors::ReceiptRejected, "Invalid evidence kind or check name"
447
+ end
448
+ if !historical_head.nil? && (!%w[review-collection review-approval].include?(kind) || !historical_head.is_a?(String) ||
449
+ !historical_head.match?(/\A[0-9a-f]{40,64}\z/))
450
+ raise AttemptErrors::ReceiptRejected, "Historical evidence requires review collection/approval and an exact recorded head"
451
+ end
452
+ unless attempt_id.to_s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
453
+ receipt_digest.to_s.match?(/\A[0-9a-f]{64}\z/)
454
+ raise AttemptErrors::ReceiptRejected, "Invalid attempt ID or receipt digest"
455
+ end
456
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
457
+ raise AttemptErrors::NotFound, "Attempt #{attempt_id} not found" unless attempt
458
+ raise AttemptErrors::ReceiptRejected, "Execution evidence requires managed immutable journal history" unless attempt.managed?
459
+ attempt = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
460
+ raise AttemptErrors::ReceiptRejected, "No succeeded accepted attempt" unless attempt&.state == "succeeded"
461
+ accepted = journal_for.accepted_receipts(attempt.binding.assignment_id)
462
+ data = accepted.find { |r| r["attempt_id"] == attempt_id && r["digest"] == receipt_digest }
463
+ raise AttemptErrors::ReceiptRejected, "Receipt was not accepted by coordinator" unless data
464
+ if kind == "check" && (%w[review review-collect].include?(data["operation"]) || @verifier.external_effect?(data["operation"]))
465
+ raise AttemptErrors::ReceiptRejected, "Review or external-effect operations cannot be check evidence"
466
+ end
467
+ expected_operation = case kind
468
+ when "review-collection" then "review-collect"
469
+ when "review-approval" then "review"
470
+ else (check_name == "tests") ? "test" : check_name
471
+ end
472
+ unless data["campaign"].nil? && data["operation"] == expected_operation
473
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt operation does not prove #{kind}: #{expected_operation}"
474
+ end
475
+ required_check = (kind == "review-collection") ? "review-execution" : check_name
476
+ has_required_check = Array(data["checks"]).any? { |check| check["name"] == required_check && check["verdict"] == "passed" }
477
+ unless kind == "review-approval" || has_required_check
478
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt does not prove the required check #{required_check}"
479
+ end
480
+ receipt = Models::ExecutionReceipt.from_h(data)
481
+ unless receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
482
+ raise AttemptErrors::ReceiptRejected, "Accepted receipt digest is corrupt"
483
+ end
484
+ evidence_head = historical_head || candidate_head!
485
+ unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
486
+ raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
487
+ end
488
+ @verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root)
489
+ {"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
490
+ "historical" => !historical_head.nil?,
491
+ "operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
492
+ "artifacts" => data["artifacts"], "assignment_id" => data["assignment_id"],
493
+ "project_id" => data["project_id"], "scope" => data["scope"],
494
+ "evidence_git_ref" => attempt.binding.evidence_git_ref, "journal_commit" => attempt.journal_commit}
495
+ end
496
+
168
497
  # Reconcile an interrupted attempt.
169
498
  #
170
499
  # Running attempts are classified conservatively (stopped before
@@ -251,7 +580,7 @@ module Ace
251
580
  return nil if assignment && !assignment.managed?
252
581
 
253
582
  return journal_for.derived_attempts(assignment_id)
254
- .find { |candidate| candidate.attempt_id == attempt_id }
583
+ .find { |candidate| candidate.attempt_id == attempt_id }
255
584
  end
256
585
 
257
586
  journal_for.assignment_ids.each do |journal_assignment_id|
@@ -339,7 +668,7 @@ module Ace
339
668
  recorded = reconciler.recorded_runtime(attempt)
340
669
  unless data.dig("producer", "runtime") == recorded
341
670
  raise AttemptErrors::ReceiptRejected,
342
- "Receipt runtime #{data.dig('producer', 'runtime').inspect} does not match the recorded " \
671
+ "Receipt runtime #{data.dig("producer", "runtime").inspect} does not match the recorded " \
343
672
  "execution boundary #{recorded.inspect}"
344
673
  end
345
674
 
@@ -381,7 +710,8 @@ module Ace
381
710
  @reconciler ||= Molecules::AttemptReconciler.new(journal: journal_for, verifier: @verifier)
382
711
  end
383
712
 
384
- def reserve(assignment, scope, project, identity, base_head, attempt_id) binding = Models::AttemptBinding.new(
713
+ def reserve(assignment, scope, project, identity, base_head, attempt_id)
714
+ binding = Models::AttemptBinding.new(
385
715
  attempt_id: attempt_id,
386
716
  assignment_id: assignment.id,
387
717
  scope: scope,
@@ -531,6 +861,12 @@ module Ace
531
861
  @journal
532
862
  end
533
863
 
864
+ # Prune/writer exclusion shared with overseer prune (durable, outside
865
+ # every deletion target).
866
+ def lifecycle_exclusion
867
+ @lifecycle_exclusion ||= Molecules::LifecycleExclusion.new(repo_root: @repo_root)
868
+ end
869
+
534
870
  def candidate_head!
535
871
  out, status = Open3.capture2("git", "rev-parse", "HEAD", chdir: @repo_root, stdin_data: "")
536
872
  head = out.to_s.strip
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.58.0"
5
+ VERSION = "0.61.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-assign
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.0
4
+ version: 0.61.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0.39'
110
+ - !ruby/object:Gem::Dependency
111
+ name: ace-review
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.57'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.57'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: ace-task
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -127,14 +141,14 @@ dependencies:
127
141
  requirements:
128
142
  - - "~>"
129
143
  - !ruby/object:Gem::Version
130
- version: '0.17'
144
+ version: '0.18'
131
145
  type: :runtime
132
146
  prerelease: false
133
147
  version_requirements: !ruby/object:Gem::Requirement
134
148
  requirements:
135
149
  - - "~>"
136
150
  - !ruby/object:Gem::Version
137
- version: '0.17'
151
+ version: '0.18'
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: ace-support-test-helpers
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -294,6 +308,7 @@ files:
294
308
  - lib/ace/assign/cli/commands/add.rb
295
309
  - lib/ace/assign/cli/commands/assignment_target.rb
296
310
  - lib/ace/assign/cli/commands/attempt/base.rb
311
+ - lib/ace/assign/cli/commands/attempt/evidence.rb
297
312
  - lib/ace/assign/cli/commands/attempt/finish.rb
298
313
  - lib/ace/assign/cli/commands/attempt/reconcile.rb
299
314
  - lib/ace/assign/cli/commands/attempt/start.rb
@@ -325,6 +340,7 @@ files:
325
340
  - lib/ace/assign/molecules/evidence_journal.rb
326
341
  - lib/ace/assign/molecules/execution_identity_resolver.rb
327
342
  - lib/ace/assign/molecules/fork_session_launcher.rb
343
+ - lib/ace/assign/molecules/lifecycle_exclusion.rb
328
344
  - lib/ace/assign/molecules/preset_inferrer.rb
329
345
  - lib/ace/assign/molecules/queue_scanner.rb
330
346
  - lib/ace/assign/molecules/receipt_verifier.rb