ace-assign 0.61.0 → 0.62.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/organisms/attempt_coordinator.rb +106 -43
- data/lib/ace/assign/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17761502c8b23ff60844d6a3da34ddde53c776d18907a23854ad4168dec3c033
|
|
4
|
+
data.tar.gz: 1538f48c6aec44ac548dcc116bddc0c4928cbc876485513b616604f886f0deb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f6b66a87f7a32496f11974ced9cb200c092488f027eb233dde59703ba3dcffa2eaad309d1f57b0b023113eb3f08c58e451b69823a33533d80c5fb45b79110e2
|
|
7
|
+
data.tar.gz: 6d0c8ef8b69d5002b98b60b22018fdc4aecf809818284e80bd11c70020a9733b9ba37140237b30fea470d539de23ce98a658bec902b1a6b9bf4454329ab50ed9
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.62.0] - 2026-10-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Lock-scoped external attempt authority (spec 8wq.t.34i)**: `AttemptCoordinator#with_verified_attempt` verifies that a managed attempt is live (reserved/running — uncertainty never grants external authority), bound to the exact assignment/project, and owned by the requesting actor, holding the assignment `LifecycleExclusion` shared side across the caller's whole locked transition. `finish` and `reconcile` hold the exclusive side across their terminal commits, so a liveness check can never observe an attempt as active after the terminal event landed (and vice versa). This is the authority behind ace-hitl's managed HITL request binding.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## [0.61.1] - 2026-10-02
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Historical evidence reads (`--historical-head`) validate acceptance from the append-only journal without re-hashing receipt artifacts, so campaigns that resolve findings mid-campaign (archiving and annotating the referenced files) can still finish; current-head reads keep full artifact re-verification.
|
|
20
|
+
|
|
21
|
+
|
|
10
22
|
## [0.61.0] - 2026-10-02
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -64,9 +64,15 @@ module Ace
|
|
|
64
64
|
receipt
|
|
65
65
|
end
|
|
66
66
|
# Recheck the source artifacts/checks of an already accepted receipt.
|
|
67
|
-
|
|
67
|
+
# Historical reads (recorded at a past head) validate acceptance from
|
|
68
|
+
# the append-only journal without re-hashing artifacts: the review
|
|
69
|
+
# lifecycle legitimately archives and annotates finding files after
|
|
70
|
+
# collection, and `.ace-local` working files are disposable, so their
|
|
71
|
+
# current existence proves nothing about an accepted past receipt.
|
|
72
|
+
# Current-head reads keep full artifact re-verification.
|
|
73
|
+
def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
|
|
68
74
|
verify_head(data, live_head)
|
|
69
|
-
verify_artifacts(data, repo_root)
|
|
75
|
+
verify_artifacts(data, repo_root) unless historical
|
|
70
76
|
verify_checks(data)
|
|
71
77
|
verify_review(data)
|
|
72
78
|
end
|
|
@@ -104,6 +104,29 @@ module Ace
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# The exact-authority check behind with_verified_attempt: the
|
|
108
|
+
# journal-derived managed attempt must be live (reserved/running —
|
|
109
|
+
# uncertainty is unproven liveness and never grants external
|
|
110
|
+
# authority) and bound to the exact assignment, project, and
|
|
111
|
+
# requesting actor.
|
|
112
|
+
def verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
|
|
113
|
+
attempt = journal_for.derived_attempts(assignment_id)
|
|
114
|
+
.find { |candidate| candidate.attempt_id == attempt_id }
|
|
115
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
116
|
+
|
|
117
|
+
unless attempt.managed? && %w[reserved running].include?(attempt.state) &&
|
|
118
|
+
attempt.binding.assignment_id == assignment_id &&
|
|
119
|
+
attempt.binding.project_id == project_id
|
|
120
|
+
raise AttemptErrors::ReceiptRejected,
|
|
121
|
+
"Authority request does not match an active managed attempt"
|
|
122
|
+
end
|
|
123
|
+
unless attempt.binding.actor == requester
|
|
124
|
+
raise AttemptErrors::UnauthorizedIdentity,
|
|
125
|
+
"Requester #{requester.inspect} does not own attempt '#{attempt_id}'"
|
|
126
|
+
end
|
|
127
|
+
attempt
|
|
128
|
+
end
|
|
129
|
+
|
|
107
130
|
def reject_service_request(binding, reason:)
|
|
108
131
|
service_attempt(binding)
|
|
109
132
|
journal_for.reject_service_request(binding, reason: reason)
|
|
@@ -299,6 +322,36 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
299
322
|
true
|
|
300
323
|
end
|
|
301
324
|
|
|
325
|
+
# Hold verified authority over one managed attempt for the duration
|
|
326
|
+
# of a single external transition (the HITL consume/deliver
|
|
327
|
+
# critical sections). The shared exclusion side is held across the
|
|
328
|
+
# yielded transition, so a terminal commit (finish/reconcile hold
|
|
329
|
+
# the exclusive side) or a prune cannot land between the liveness
|
|
330
|
+
# check and the caller's commit: stale, ended or replaced attempts
|
|
331
|
+
# cannot acquire new authority. Authority is attribution over
|
|
332
|
+
# journal facts and the requesting identity — never a process id,
|
|
333
|
+
# runtime marker, or caller-supplied name.
|
|
334
|
+
#
|
|
335
|
+
# @param assignment_id [String] Owning assignment ID
|
|
336
|
+
# @param attempt_id [String] Attempt ID
|
|
337
|
+
# @param project_id [String] Project the attempt must be bound to
|
|
338
|
+
# @param requester [String] Authenticated requester (execution-
|
|
339
|
+
# boundary identity of the caller, matched against binding actor)
|
|
340
|
+
# @yield [attempt] the verified managed attempt
|
|
341
|
+
# @raise [AttemptErrors::NotFound, AttemptErrors::ReceiptRejected,
|
|
342
|
+
# AttemptErrors::UnauthorizedIdentity] on any authority doubt
|
|
343
|
+
def with_verified_attempt(assignment_id:, attempt_id:, project_id:, requester:)
|
|
344
|
+
assignment_id = assignment_id.to_s
|
|
345
|
+
attempt_id = attempt_id.to_s
|
|
346
|
+
lifecycle_exclusion.with_shared(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
347
|
+
attempt = verified_attempt(
|
|
348
|
+
assignment_id: assignment_id, attempt_id: attempt_id,
|
|
349
|
+
project_id: project_id.to_s, requester: requester.to_s
|
|
350
|
+
)
|
|
351
|
+
yield attempt
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
302
355
|
# Start a scoped attempt for an assignment.
|
|
303
356
|
#
|
|
304
357
|
# A repeated identical start returns the existing active attempt; a
|
|
@@ -392,38 +445,44 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
392
445
|
data = read_receipt_file(receipt_path)
|
|
393
446
|
assignment_id = data["assignment_id"].to_s
|
|
394
447
|
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
448
|
+
# Terminal commits hold the exclusive exclusion so an external
|
|
449
|
+
# liveness reader (with_verified_attempt holds the shared side
|
|
450
|
+
# across its whole transition) can never observe the attempt as
|
|
451
|
+
# active after the terminal event landed.
|
|
452
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
|
|
453
|
+
# Serialize load-validate-accept so concurrent finishes observe one
|
|
454
|
+
# consistent state and cannot persist contradictory terminal outcomes.
|
|
455
|
+
@store.with_lock(assignment_id) do
|
|
456
|
+
attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
|
|
457
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
458
|
+
|
|
459
|
+
ensure_journal_consistent!(attempt) if attempt.managed?
|
|
460
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
|
|
461
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
|
|
462
|
+
|
|
463
|
+
identity ||= @identity_resolver.resolve
|
|
464
|
+
live_head = candidate_head!
|
|
465
|
+
|
|
466
|
+
attempt = invalidate_stale_candidate(attempt, live_head)
|
|
467
|
+
|
|
468
|
+
receipt = @verifier.verify!(
|
|
469
|
+
data,
|
|
470
|
+
attempt: attempt,
|
|
471
|
+
identity: identity,
|
|
472
|
+
live_head: live_head,
|
|
473
|
+
repo_root: @repo_root
|
|
474
|
+
)
|
|
475
|
+
if @verifier.external_effect?(receipt.operation)
|
|
476
|
+
unless attempt.managed?
|
|
477
|
+
raise AttemptErrors::InvalidState,
|
|
478
|
+
"Taskless attempts cannot record external effects without managed evidence"
|
|
479
|
+
end
|
|
409
480
|
|
|
410
|
-
|
|
411
|
-
data,
|
|
412
|
-
attempt: attempt,
|
|
413
|
-
identity: identity,
|
|
414
|
-
live_head: live_head,
|
|
415
|
-
repo_root: @repo_root
|
|
416
|
-
)
|
|
417
|
-
if @verifier.external_effect?(receipt.operation)
|
|
418
|
-
unless attempt.managed?
|
|
419
|
-
raise AttemptErrors::InvalidState,
|
|
420
|
-
"Taskless attempts cannot record external effects without managed evidence"
|
|
481
|
+
require_review_evidence(attempt, live_head)
|
|
421
482
|
end
|
|
422
483
|
|
|
423
|
-
|
|
484
|
+
accept(attempt, receipt, live_head)
|
|
424
485
|
end
|
|
425
|
-
|
|
426
|
-
accept(attempt, receipt, live_head)
|
|
427
486
|
end
|
|
428
487
|
end
|
|
429
488
|
|
|
@@ -485,7 +544,8 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
485
544
|
unless data["verdict"] == "succeeded" && data["head"] == evidence_head && attempt.candidate_head == evidence_head
|
|
486
545
|
raise AttemptErrors::ReceiptRejected, "Accepted execution evidence is stale or unsuccessful"
|
|
487
546
|
end
|
|
488
|
-
@verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root
|
|
547
|
+
@verifier.verify_accepted_evidence!(data, live_head: evidence_head, repo_root: @repo_root,
|
|
548
|
+
historical: !historical_head.nil?)
|
|
489
549
|
{"attempt_id" => attempt_id, "receipt_digest" => receipt_digest, "head" => evidence_head, "kind" => kind,
|
|
490
550
|
"historical" => !historical_head.nil?,
|
|
491
551
|
"operation" => data["operation"], "checks" => data["checks"], "producer" => data["producer"], "review" => data["review"],
|
|
@@ -511,24 +571,27 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
511
571
|
probe = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
512
572
|
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless probe
|
|
513
573
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
574
|
+
# Terminal commits hold the exclusive exclusion (see finish).
|
|
575
|
+
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(probe.binding.assignment_id)) do
|
|
576
|
+
@store.with_lock(probe.binding.assignment_id) do
|
|
577
|
+
attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
|
|
578
|
+
recover_managed_attempt(probe.binding.assignment_id, attempt_id)
|
|
579
|
+
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
518
580
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
581
|
+
derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
|
|
582
|
+
# The journal is authoritative for managed attempts: uncertainty
|
|
583
|
+
# journaled after the local save drives reconciliation.
|
|
584
|
+
attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
|
|
523
585
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
586
|
+
if attempt.terminal?
|
|
587
|
+
raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
|
|
588
|
+
end
|
|
527
589
|
|
|
528
|
-
|
|
590
|
+
return classify_running(attempt) if attempt.state == "running"
|
|
529
591
|
|
|
530
|
-
|
|
531
|
-
|
|
592
|
+
identity ||= @identity_resolver.resolve
|
|
593
|
+
resolve_uncertain(attempt, receipt_path, identity)
|
|
594
|
+
end
|
|
532
595
|
end
|
|
533
596
|
end
|
|
534
597
|
|
data/lib/ace/assign/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.62.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|