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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 494e61c0842f8930425d12aeee6c0b51b46ce491e829c19401df1296e440ca09
4
- data.tar.gz: 614e997a57ea09e7ff83d30ad372514dc0c0abceac7a809cdcb4aea1b630005a
3
+ metadata.gz: 17761502c8b23ff60844d6a3da34ddde53c776d18907a23854ad4168dec3c033
4
+ data.tar.gz: 1538f48c6aec44ac548dcc116bddc0c4928cbc876485513b616604f886f0deb0
5
5
  SHA512:
6
- metadata.gz: a83405e18f47bdf33cf83619a88b790ad6ed27ff933f8816530931a2eb571c744f8eeca5c25aded6c0c2dacdda5429aec87372aadae63b0393a17a2797d149ea
7
- data.tar.gz: c6dba5cc8f2925af84265864bbf4b54e3102f27d0707ee936d0440ec3df28e3acbf67cc7040a103c808461d33cfd77c8dd43f5092bbec6ce0fa362b1920cfc52
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
- def verify_accepted_evidence!(data, live_head:, repo_root:)
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
- # Serialize load-validate-accept so concurrent finishes observe one
396
- # consistent state and cannot persist contradictory terminal outcomes.
397
- @store.with_lock(assignment_id) do
398
- attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
399
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
400
-
401
- ensure_journal_consistent!(attempt) if attempt.managed?
402
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
403
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
404
-
405
- identity ||= @identity_resolver.resolve
406
- live_head = candidate_head!
407
-
408
- attempt = invalidate_stale_candidate(attempt, live_head)
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
- receipt = @verifier.verify!(
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
- require_review_evidence(attempt, live_head)
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
- @store.with_lock(probe.binding.assignment_id) do
515
- attempt = @store.load(probe.binding.assignment_id, attempt_id) ||
516
- recover_managed_attempt(probe.binding.assignment_id, attempt_id)
517
- raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
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
- derived = ensure_journal_consistent!(attempt, allow_uncertain: true) if attempt.managed?
520
- # The journal is authoritative for managed attempts: uncertainty
521
- # journaled after the local save drives reconciliation.
522
- attempt = attempt.with(state: derived.state) if derived&.state == "uncertain" && attempt.state == "running"
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
- if attempt.terminal?
525
- raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
526
- end
586
+ if attempt.terminal?
587
+ raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
588
+ end
527
589
 
528
- return classify_running(attempt) if attempt.state == "running"
590
+ return classify_running(attempt) if attempt.state == "running"
529
591
 
530
- identity ||= @identity_resolver.resolve
531
- resolve_uncertain(attempt, receipt_path, identity)
592
+ identity ||= @identity_resolver.resolve
593
+ resolve_uncertain(attempt, receipt_path, identity)
594
+ end
532
595
  end
533
596
  end
534
597
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Assign
5
- VERSION = "0.61.0"
5
+ VERSION = "0.62.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.61.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-02 00:00:00.000000000 Z
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