ace-assign 0.64.0 → 0.66.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -11,6 +11,10 @@ module Ace
11
11
  # Handles creation of new step files and updating existing ones,
12
12
  # including appending reports and updating frontmatter.
13
13
  class StepWriter
14
+ PRIVATE_MAX_BYTES = 1024 * 1024
15
+ def initialize(private_files: false)
16
+ @private_files = private_files
17
+ end
14
18
  # Create a new step file
15
19
  #
16
20
  # @param steps_dir [String] Path to steps directory
@@ -101,6 +105,8 @@ module Ace
101
105
 
102
106
  # Extract number and name from filename for report file
103
107
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
108
+ encoded_report = report_bytes(filename_info[:number], filename_info[:name], report_content)
109
+ validate_private_bytes!(encoded_report)
104
110
 
105
111
  # Update frontmatter only (status + completed_at)
106
112
  new_frontmatter = parsed[:frontmatter].merge({
@@ -119,7 +125,7 @@ module Ace
119
125
  )
120
126
  report_path = File.join(reports_dir, report_filename)
121
127
 
122
- write_report(report_path, filename_info[:number], filename_info[:name], report_content)
128
+ write_report(report_path, filename_info[:number], filename_info[:name], report_content, encoded: encoded_report)
123
129
 
124
130
  file_path
125
131
  end
@@ -204,8 +210,15 @@ module Ace
204
210
  # Write content atomically using temp file + rename pattern.
205
211
  # Prevents partial writes if process crashes mid-write.
206
212
  def atomic_write(path, content)
213
+ validate_private_bytes!(content)
207
214
  temp_path = "#{path}.tmp.#{Process.pid}"
208
- File.write(temp_path, content)
215
+ if @private_files
216
+ File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
217
+ file.write(content); file.flush; file.fsync
218
+ end
219
+ else
220
+ File.write(temp_path, content)
221
+ end
209
222
  File.rename(temp_path, path)
210
223
  end
211
224
 
@@ -230,15 +243,24 @@ module Ace
230
243
  # @param number [String] Step number
231
244
  # @param name [String] Step name
232
245
  # @param content [String] Report content
233
- def write_report(report_path, number, name, content)
246
+ def write_report(report_path, number, name, content, encoded: nil)
247
+ atomic_write(report_path, encoded || report_bytes(number, name, content))
248
+ end
249
+
250
+ def report_bytes(number, name, content)
234
251
  frontmatter = {
235
252
  "step" => number,
236
253
  "name" => name,
237
254
  "completed_at" => Time.now.utc.iso8601
238
255
  }
239
256
  yaml = frontmatter.to_yaml
240
- report_content = "#{yaml}---\n\n#{content}\n"
241
- atomic_write(report_path, report_content)
257
+ "#{yaml}---\n\n#{content}\n"
258
+ end
259
+
260
+ def validate_private_bytes!(content)
261
+ if @private_files && (!content.is_a?(String) || !content.bytesize.between?(1, PRIVATE_MAX_BYTES))
262
+ raise ArgumentError, "private queue output exceeds captured text bound"
263
+ end
242
264
  end
243
265
  end
244
266
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require_relative "receipt_verifier"
3
+ require_relative "canonical_evidence"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # Pure reader over already accepted terminal owner evidence. A submitted
9
+ # result alone carries no terminal authority; local filesystem artifacts
10
+ # are never substituted for its imported protected bytes.
11
+ class TerminalScopeReceipt
12
+ BINDING_FIELDS = %w[mapping_id project_id assignment_id attempt_id result_id worker_uid worker_actor
13
+ launch_ticket process_binding head candidate_generation uploaded_receipt_sha256 original_receipt_digest].freeze
14
+ PAYLOAD_FIELDS = %w[version result_id binding uploaded_receipt_sha256 original_receipt_digest receipt_digest receipt artifacts].freeze
15
+
16
+ def self.verify!(events:, lineage:, journal:, commit:, mapping:)
17
+ raise AttemptErrors::EvidenceUnavailable, "terminal receipt chain differs" unless Models::EvidenceEvent.chain_valid?(events)
18
+ terminal = events.select { |event| event["type"] == "receipt_accepted" &&
19
+ %w[succeeded failed].include?(event.dig("payload", "receipt", "verdict")) }
20
+ raise AttemptErrors::EvidenceUnavailable, "no unique accepted terminal receipt" unless terminal.size == 1
21
+ accepted = terminal.first
22
+ receipt = accepted.fetch("payload").fetch("receipt")
23
+ before = events.take_while { |event| event.fetch("digest") != accepted.fetch("digest") }
24
+ submitted = before.select { |event| event["type"] == "result_submitted" &&
25
+ event.dig("payload", "receipt_digest") == receipt.fetch("digest") }
26
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal result provenance differs" unless submitted.size == 1
27
+ payload = submitted.first.fetch("payload")
28
+ binding = payload.fetch("binding")
29
+ reservation = before.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
30
+ origin = reservation.fetch("payload").fetch("data")
31
+ expected = {"mapping_id" => origin.fetch("mapping_id"), "project_id" => mapping.fetch("project_id"),
32
+ "assignment_id" => origin.fetch("assignment_id"), "attempt_id" => accepted.fetch("attempt_id"),
33
+ "worker_uid" => mapping.fetch("worker_uid"), "worker_actor" => mapping.fetch("worker_actor"),
34
+ "launch_ticket" => origin.fetch("launch_ticket"), "process_binding" => lineage.child_event.fetch("payload").fetch("original_process_binding")}
35
+ candidates = before.select { |event| event["type"] == "authority_mutation" &&
36
+ event.dig("payload", "operation") == "submit_candidate" &&
37
+ event.dig("payload", "data", "candidate_generation") == binding.fetch("candidate_generation") }
38
+ candidate = candidates.one? && candidates.first.dig("payload", "data")
39
+ unless payload.keys.sort == PAYLOAD_FIELDS.sort && binding.keys.sort == BINDING_FIELDS.sort &&
40
+ payload["version"] == 1 && expected.all? { |key, value| binding[key] == value } &&
41
+ candidate && candidate["head"] == binding["head"] && receipt["head"] == binding["head"] &&
42
+ payload["result_id"] == binding["result_id"] && payload["uploaded_receipt_sha256"] == binding["uploaded_receipt_sha256"] &&
43
+ payload["original_receipt_digest"] == binding["original_receipt_digest"] &&
44
+ Models::ExecutionReceipt.from_h(payload.fetch("receipt")).digest_payload == Models::ExecutionReceipt.from_h(receipt).digest_payload &&
45
+ (receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
46
+ payload["artifacts"] == receipt["artifacts"] &&
47
+ receipt["producer"] == {"actor" => mapping.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"} &&
48
+ journal.canonical_attempt_state(events) == receipt.fetch("verdict")
49
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt selectors differ"
50
+ end
51
+ model = Models::ExecutionReceipt.from_h(receipt)
52
+ unless model.digest == Atoms::EvidenceDigest.digest(model.digest_payload) && model.digest == payload.fetch("receipt_digest")
53
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt digest differs"
54
+ end
55
+ finishes = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "finish" }
56
+ unless finishes.empty?
57
+ transition = events[events.index(accepted) + 1]
58
+ finish = events[events.index(accepted) + 2]
59
+ expected_reply = binding.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
60
+ "receipt_digest" => payload.fetch("receipt_digest"), "state" => receipt.fetch("verdict"))
61
+ unless finishes.one? && finishes.first == finish && transition && transition["type"] == "transition" &&
62
+ transition.fetch("payload") == {"from" => "running", "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"} &&
63
+ finish.dig("payload", "data").except("generation") == expected_reply &&
64
+ finish.dig("payload", "data", "generation") == events.take(events.index(finish) + 1).count { |event| event["type"] == "authority_mutation" }
65
+ raise AttemptErrors::EvidenceUnavailable, "accepted finish reply or terminal transition differs"
66
+ end
67
+ end
68
+ canonical = CanonicalEvidence.new(journal: journal)
69
+ context = {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
70
+ attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
71
+ request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
72
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
73
+ **context, commit: commit) }
74
+ Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
75
+ intent = before.find { |event| event["type"] == "intent" }
76
+ attempt = journal.send(:build_attempt, binding.fetch("assignment_id"), binding.fetch("attempt_id"),
77
+ intent.fetch("payload"), before, "running")
78
+ verifier = ReceiptVerifier.new(artifact_reader: reader)
79
+ verifier.verify_result!(receipt, attempt: attempt, live_head: binding.fetch("head"), repo_root: journal.repo_root)
80
+ verifier.verify_accepted_evidence!(receipt, live_head: binding.fetch("head"), repo_root: journal.repo_root, historical: true)
81
+ accepted
82
+ rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
83
+ raise AttemptErrors::EvidenceUnavailable, "authenticated terminal scope receipt unavailable"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -37,10 +37,10 @@ module Ace
37
37
  end
38
38
  end
39
39
 
40
- def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
41
- @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
42
- @queue_scanner = Molecules::QueueScanner.new
43
- @step_writer = Molecules::StepWriter.new
40
+ def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil, selected_assignment: nil, queue_scanner: nil)
41
+ @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base, selected_assignment: selected_assignment)
42
+ @queue_scanner = queue_scanner || Molecules::QueueScanner.new
43
+ @step_writer = Molecules::StepWriter.new(private_files: !selected_assignment.nil?)
44
44
  @skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
45
45
  @step_catalog = nil
46
46
  @step_catalog_from_fixture = step_catalog
@@ -4,7 +4,10 @@ require "digest"
4
4
  require "json"
5
5
  require "open3"
6
6
  require "pathname"
7
- require "ace/herdr"
7
+ require "ace/herdr/errors"
8
+ require "ace/herdr/models/delivery_record"
9
+ require "ace/herdr/molecules/delivery_record_store"
10
+ require_relative "../molecules/canonical_attempt_state"
8
11
 
9
12
  module Ace
10
13
  module Assign
@@ -46,6 +49,59 @@ module Ace
46
49
 
47
50
  attr_reader :store
48
51
 
52
+ # Pure producer plan. The protected lifecycle authenticates the original
53
+ # proof and both exhaustive owner projections anew at each CAS prefix.
54
+ # No caller receipt, cache state, native wait or store mutation enters it.
55
+ def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
56
+ Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
57
+ payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
58
+ service_evidence: service_evidence, commit: commit)
59
+ {events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
60
+ end
61
+
62
+ # The protected result owner authenticates imported bytes, candidate,
63
+ # closure and independent settlements at the same CAS prefix.
64
+ def self.finished_transition_plan(events:, receipt:)
65
+ state = Molecules::CanonicalAttemptState.derive(events)
66
+ unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
67
+ raise AttemptErrors::Conflict, "Result finish requires a running attempt"
68
+ end
69
+ Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
70
+ {events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
71
+ {type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"}}], blobs: {}}
72
+ end
73
+
74
+ def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
75
+ return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
76
+ reason = if effects_pending
77
+ "unresolved_effect"
78
+ elsif state == "uncertain"
79
+ "attempt_uncertain"
80
+ elsif !bound
81
+ "launch_unbound"
82
+ elsif !checkpoint
83
+ "checkpoint_unavailable"
84
+ elsif !live
85
+ "scope_unverifiable"
86
+ end
87
+ reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
88
+ end
89
+
90
+ def self.protected_recovery_plan(events:, decision:, reason:, observation:)
91
+ state = Molecules::CanonicalAttemptState.derive(events)
92
+ if %w[succeeded failed stopped].include?(state)
93
+ raise AttemptErrors::Conflict, "Terminal recovery is read-only"
94
+ end
95
+ planned = [{type: "recovery_observation", payload: {"decision" => decision,
96
+ "recovery_reason" => reason, "observation" => observation}}]
97
+ if state == "running" && decision != "adopt"
98
+ Atoms::AttemptStateMachine.transition!(state, "uncertain")
99
+ planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
100
+ state = "uncertain"
101
+ end
102
+ {events: planned, blobs: {}, state: state}
103
+ end
104
+
49
105
  # Read-only exact native reverse binding from accepted history. A PID
50
106
  # supplied by a trusted boundary must be kernel-derived; no command
51
107
  # flag, environment address or local cache grants this authority.
@@ -572,14 +628,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
572
628
  recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
573
629
  observation = reconciler.observation(attempt)
574
630
  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
631
  unstarted = !reconciler.process_started?(attempt)
580
- decision, reason = if blocked || inbox_blocked || attempt.uncertain?
632
+ decision, reason = if blocked || attempt.uncertain?
581
633
  ["reconcile-required", blocked ? "unresolved external effect" :
582
- (inbox_blocked ? "inbox outcome requires signed native observation" : "attempt remains uncertain")]
634
+ "attempt remains uncertain"]
583
635
  elsif attempt.terminal?
584
636
  ["restart-required", "attempt ended; restart requires a new attributable attempt"]
585
637
  elsif unstarted
@@ -657,60 +709,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
657
709
  # Inbox delivery is transport evidence, never business-effect proof.
658
710
  # Herdr owns signature, pinned key, generation and native identity
659
711
  # 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
712
 
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
713
 
715
714
  # Register a transport reference in the existing attempt journal so
716
715
  # a missing delivery record cannot be mistaken for no pending effect.
@@ -730,7 +729,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
730
729
  unless record["attempt_id"] == attempt_id
731
730
  raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
732
731
  end
733
- payload = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
732
+ payload = record.slice("event_id", "attempt_id", "payload_sha256")
734
733
  existing = reconciler.events(attempt).find do |event|
735
734
  event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
736
735
  end
@@ -859,6 +858,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
859
858
  end
860
859
 
861
860
  def recovery_inboxes(attempts)
861
+ # Ordinary local recovery selects Herdr's configuration only here;
862
+ # protected launch admission must not initialize that broad entry.
863
+ require "ace/herdr"
862
864
  root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
863
865
  entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
864
866
  registrations = attempts.flat_map do |attempt|
@@ -869,21 +871,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
869
871
  record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
870
872
  attributed = record && record.inbox && record.event_id == payload["event_id"] &&
871
873
  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)
874
+ record.answer_digest == payload["payload_sha256"]
875
+ state = attributed ? record.state : "unknown"
876
+ payload.slice("event_id", "attempt_id").merge("state" => state)
887
877
  rescue JSON::ParserError, ArgumentError, SystemCallError
888
878
  payload.slice("event_id", "attempt_id").merge("state" => "unknown")
889
879
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/bundle"
4
+ require_relative "task_assignment_creator"
5
+ require_relative "../authority/prepared_work"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Organisms
10
+ # Captures managed preparation once. This owner neither registers nor
11
+ # launches work; callers retain its exact output across uncertain replies.
12
+ class PreparedWorkBuilder
13
+ def initialize(export_root:, task_manager: nil, executor: nil, bundle_loader: nil)
14
+ @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
15
+ @executor = executor || AssignmentExecutor.new
16
+ @bundles = bundle_loader || Ace::Bundle::Organisms::BundleLoader.new
17
+ @transfer = Authority::CandidateTransfer.new(root: export_root)
18
+ end
19
+
20
+ def call(task_ref:, project_id:, dependency_reports:)
21
+ token!(project_id)
22
+ @reads = {}
23
+ @inventories = {}
24
+ tasks = closure(task_ref)
25
+ selected = tasks.fetch(task_ref)
26
+ refuse!("selected task is not a reviewed leaf") unless
27
+ %w[pending in-progress].include?(selected.status.to_s) && Array(selected.subtasks).empty?
28
+ contexts = tasks.sort.to_h do |id, task|
29
+ refuse!("task still needs review") unless task.metadata.fetch("needs_review", false) == false
30
+ [id, capture_context(task)]
31
+ end
32
+ reports = capture_reports(dependency_reports, tasks, task_ref, project_id)
33
+ result = TaskAssignmentCreator.new(task_manager: @tasks, executor: @executor)
34
+ .call(task_refs: [task_ref], primary_task_ref: task_ref, project_id: project_id)
35
+ assignment = result.fetch(:assignment)
36
+ refuse!("managed association changed") unless assignment.task_id == task_ref && assignment.project_id == project_id
37
+ job = read(assignment.source_config)
38
+ read(assignment.assignment_file)
39
+ inventory = inventory(assignment.steps_dir)
40
+ step_files = inventory.to_h { |name| [name, read(File.join(assignment.steps_dir, name))] }
41
+ roots = step_files.filter_map do |name, bytes|
42
+ fm = Atoms::StepFileParser.parse(bytes).fetch(:frontmatter)
43
+ Atoms::StepFileParser.parse_filename(name).fetch(:number) if fm["context"] == "fork" && fm["taskref"] == task_ref
44
+ end
45
+ refuse!("selected fork root is not unique") unless roots.one? && roots.first
46
+ scope = roots.first
47
+ files = {"definition.json" => JSON.generate(assignment.to_h), "job.yaml" => job}
48
+ selected_steps = step_files.filter_map do |name, bytes|
49
+ number = Atoms::StepFileParser.parse_filename(name).fetch(:number)
50
+ refuse!("unexpected queue entry") unless number && name.end_with?(".st.md")
51
+ next unless number == scope || number.start_with?(scope + ".")
52
+ files["steps/#{name}"] = bytes
53
+ record(name, bytes).merge("number" => number)
54
+ end.sort_by { |entry| number_key(entry.fetch("number")) }
55
+ context = contexts.map do |id, captured|
56
+ spec_path = "context/#{id}/spec.md"
57
+ text_path = "context/#{id}/bundle.txt"
58
+ files[spec_path], files[text_path] = captured.values_at(:spec, :text)
59
+ selected_reports = reports.fetch(id, []).map do |report|
60
+ path = "context/#{id}/reports/#{report.fetch(:assignment_id)}/#{report.fetch(:number)}.r.md"
61
+ files[path] = report.fetch(:bytes)
62
+ {"assignment_id" => report.fetch(:assignment_id), "number" => report.fetch(:number),
63
+ "file" => record(path, report.fetch(:bytes))}
64
+ end
65
+ {"uri" => "task://#{id}", "task_id" => id, "spec" => record(spec_path, files.fetch(spec_path)),
66
+ "text" => record(text_path, files.fetch(text_path)), "reports" => selected_reports}
67
+ end
68
+ manifest = {"version" => 1, "assignment_id" => assignment.id, "project_id" => project_id,
69
+ "task_id" => task_ref, "scope" => scope, "job" => record("job.yaml", job),
70
+ "steps" => selected_steps, "context" => context}
71
+ files["manifest.json"] = Authority::PreparedWork.canonical_manifest(manifest) + "\n"
72
+ work = Authority::PreparedWork.new(files: files)
73
+ current = closure(task_ref)
74
+ refuse!("task dependency inventory changed") unless current.keys.sort == tasks.keys.sort
75
+ refuse!("selected task expanded during capture") unless Array(current.fetch(task_ref).subtasks).empty?
76
+ current.each { |id, task| refuse!("task context changed") unless capture_context(task) == contexts.fetch(id) }
77
+ @inventories.each { |path, names| refuse!("input inventory changed") unless Dir.children(path).sort == names }
78
+ @reads.each { |path, bytes| refuse!("captured input changed") unless read_bytes(path) == bytes }
79
+ @transfer.export_prepared(work: work).merge("assignment_id" => assignment.id,
80
+ "scope" => scope.freeze, "selection_sha256" => work.selection_sha256).freeze
81
+ ensure
82
+ @reads = nil
83
+ @inventories = nil
84
+ end
85
+
86
+ private
87
+
88
+ def closure(ref)
89
+ found = {}; visiting = []
90
+ walk = lambda do |id|
91
+ token!(id)
92
+ refuse!("dependency cycle: #{(visiting + [id]).join(' -> ')}") if visiting.include?(id)
93
+ return if found.key?(id)
94
+ refuse!("too many task contexts") if found.size >= 256
95
+ task = @tasks.show(id)
96
+ refuse!("missing or noncanonical task") unless task && task.id == id
97
+ found[id] = task
98
+ visiting << id
99
+ Array(task.dependencies).each { |dependency| walk.call(dependency) }
100
+ visiting.pop
101
+ end
102
+ walk.call(ref)
103
+ found
104
+ end
105
+
106
+ def capture_context(task)
107
+ spec = read(task.file_path)
108
+ bundle = @bundles.load_file(task.file_path)
109
+ errors = bundle.metadata.values_at(:error, :errors, "error", "errors").flatten.compact
110
+ refuse!("unresolvable task context") unless errors.empty?
111
+ text = bounded_text(bundle.content)
112
+ # Deep-copy owner inventories; a loader may reuse mutable objects.
113
+ {spec: spec, text: text, path: task.file_path,
114
+ inventory: Marshal.dump([bundle.files, bundle.source_files])}
115
+ end
116
+
117
+ def capture_reports(selections, tasks, selected, project)
118
+ refuse!("report selections must be explicit") unless selections.is_a?(Array) && selections.size <= 4096
119
+ seen = {}; grouped = Hash.new { |hash, key| hash[key] = [] }
120
+ selections.each do |selection|
121
+ refuse!("report selector") unless selection.is_a?(Hash) && selection.keys.sort == %w[assignment_id number task_id]
122
+ id, assignment_id, number = selection.values_at("task_id", "assignment_id", "number")
123
+ token!(id); token!(assignment_id)
124
+ refuse!("report scope") unless number.is_a?(String) && number.match?(Authority::PreparedWork::SCOPE)
125
+ refuse!("report is not a dependency") unless id != selected && tasks.key?(id)
126
+ identity = [id, assignment_id, number]
127
+ refuse!("duplicate report") if seen[identity]
128
+ seen[identity] = true
129
+ assignment = @executor.assignment_manager.load(assignment_id)
130
+ refuse!("report assignment association") unless assignment && assignment.task_id == id && assignment.project_id == project
131
+ metadata = read(assignment.assignment_file)
132
+ parsed_metadata = YAML.safe_load(metadata, permitted_classes: [Time, Date])
133
+ refuse!("report assignment changed") unless parsed_metadata["session_id"] == assignment_id &&
134
+ parsed_metadata["task_id"] == id && parsed_metadata["project_id"] == project
135
+ inventory(assignment.reports_dir)
136
+ matches = inventory(assignment.steps_dir).select do |name|
137
+ name.end_with?(".st.md") && Atoms::StepFileParser.parse_filename(name)[:number] == number
138
+ end
139
+ refuse!("report step association") unless matches.one?
140
+ step = matches.first
141
+ read(File.join(assignment.steps_dir, step))
142
+ parsed = Atoms::StepFileParser.parse_filename(step)
143
+ filename = Atoms::StepFileParser.generate_report_filename(number, parsed.fetch(:name))
144
+ grouped[id] << {assignment_id: assignment_id, number: number,
145
+ bytes: read(File.join(assignment.reports_dir, filename))}
146
+ end
147
+ grouped.each_value { |entries| entries.sort_by! { |entry| [entry.fetch(:assignment_id), number_key(entry.fetch(:number))] } }
148
+ grouped
149
+ end
150
+
151
+ def record(path, bytes)
152
+ {"filename" => path, "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
153
+ end
154
+
155
+ def read(path)
156
+ bytes = read_bytes(path)
157
+ refuse!("captured input changed") if @reads.key?(path) && @reads.fetch(path) != bytes
158
+ @reads[path] = bytes
159
+ refuse!("capture inventory bound") if @reads.size > 4096 || @reads.values.sum(&:bytesize) > Authority::PreparedWork::MAX_TOTAL
160
+ bytes
161
+ end
162
+
163
+ def inventory(path)
164
+ names = Dir.children(path).sort.freeze
165
+ refuse!("capture inventory bound") if names.size > 4096
166
+ refuse!("input inventory changed") if @inventories.key?(path) && @inventories.fetch(path) != names
167
+ @inventories[path] = names
168
+ end
169
+
170
+ def read_bytes(path)
171
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
172
+ refuse!("input is not a bounded regular file") unless file.stat.file? && file.stat.size <= Authority::PreparedWork::MAX_TEXT
173
+ bounded_text(file.read(Authority::PreparedWork::MAX_TEXT + 1))
174
+ end
175
+ rescue SystemCallError
176
+ refuse!("input unavailable")
177
+ end
178
+
179
+ def bounded_text(bytes)
180
+ refuse!("input text bound") unless bytes.is_a?(String) && bytes.bytesize.between?(1, Authority::PreparedWork::MAX_TEXT)
181
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
182
+ refuse!("input encoding") unless text.valid_encoding? && !text.include?("\0")
183
+ text.freeze
184
+ end
185
+
186
+ def number_key(number) = number.split(".").map(&:to_i)
187
+ def token!(value)
188
+ refuse!("invalid identity") unless value.is_a?(String) && value.match?(Authority::PreparedWork::TOKEN)
189
+ end
190
+ def refuse!(message) = raise(AttemptErrors::ReceiptRejected, "prepared_input_invalid: #{message}")
191
+ end
192
+ end
193
+ end
194
+ end