ace-assign 0.63.0 → 0.64.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
@@ -4,6 +4,7 @@ require "digest"
4
4
  require "json"
5
5
  require "open3"
6
6
  require "pathname"
7
+ require "ace/herdr"
7
8
 
8
9
  module Ace
9
10
  module Assign
@@ -45,6 +46,41 @@ module Ace
45
46
 
46
47
  attr_reader :store
47
48
 
49
+ # Read-only exact native reverse binding from accepted history. A PID
50
+ # supplied by a trusted boundary must be kernel-derived; no command
51
+ # flag, environment address or local cache grants this authority.
52
+ # Missing/dead/changed owners remain a classified refusal.
53
+ def runtime_binding(attempt_id:, caller_pid: nil)
54
+ attempt = recover_managed_attempt(nil, attempt_id)
55
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found in accepted history" unless attempt
56
+ unless attempt.managed? && attempt.active?
57
+ raise AttemptErrors::ReceiptRejected, "Native reverse requires an active managed attempt"
58
+ end
59
+ binding = reconciler.recorded_binding(attempt)
60
+ observation = reconciler.observation(attempt)
61
+ unless binding && observation["liveness"] == "live" &&
62
+ binding["process_identity"] == observation["identity"] &&
63
+ %w[runtime session pane].all? { |key| binding[key].is_a?(String) && !binding[key].empty? }
64
+ raise AttemptErrors::ReceiptRejected, "Exact native owner binding is unavailable or changed"
65
+ end
66
+ unless caller_pid.nil?
67
+ unless caller_pid.is_a?(Integer) && caller_pid.positive?
68
+ raise AttemptErrors::UnauthorizedIdentity, "Kernel caller process is unavailable"
69
+ end
70
+ caller = Ace::Runtime.resolve(binding["runtime"]).process_binding(pane: binding["pane"], caller_pid: caller_pid)
71
+ unless caller == binding
72
+ raise AttemptErrors::UnauthorizedIdentity, "Caller does not descend from the recorded native owner"
73
+ end
74
+ end
75
+ latest = recover_managed_attempt(attempt.binding.assignment_id, attempt_id)
76
+ unless latest&.active? && latest.binding.to_h == attempt.binding.to_h && reconciler.recorded_binding(latest) == binding
77
+ raise AttemptErrors::ReceiptRejected, "Native owner changed during observation"
78
+ end
79
+ binding
80
+ rescue Ace::Runtime::Error => e
81
+ raise AttemptErrors::ReceiptRejected, "Native owner observation unavailable (#{e.class})"
82
+ end
83
+
48
84
  # Claim an external service effect before dispatch. Only a managed,
49
85
  # active attempt for the exact project and candidate may own it. The
50
86
  # guard re-runs inside the journal lock per CAS attempt, so a
@@ -65,6 +101,10 @@ module Ace
65
101
  journal_for.claim_service_request(binding, state: "uncertain", guard: -> { claim_guard(binding) })
66
102
  end
67
103
 
104
+ def proposal_authorization(reference, binding)
105
+ journal_for.proposal_authorize!(reference, binding)
106
+ end
107
+
68
108
  # Rechecked against the authoritative ref for every locked claim
69
109
  # attempt: the attempt must still be journal-active and the exact
70
110
  # authorization must still be unconsumed.
@@ -228,6 +268,11 @@ module Ace
228
268
  # and attested outcome. All checks read one open handle, so a swap
229
269
  # on a caller-writable path cannot mix files between checks.
230
270
  def verify_service_evidence!(evidence, request, state, require_no_effect: false)
271
+ if @journal&.evidence_mode == :protected
272
+ receipt = request.slice(*SERVICE_BINDING_FIELDS).merge("outcome" => state, "evidence" => evidence)
273
+ @journal.validate_terminal_receipt!(request, require_no_effect ? "failed-settled" : state, receipt)
274
+ return true
275
+ end
231
276
  repo_root = File.realpath(@repo_root)
232
277
  claimed_at = parse_claimed_at(request)
233
278
  evidence.each do |item|
@@ -391,9 +436,14 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
391
436
  @store.with_lock(assignment_id) do
392
437
  journal_actives = assignment.managed? ? active_journal_attempts(assignment) : []
393
438
 
394
- existing = @store.active(assignment_id, scope) ||
395
- journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) }
439
+ existing = journal_actives.find { |attempt| Atoms::AssignmentScope.equal?(attempt.binding.scope, scope) } ||
440
+ @store.active(assignment_id, scope)
396
441
  if existing
442
+ if existing.state == "reserved"
443
+ raise AttemptErrors::Conflict,
444
+ "Unresolved reservation #{existing.attempt_id} owns #{assignment_id}@#{scope}; " \
445
+ "protected launch reconciliation needs positive no-execution/no-surviving-writer proof before abort"
446
+ end
397
447
  if existing.binding.project_id == project && existing.binding.task_id == assignment.task_id &&
398
448
  existing.binding.actor == identity.actor && existing.binding.role == identity.role
399
449
  return existing
@@ -456,7 +506,13 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
456
506
  attempt = @store.load(assignment_id, attempt_id) || recover_managed_attempt(assignment_id, attempt_id)
457
507
  raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
458
508
 
459
- ensure_journal_consistent!(attempt) if attempt.managed?
509
+ derived = ensure_journal_consistent!(attempt) if attempt.managed?
510
+ attempt = attempt.with(state: derived.state) if derived
511
+ if attempt.state == "reserved"
512
+ raise AttemptErrors::EvidenceUnavailable,
513
+ "Reservation #{attempt_id} is unbound; protected launch reconciliation requires positive " \
514
+ "no-execution/no-surviving-writer proof before abort"
515
+ end
460
516
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable" if attempt.terminal?
461
517
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is uncertain; reconcile before finishing" if attempt.uncertain?
462
518
 
@@ -498,6 +554,199 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
498
554
  attempt.projection
499
555
  end
500
556
 
557
+ # Reconstruct recovery from accepted history. Local caches and pane
558
+ # titles never override journal authority. This method has no writes.
559
+ def recovery_snapshot(assignment_id)
560
+ assignment = @manager.load(assignment_id)
561
+ managed = assignment&.managed? || journal_for.assignment_ids.include?(assignment_id)
562
+ attempts = managed ? journal_for.derived_attempts(assignment_id) : @store.list(assignment_id)
563
+ raise AssignmentErrors::NotFound, "Assignment '#{assignment_id}' not found" unless assignment || attempts.any?
564
+
565
+ requests = managed ? journal_for.service_requests(assignment_id) : []
566
+ unresolved = requests.reject do |request|
567
+ %w[succeeded failed-settled].include?(request["state"]) ||
568
+ request["state"] == "rejected" && request["consumed"] == false
569
+ end
570
+ queue = assignment && Molecules::QueueScanner.new.scan(assignment.steps_dir, assignment: assignment)
571
+ inbox_records = recovery_inboxes(attempts)
572
+ recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
573
+ observation = reconciler.observation(attempt)
574
+ blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
575
+ inbox_blocked = inbox_records.any? do |record|
576
+ [nil, attempt.attempt_id].include?(record["attempt_id"]) &&
577
+ %w[unknown claimed uncertain delivered].include?(record["state"])
578
+ end
579
+ unstarted = !reconciler.process_started?(attempt)
580
+ decision, reason = if blocked || inbox_blocked || attempt.uncertain?
581
+ ["reconcile-required", blocked ? "unresolved external effect" :
582
+ (inbox_blocked ? "inbox outcome requires signed native observation" : "attempt remains uncertain")]
583
+ elsif attempt.terminal?
584
+ ["restart-required", "attempt ended; restart requires a new attributable attempt"]
585
+ elsif unstarted
586
+ ["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
587
+ elsif !assignment || observation["liveness"] != "live"
588
+ ["reconcile-required", !assignment ? "assignment checkpoint unavailable" : observation["reason"]]
589
+ else
590
+ ["adopt", "verified owner survives; preserve current attempt"]
591
+ end
592
+ receipt_history = reconciler.events(attempt).select { |event| event["type"] == "receipt_accepted" }
593
+ .filter_map { |event| event.dig("payload", "receipt") }
594
+ accepted_effects = receipt_history.select { |receipt| @verifier.external_effect?(receipt["operation"]) }
595
+ .map { |receipt| receipt.slice("operation", "head", "digest", "verdict") }
596
+ attempt.projection.merge("decision" => decision, "recovery_reason" => reason,
597
+ "accepted_effects" => accepted_effects,
598
+ "accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
599
+ "liveness" => observation["liveness"], "last_verified_observation" =>
600
+ (observation["liveness"] == "live" ? observation : last_verified_observation(attempt)),
601
+ "observation" => observation)
602
+ end
603
+ decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
604
+ "reconcile-required"
605
+ elsif recovered.any? { |item| item["decision"] == "adopt" }
606
+ "adopt"
607
+ else
608
+ "restart-required"
609
+ end
610
+ {"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
611
+ "latest_attempt_id" => recovered.last&.dig("attempt_id"),
612
+ "decision" => decision, "liveness" => decision == "adopt" ? "live" : "unknown",
613
+ "last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
614
+ .max_by { |item| item["observed_at"].to_s },
615
+ "recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
616
+ recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
617
+ "goal" => assignment&.description || assignment&.name,
618
+ "checkpoint" => queue && {"state" => queue.assignment_state.to_s,
619
+ "active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
620
+ "attempts" => recovered, "inbox_events" => inbox_records,
621
+ "pending_hitl" => queue ? queue.steps.filter_map do |step|
622
+ id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
623
+ {"event_id" => id, "scope" => step.number, "rebind" => false} if id
624
+ end : [], "unresolved_effects" => unresolved.map do |request|
625
+ request.slice("request_id", "attempt_id", "operation", "state", "candidate_head")
626
+ end}
627
+ end
628
+
629
+ def resume(assignment_id:, dry_run: false)
630
+ return recovery_snapshot(assignment_id) if dry_run
631
+
632
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(assignment_id)) do
633
+ @store.with_lock(assignment_id) do
634
+ snapshot = recovery_snapshot(assignment_id)
635
+ snapshot.fetch("attempts").each do |projection|
636
+ attempt = recover_managed_attempt(assignment_id, projection["attempt_id"]) ||
637
+ @store.load(assignment_id, projection["attempt_id"])
638
+ next unless attempt && !attempt.terminal?
639
+
640
+ event = Models::EvidenceEvent.build(type: "recovery_observation", attempt_id: attempt.attempt_id,
641
+ payload: projection.slice("decision", "recovery_reason", "observation"),
642
+ previous_digest: last_event_digest(attempt))
643
+ attempt = append_events(attempt, [event])
644
+ if attempt.state == "running" && projection["decision"] != "adopt"
645
+ target = projection["decision"] == "restart-required" ? "stopped" : "uncertain"
646
+ attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
647
+ attempt = attempt.transition(target)
648
+ @store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
649
+ end
650
+ @store.save(attempt)
651
+ end
652
+ recovery_snapshot(assignment_id)
653
+ end
654
+ end
655
+ end
656
+
657
+ # Inbox delivery is transport evidence, never business-effect proof.
658
+ # Herdr owns signature, pinned key, generation and native identity
659
+ # verification; this consumer records only its verified references.
660
+ def reconcile_inbox(attempt_id:, event_id:, receipt_path:, inbox:, identity: nil)
661
+ identity ||= @identity_resolver.resolve
662
+ unless @identity_resolver.trusted?(identity)
663
+ raise AttemptErrors::UnauthorizedIdentity, "Inbox recovery requires a trusted supervisor"
664
+ end
665
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
666
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
667
+
668
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
669
+ @store.with_lock(attempt.binding.assignment_id) do
670
+ attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
671
+ @store.load(attempt.binding.assignment_id, attempt_id)
672
+ record = inbox.status(event: event_id)
673
+ unless record["attempt_id"] == attempt_id
674
+ raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
675
+ end
676
+ registered = reconciler.events(attempt).find do |event|
677
+ event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
678
+ end
679
+ expected = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
680
+ unless registered && registered["payload"] == expected
681
+ raise AttemptErrors::ReceiptRejected, "Inbox event does not match its registered binding"
682
+ end
683
+ bytes = File.binread(receipt_path)
684
+ receipt = JSON.parse(bytes)
685
+ result = inbox.reconcile(event: event_id, receipt: receipt,
686
+ expected_registration: registered.fetch("payload"),
687
+ signed_bytes: bytes, signature: File.binread("#{receipt_path}.sig"))
688
+ return result if result["reconciliation_refusal"]
689
+
690
+ payload = result.slice("event_id", "attempt_id", "claim_generation", "payload_sha256",
691
+ "receipt_key_sha256", "binding", "state")
692
+ .merge("receipt_sha256" => Digest::SHA256.hexdigest(bytes),
693
+ "receipt_ref" => File.expand_path(receipt_path),
694
+ "outcome" => receipt["outcome"], "observer" => receipt["observer"],
695
+ "evidence" => receipt["evidence"].slice("kind", "native_reference"))
696
+ accepted = reconciler.events(attempt).any? do |event|
697
+ event["type"] == "inbox_reconciliation" &&
698
+ event["payload"].reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) } ==
699
+ payload.reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) }
700
+ end
701
+ unless accepted
702
+ event = Models::EvidenceEvent.build(type: "inbox_reconciliation", attempt_id: attempt_id,
703
+ payload: payload, previous_digest: last_event_digest(attempt))
704
+ @store.save(append_events(attempt, [event]))
705
+ end
706
+ result
707
+ end
708
+ end
709
+ rescue JSON::ParserError, SystemCallError => e
710
+ raise AttemptErrors::ReceiptRejected, "Inbox proof unavailable (#{e.class})"
711
+ rescue Ace::Herdr::Error => e
712
+ raise AttemptErrors::ReceiptRejected, "Inbox proof rejected: #{e.message}"
713
+ end
714
+
715
+ # Register a transport reference in the existing attempt journal so
716
+ # a missing delivery record cannot be mistaken for no pending effect.
717
+ def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
718
+ identity ||= @identity_resolver.resolve
719
+ attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
720
+ raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
721
+
722
+ lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
723
+ @store.with_lock(attempt.binding.assignment_id) do
724
+ attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
725
+ @store.load(attempt.binding.assignment_id, attempt_id)
726
+ unless attempt.active? && attempt.binding.actor == identity.actor
727
+ raise AttemptErrors::UnauthorizedIdentity, "Inbox registration must belong to the active attempt owner"
728
+ end
729
+ record = inbox.status(event: event_id)
730
+ unless record["attempt_id"] == attempt_id
731
+ raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
732
+ end
733
+ payload = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
734
+ existing = reconciler.events(attempt).find do |event|
735
+ event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
736
+ end
737
+ if existing && existing["payload"] != payload
738
+ raise AttemptErrors::ReceiptRejected, "Inbox binding changed"
739
+ end
740
+ unless existing
741
+ event = Models::EvidenceEvent.build(type: "inbox_binding", attempt_id: attempt_id,
742
+ payload: payload, previous_digest: last_event_digest(attempt))
743
+ @store.save(append_events(attempt, [event]))
744
+ end
745
+ payload
746
+ end
747
+ end
748
+ end
749
+
501
750
  # Read accepted check evidence without transitions, locks, cache writes
502
751
  # or audit checkout creation. Managed history comes from the Git ref.
503
752
  def evidence(attempt_id:, receipt_digest:, kind: "check", check_name: "tests", historical_head: nil)
@@ -579,14 +828,19 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
579
828
  raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
580
829
 
581
830
  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"
831
+ # The journal is authoritative, including an unbound reservation
832
+ # that a stale local running projection must not promote.
833
+ attempt = attempt.with(state: derived.state) if derived
585
834
 
586
835
  if attempt.terminal?
587
836
  raise AttemptErrors::InvalidState, "Attempt #{attempt_id} is terminal; accepted history is immutable"
588
837
  end
589
838
 
839
+ if attempt.state == "reserved"
840
+ raise AttemptErrors::EvidenceUnavailable,
841
+ "Reservation #{attempt_id} requires protected launch reconciliation with positive no-execution/" \
842
+ "no-surviving-writer proof before abort; missing process_start is not proof"
843
+ end
590
844
  return classify_running(attempt) if attempt.state == "running"
591
845
 
592
846
  identity ||= @identity_resolver.resolve
@@ -597,6 +851,51 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
597
851
 
598
852
  private
599
853
 
854
+ def last_verified_observation(attempt)
855
+ event = reconciler.events(attempt).reverse.find do |candidate|
856
+ candidate["type"] == "recovery_observation" && candidate.dig("payload", "observation", "liveness") == "live"
857
+ end
858
+ event&.dig("payload", "observation")
859
+ end
860
+
861
+ def recovery_inboxes(attempts)
862
+ root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
863
+ entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
864
+ registrations = attempts.flat_map do |attempt|
865
+ reconciler.events(attempt).select { |event| event["type"] == "inbox_binding" }
866
+ .map { |event| [attempt, event["payload"]] }
867
+ end
868
+ records = registrations.map do |attempt, payload|
869
+ record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
870
+ attributed = record && record.inbox && record.event_id == payload["event_id"] &&
871
+ record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
872
+ record.answer_digest == payload["payload_sha256"] &&
873
+ record.inbox["receipt_key_sha256"] == payload["receipt_key_sha256"]
874
+ verified = attributed && reconciler.events(attempt).any? do |event|
875
+ next false unless event["type"] == "inbox_reconciliation"
876
+ proof = event["payload"]
877
+ proof["event_id"] == record.event_id && proof["claim_generation"] == record.inbox["claim_generation"] &&
878
+ proof["payload_sha256"] == record.answer_digest && proof["binding"] == record.inbox["binding"] &&
879
+ proof["receipt_key_sha256"] == record.inbox["receipt_key_sha256"] && proof["state"] == record.state
880
+ end
881
+ state = if !attributed || ((record.state == "completed" || record.state == "queued" && record.inbox["reconciliation"]) && !verified)
882
+ "unknown"
883
+ else
884
+ record.state
885
+ end
886
+ payload.slice("event_id", "attempt_id", "receipt_key_sha256").merge("state" => state)
887
+ rescue JSON::ParserError, ArgumentError, SystemCallError
888
+ payload.slice("event_id", "attempt_id").merge("state" => "unknown")
889
+ end
890
+ entries.each do |entry|
891
+ next if records.any? { |record| record["event_id"] == entry[:event_id] }
892
+ record = entry[:record]
893
+ next if record && (!record.inbox || !attempts.any? { |attempt| attempt.attempt_id == record.inbox["attempt_id"] })
894
+ records << {"event_id" => entry[:event_id], "state" => "unknown"}
895
+ end
896
+ records
897
+ end
898
+
600
899
  # Attempt IDs must be unique per assignment AND globally: records,
601
900
  # journal events, and reconciliation lookups key on them. Allocation
602
901
  # runs under the assignment lock, avoids journal-known IDs, and
@@ -698,12 +997,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
698
997
  when :live
699
998
  raise AttemptErrors::InvalidState,
700
999
  "Attempt #{attempt.attempt_id} process is verifiably live; reconcile after it exits"
701
- when :stopped
702
- attempt = append_events(attempt, [transition_event(attempt, "stopped", "interrupted before process start")])
703
- attempt = attempt.transition("stopped")
704
- @store.save(attempt)
705
- @store.release(attempt.binding.assignment_id, attempt.binding.scope, attempt.attempt_id)
706
- attempt
707
1000
  else
708
1001
  attempt = append_events(attempt, [transition_event(attempt, "uncertain", "effect completion cannot be proven or excluded")])
709
1002
  attempt = attempt.transition("uncertain")
@@ -809,7 +1102,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
809
1102
  "actor" => identity.actor,
810
1103
  "role" => identity.role,
811
1104
  "runtime" => identity.runtime,
812
- "pid" => Process.pid
1105
+ "pid" => identity.process_pid,
1106
+ "process_identity" => Ace::Runtime::Molecules::ProcessIdentity.new.capture(identity.process_pid),
1107
+ "runtime_binding" => identity.runtime_binding
813
1108
  },
814
1109
  previous_digest: intent["digest"]
815
1110
  )
@@ -833,6 +1128,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
833
1128
  end
834
1129
 
835
1130
  def accept(attempt, receipt, live_head, pre_events = [])
1131
+ # Reject illegal terminal outcomes before accepting any receipt.
1132
+ if %w[succeeded failed].include?(receipt.verdict)
1133
+ Atoms::AttemptStateMachine.transition!(attempt.state, receipt.verdict)
1134
+ end
836
1135
  previous = pre_events.last&.dig("digest") || last_event_digest(attempt)
837
1136
  event = Models::EvidenceEvent.build(
838
1137
  type: "receipt_accepted",