ace-assign 0.64.0 → 0.66.1

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 +25 -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 +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -4,17 +4,22 @@ require "json"
4
4
  require "securerandom"
5
5
  require "ace/herdr/molecules/protected_native_control"
6
6
  require_relative "client"
7
+ require_relative "candidate_transfer"
8
+ require_relative "launch_control_channel"
7
9
 
8
10
  module Ace
9
11
  module Assign
10
12
  module Authority
11
13
  class LaunchDriver
14
+ LAUNCH_DEADLINE = 30
12
15
  def initialize(mapping_id:, deployment: Deployment.load,
13
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil, native: nil)
16
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil, native: nil,
17
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
14
18
  @mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
15
19
  @map = deployment.mapping(mapping_id)
16
20
  @client = client || Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
17
21
  @native = native
22
+ @clock = clock
18
23
  end
19
24
 
20
25
  def preflight
@@ -22,40 +27,62 @@ module Ace
22
27
  @client.call("launch_preflight", {}).data
23
28
  end
24
29
 
25
- def launch(assignment_id:, definition_bytes:, scope:, base_head:, mutation_id: SecureRandom.hex(16))
26
- preflight
27
- registration = @client.call("registration_status", {"assignment_id" => assignment_id}).data
30
+ def launch(assignment_id:, definition_bytes:, prepared_bundle:, scope:, base_head:, mutation_id: SecureRandom.hex(16))
31
+ state = nil
32
+ deadline = @clock.call + LAUNCH_DEADLINE
33
+ @deployment.verify!(@mapping_id, kernel: @kernel)
34
+ launch_call(deadline, "launch_preflight", {})
35
+ registration = launch_call(deadline, "registration_status", {"assignment_id" => assignment_id}).data
28
36
  digest = Digest::SHA256.hexdigest(definition_bytes)
37
+ definition = JSON.parse(definition_bytes)
38
+ prepared = definition.fetch("prepared_work")
39
+ unless definition["session_id"] == assignment_id && prepared["scope"] == scope &&
40
+ prepared_bundle.is_a?(String) && prepared_bundle.bytesize.between?(1, CandidateTransfer::MAX_BYTES)
41
+ raise ArgumentError, "prepared launch selection or bundle is invalid"
42
+ end
43
+ bundle_sha = Digest::SHA256.hexdigest(prepared_bundle)
29
44
  registered = if registration["definition_digest"] == digest
45
+ unless registration.values_at("prepared_bundle_bytes", "prepared_bundle_sha256") == [prepared_bundle.bytesize, bundle_sha]
46
+ raise AttemptErrors::Conflict, "original registration prepared bundle differs"
47
+ end
30
48
  registration
31
49
  else
32
- @client.call("register_assignment", {"assignment_id" => assignment_id,
33
- "definition_bytes" => definition_bytes, "definition_digest" => digest,
34
- "expected_generation" => registration.fetch("generation", 0)}, mutation_id: "#{mutation_id}-register").data
50
+ launch_call(deadline, "register_assignment", {"assignment_id" => assignment_id,
51
+ "definition_digest" => digest, "prepared_head" => prepared.fetch("prepared_head"), "prepared_tree" => prepared.fetch("prepared_tree"),
52
+ "manifest_sha256" => prepared.fetch("manifest_sha256"), "expected_generation" => registration.fetch("generation", 0)},
53
+ mutation_id: "#{mutation_id}-register", upload_parts: [prepared_bundle], purpose: :candidate).data
35
54
  end
36
- reserved = @client.call("reserve_attempt", {"assignment_id" => assignment_id, "scope" => scope,
55
+ reserved = launch_call(deadline, "reserve_attempt", {"assignment_id" => assignment_id, "scope" => scope,
37
56
  "worker_uid" => @map.fetch("worker_uid"), "runtime" => "herdr", "base_head" => base_head,
38
57
  "launcher_process_binding" => @kernel.capture(Process.pid),
39
- "expected_generation" => registered.fetch("definition_generation")}, mutation_id: "#{mutation_id}-reserve")
58
+ "expected_generation" => registered.fetch("definition_generation")}, mutation_id: "#{mutation_id}-reserve") { |reply| state = reply.data }
40
59
  # Only this actual CAS winner may create. A cached canonical reply is
41
60
  # evidence of ownership, never evidence that native creation is fresh.
42
61
  return reserved.data.merge("required_action" => "inspect_retained_reservation_no_creation_permission") if reserved.replayed
43
62
  state = reserved.data
44
- inspected = @client.call("inspect_launch", state.slice("assignment_id", "attempt_id")).data
63
+ inspected = launch_call(deadline, "inspect_launch", state.slice("assignment_id", "attempt_id")).data
45
64
  state = inspected
46
65
  origin = inspected.fetch("native_binding")
47
- return close_before_native_release(state, mutation_id) unless origin
66
+ return close_before_native_release(state, mutation_id, deadline: deadline) unless origin
48
67
  fixed = JSON.parse(JSON.generate(@map))
49
68
  fixed.fetch("native").merge!(origin.slice("server_identity", "socket_identity", "workspace_id"))
50
69
  @native ||= Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: fixed, kernel: @kernel)
70
+ launch_remaining!(deadline)
51
71
  binding = @native.create(mapping_id: @mapping_id, ticket: state.fetch("launch_ticket"))
52
72
  child_handle = @kernel.pin(binding.fetch("process_identity"))
53
- recorded = @client.call("record_launch", lifecycle_params(state, binding), mutation_id: "#{mutation_id}-record").data
54
- bound = @client.call("bind_process", lifecycle_params(recorded, binding), mutation_id: "#{mutation_id}-bind").data
73
+ guarded = @native.guarded_binding!(binding)
74
+ unless guarded.reject { |key, _| key == "guarded_origin" } == binding
75
+ raise AttemptErrors::EvidenceUnavailable, "original native capture changed launch binding"
76
+ end
77
+ launch_remaining!(deadline)
78
+ recorded = launch_call(deadline, "record_launch", lifecycle_params(state, binding).merge("guarded_origin" => guarded.fetch("guarded_origin")), mutation_id: "#{mutation_id}-record").data
79
+ bound = launch_call(deadline, "bind_process", lifecycle_params(recorded, binding), mutation_id: "#{mutation_id}-bind").data
55
80
  # Exact original child is reobserved after canonical bind, before release.
56
81
  @native.observe(binding.fetch("native_origin").merge("process_binding" => binding))
57
82
  @kernel.live!(binding.fetch("process_identity"))
58
- issued = @client.call("release_launch", lifecycle_params(bound, binding), mutation_id: "#{mutation_id}-release").data
83
+ issued = launch_call(deadline, "release_launch", lifecycle_params(bound, binding), mutation_id: "#{mutation_id}-release").data
84
+ @issued_state = issued
85
+ @steering_binding = binding.merge("guarded_origin" => recorded.fetch("guarded_origin"))
59
86
  issued
60
87
  rescue StandardError
61
88
  # Response loss or partial creation never retries native creation.
@@ -68,6 +95,47 @@ module Ace
68
95
  child_handle&.close
69
96
  end
70
97
 
98
+ # The same original launcher remains foreground. A transient channel
99
+ # failure reconnects this process and never repeats native issuance.
100
+ def serve_control!(state:)
101
+ unless state.equal?(@issued_state) && state["phase"] == "issued" && @steering_binding
102
+ raise AttemptErrors::EvidenceUnavailable, "Only the fresh original launcher may retain control"
103
+ end
104
+ @seen_prompt_intents ||= {}
105
+ @prompt_issuance_refs ||= {}
106
+ @reported_prompt_completions ||= {}
107
+ announced = false
108
+ until @control_cancelled
109
+ begin
110
+ break if announced && authenticated_control_release?(state)
111
+ report_retained_prompt_completions!(state)
112
+ report_retained_input_inhibition!(state)
113
+ @client.with_launch_control(state: state) do |socket, codec, ready|
114
+ if @control_original_binding_digest && ready.fetch("original_binding_digest") != @control_original_binding_digest
115
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher control binding changed"
116
+ end
117
+ @control_original_binding_digest ||= ready.fetch("original_binding_digest")
118
+ unless announced
119
+ yield({"version" => 1, "type" => "launch_ready", "mapping_id" => @mapping_id,
120
+ "assignment_id" => state.fetch("assignment_id"), "attempt_id" => state.fetch("attempt_id"),
121
+ "generation" => ready.fetch("generation"), "journal_commit" => ready.fetch("journal_commit"),
122
+ "original_binding_digest" => ready.fetch("original_binding_digest")})
123
+ announced = true
124
+ end
125
+ original_prompt_loop(socket, codec, state, ready)
126
+ end
127
+ rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable, AttemptErrors::UnauthorizedIdentity, IOError, SystemCallError
128
+ # Endpoint refusal also cannot authenticate terminal release.
129
+ # EOF does not prove the actor drained or justify a second send.
130
+ sleep(0.25) unless @control_cancelled
131
+ end
132
+ end
133
+ end
134
+
135
+ def request_control_cancel
136
+ @control_cancelled = true
137
+ end
138
+
71
139
  def terminate(state:, binding:, evidence:, mutation_id: SecureRandom.hex(16))
72
140
  observed = state["termination_observation"]
73
141
  unless observed.is_a?(Hash) && observed["status"] == "exited" && observed["process_identity"] == binding["process_identity"]
@@ -83,23 +151,234 @@ module Ace
83
151
  end
84
152
 
85
153
  private
86
- def close_before_native_release(state, mutation_id)
154
+
155
+ # Client preparation precedes its transport deadline. Recheck the one
156
+ # original budget after each reply and before subsequent native effects.
157
+ def launch_call(deadline, operation, params, **options)
158
+ remaining = launch_remaining!(deadline)
159
+ result = @client.call(operation, params, **options, timeout: remaining)
160
+ yield result if block_given?
161
+ launch_remaining!(deadline)
162
+ result
163
+ end
164
+
165
+ def launch_remaining!(deadline)
166
+ remaining = deadline - @clock.call
167
+ unless remaining.positive?
168
+ raise Ace::Runtime::RuntimeUnavailableError, "original launch deadline expired"
169
+ end
170
+ remaining
171
+ end
172
+
173
+ # Status is queried before reopening a private stream: synchronous
174
+ # canonical reads cannot consume the original channel's heartbeat wait.
175
+ def authenticated_control_release?(state)
176
+ params = state.slice("assignment_id", "attempt_id")
177
+ composition = @deployment.authority(@map.fetch("authority_id")).fetch("composition")
178
+ params["result_candidate_generation"] = nil if composition == "services"
179
+ data = @client.call("attempt_status", params, mutation_id: nil, timeout: 30).data
180
+ unless data.is_a?(Hash) && data.values_at("mapping_id", "assignment_id", "attempt_id") ==
181
+ [@mapping_id, state.fetch("assignment_id"), state.fetch("attempt_id")] &&
182
+ data["original_binding_digest"] == @control_original_binding_digest &&
183
+ data["process_binding"] == @steering_binding.reject { |key, _| key == "guarded_origin" } &&
184
+ data["launcher_identity"] == state.fetch("launcher_identity") &&
185
+ data["state"].is_a?(String) && %w[reserved running uncertain succeeded failed stopped].include?(data["state"]) &&
186
+ data["generation"].is_a?(Integer) && data["generation"] >= 0 &&
187
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
188
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher canonical status differs"
189
+ end
190
+ terminal = data.fetch("terminal_event_id")
191
+ release = data.fetch("reservation_release_event_id")
192
+ unless [terminal, release].all? { |value| value.nil? || value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) } &&
193
+ (release.nil? || !terminal.nil? && %w[succeeded failed stopped].include?(data.fetch("state")))
194
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher terminal release metadata differs"
195
+ end
196
+ %w[succeeded failed stopped].include?(data.fetch("state")) && !terminal.nil? && !release.nil?
197
+ rescue KeyError, TypeError, NoMethodError
198
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher canonical status is malformed"
199
+ end
200
+
201
+ def report_retained_prompt_completions!(state)
202
+ @seen_prompt_intents.each do |id, evidence|
203
+ next if @reported_prompt_completions[id]
204
+ next unless %w[submitted not_issued].include?(evidence["outcome"])
205
+ reference = @prompt_issuance_refs.fetch(id)
206
+ accepted = @client.call("launch_prompt_completion", state.slice("assignment_id", "attempt_id").merge(reference).merge(
207
+ "guarded_evidence" => evidence), mutation_id: nil, timeout: 30).data
208
+ unless accepted.keys.sort == %w[intent_event_id journal_commit outcome] && accepted["intent_event_id"] == id && accepted["outcome"] == evidence.fetch("outcome") &&
209
+ accepted["journal_commit"].is_a?(String) && accepted["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
210
+ raise AttemptErrors::EvidenceUnavailable, "Retained native completion acknowledgement differs"
211
+ end
212
+ @reported_prompt_completions[id] = accepted.fetch("journal_commit")
213
+ end
214
+ end
215
+ def original_prompt_loop(socket, codec, state, ready)
216
+ wire = Ace::Runtime::Molecules::ProtectedSocket
217
+ until @control_cancelled
218
+ deadline = wire.deadline(30)
219
+ frame = wire.read(socket, deadline: deadline, limit: 16_384)
220
+ if frame.is_a?(Hash) && frame.keys.sort == %w[nonce type version] && frame["type"] == "launch_control_idle" &&
221
+ frame["version"].is_a?(Integer) && frame["version"] == 1 && frame["nonce"].is_a?(String) && frame["nonce"].match?(/\A[0-9a-f]{32}\z/)
222
+ wire.write(socket, frame.merge("type" => "launch_control_idle_ack"), deadline: deadline, limit: 16_384)
223
+ next
224
+ end
225
+ if frame.is_a?(Hash) && frame["type"] == "launch_input_inhibit"
226
+ original_input_inhibit!(socket, state, ready, frame, deadline)
227
+ next
228
+ end
229
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher input is inhibited" if @input_inhibited
230
+ if frame.is_a?(Hash) && frame["type"] == "launch_review_delegate"
231
+ original_review_delegate!(socket, state, ready, frame, deadline)
232
+ next
233
+ end
234
+ LaunchControlChannel.validate_prompt!(frame)
235
+ unless frame["attempt_id"] == state.fetch("attempt_id") && frame["original_binding_digest"] == ready.fetch("original_binding_digest")
236
+ raise AttemptErrors::EvidenceUnavailable, "Private prompt does not join original launcher"
237
+ end
238
+ evidence = codec.receive_launch_prompt(socket, descriptor: frame.fetch("text_descriptor"),
239
+ transfer_id: frame.fetch("transfer_id"), deadline: deadline) do |input|
240
+ expected = @client.call("launch_prompt_intent", state.slice("assignment_id", "attempt_id").merge(
241
+ frame.slice("mutation_id", "intent_event_id", "journal_commit")), mutation_id: nil).data
242
+ binding = expected.fetch("binding")
243
+ Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(expected.fetch("origin"),
244
+ terminal_id: @steering_binding.fetch("terminal_id"), child: @steering_binding.fetch("process_identity"))
245
+ unless expected["intent_event_id"] == frame["intent_event_id"] && expected["origin"] == @steering_binding.fetch("guarded_origin") &&
246
+ binding.is_a?(Hash) && binding["text_bytes"].is_a?(Integer) &&
247
+ binding.values_at("mapping_id", "assignment_id", "attempt_id", "mutation_id") ==
248
+ [@mapping_id, state.fetch("assignment_id"), state.fetch("attempt_id"), frame.fetch("mutation_id")] &&
249
+ binding["original_binding_digest"] == frame["original_binding_digest"] && binding["text_bytes"] == input.bytes.bytesize &&
250
+ binding["text_sha256"] == Digest::SHA256.hexdigest(input.bytes)
251
+ raise AttemptErrors::EvidenceUnavailable, "Private prompt immutable intent differs"
252
+ end
253
+ id = frame.fetch("intent_event_id")
254
+ @prompt_issuance_refs[id] = frame.slice("mutation_id", "intent_event_id", "original_binding_digest")
255
+ if @seen_prompt_intents.key?(id)
256
+ @seen_prompt_intents.fetch(id)
257
+ else
258
+ @seen_prompt_intents[id] = {"outcome" => "uncertain", "origin" => @steering_binding.fetch("guarded_origin")}
259
+ @native.prompt_preflight!(@steering_binding)
260
+ @seen_prompt_intents[id] = @native.prompt(binding: @steering_binding, text: input.bytes)
261
+ end
262
+ end
263
+ result = frame.slice("mutation_id", "intent_event_id", "original_binding_digest").merge("version" => 1,
264
+ "type" => "prompt_dispatch_outcome", "guarded_evidence" => evidence)
265
+ wire.write(socket, result, deadline: deadline, limit: 16_384)
266
+ recorded = wire.read(socket, deadline: deadline, limit: 16_384)
267
+ LaunchControlChannel.validate_recorded!(recorded, frame, evidence)
268
+ @reported_prompt_completions[frame.fetch("intent_event_id")] = recorded.fetch("journal_commit") if %w[submitted not_issued].include?(evidence.fetch("outcome"))
269
+ end
270
+ end
271
+
272
+ def original_review_delegate!(socket, state, ready, frame, deadline)
273
+ LaunchControlChannel.validate_review!(frame)
274
+ unless frame["attempt_id"] == state.fetch("attempt_id") &&
275
+ frame["original_binding_digest"] == ready.fetch("original_binding_digest")
276
+ raise AttemptErrors::EvidenceUnavailable, "Review request belongs to another original launcher"
277
+ end
278
+ selectors = state.slice("assignment_id", "attempt_id").merge(
279
+ frame.slice("mutation_id", "request_event_id", "journal_commit", "original_binding_digest"))
280
+ intent = @client.call("launch_review_intent", selectors, mutation_id: nil,
281
+ timeout: review_exchange_remaining!(deadline)).data
282
+ params = validate_review_intent!(intent, state, frame)
283
+ accepted = @client.call("assign_review", params, mutation_id: "review-delegate.#{frame.fetch('request_event_id')}",
284
+ timeout: review_exchange_remaining!(deadline)).data
285
+ unless accepted.is_a?(Hash) &&
286
+ %w[candidate_generation reviewer_uid].all? { |key| accepted[key].is_a?(Integer) } &&
287
+ %w[head candidate_generation reviewer_uid reviewer_process_binding].all? { |key| accepted[key] == params.fetch(key) } &&
288
+ accepted["generation"].is_a?(Integer) && accepted["generation"] == params.fetch("expected_generation") + 1 &&
289
+ accepted["review_id"].is_a?(String) && accepted["review_id"].match?(/\A[0-9a-f]{32}\z/)
290
+ raise AttemptErrors::EvidenceUnavailable, "Original review assignment reply differs"
291
+ end
292
+ identity = accepted.fetch("reviewer_process_binding")
293
+ boot = params.fetch("reviewer_process_binding").fetch("started_at").split(":", -1)[1]
294
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
295
+ result = frame.slice("mutation_id", "request_event_id", "original_binding_digest").merge(
296
+ accepted.slice("assignment_event_id", "journal_commit"), "version" => 1, "type" => "launch_review_assigned")
297
+ LaunchControlChannel.validate_review_assigned!(result, frame)
298
+ Ace::Runtime::Molecules::ProtectedSocket.write(socket, result, deadline: deadline, limit: 16_384)
299
+ end
300
+
301
+ def validate_review_intent!(intent, state, frame)
302
+ fields = %w[assignment_params original_binding_digest request_event_id]
303
+ params = intent.is_a?(Hash) && intent["assignment_params"]
304
+ unless intent.is_a?(Hash) && intent.keys.sort == fields &&
305
+ %w[request_event_id original_binding_digest].all? { |key| intent[key] == frame.fetch(key) } &&
306
+ params.is_a?(Hash) && params.keys.sort == %w[assignment_id attempt_id candidate_generation expected_generation head mapping_id reviewer_process_binding reviewer_uid] &&
307
+ params.values_at("mapping_id", "assignment_id", "attempt_id") == [@mapping_id, state.fetch("assignment_id"), state.fetch("attempt_id")] &&
308
+ params["head"].is_a?(String) && params["head"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
309
+ %w[candidate_generation expected_generation reviewer_uid].all? { |key| params[key].is_a?(Integer) && params[key].positive? } &&
310
+ params["reviewer_uid"] != @map.fetch("worker_uid") &&
311
+ params["reviewer_process_binding"].is_a?(Hash) && params["reviewer_process_binding"]["uid"] == params["reviewer_uid"]
312
+ raise AttemptErrors::EvidenceUnavailable, "Canonical review intent differs"
313
+ end
314
+ identity = params.fetch("reviewer_process_binding")
315
+ boot = identity["started_at"].is_a?(String) && identity["started_at"].split(":", -1)[1]
316
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
317
+ params
318
+ end
319
+
320
+ def review_exchange_remaining!(deadline)
321
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
322
+ raise AttemptErrors::EvidenceUnavailable, "Original review exchange deadline expired" unless remaining.positive?
323
+ [remaining, 30].min
324
+ end
325
+
326
+ def original_input_inhibit!(socket, state, ready, frame, deadline)
327
+ LaunchControlChannel.validate_inhibit!(frame)
328
+ unless frame["attempt_id"] == state.fetch("attempt_id") && frame["original_binding_digest"] == ready.fetch("original_binding_digest")
329
+ raise AttemptErrors::EvidenceUnavailable, "Input inhibition does not join original launcher"
330
+ end
331
+ selectors = frame.slice("original_binding_digest", "seal_event_id", "journal_commit")
332
+ selected = @client.call("launch_input_inhibit_selection", state.slice("assignment_id", "attempt_id").merge(selectors), mutation_id: nil).data
333
+ unless selected == frame.slice("attempt_id", "original_binding_digest", "seal_event_id", "journal_commit")
334
+ raise AttemptErrors::EvidenceUnavailable, "Input inhibition original seal selection differs"
335
+ end
336
+ @input_inhibited = true
337
+ @input_inhibition_refs = frame.slice("original_binding_digest", "seal_event_id")
338
+ evidence = @retained_input_inhibition || @native.inhibit_input(binding: @steering_binding)
339
+ if evidence.is_a?(Hash) && evidence["outcome"] == "inhibited"
340
+ @retained_input_inhibition = evidence
341
+ end
342
+ wire = Ace::Runtime::Molecules::ProtectedSocket
343
+ result = @input_inhibition_refs.merge("version" => 1, "type" => "launch_input_inhibit_outcome", "guarded_evidence" => evidence)
344
+ wire.write(socket, result, deadline: deadline, limit: 16_384)
345
+ recorded = wire.read(socket, deadline: deadline, limit: 16_384)
346
+ LaunchControlChannel.validate_inhibit_recorded!(recorded, frame)
347
+ unless @retained_input_inhibition
348
+ raise AttemptErrors::EvidenceUnavailable, "Unconfirmed drain cannot receive positive canonical acknowledgement"
349
+ end
350
+ @reported_input_inhibition = recorded.fetch("journal_commit")
351
+ end
352
+
353
+ def report_retained_input_inhibition!(state)
354
+ return unless @retained_input_inhibition && !@reported_input_inhibition
355
+ accepted = @client.call("launch_input_inhibit_completion", state.slice("assignment_id", "attempt_id").merge(@input_inhibition_refs).merge(
356
+ "guarded_evidence" => @retained_input_inhibition), mutation_id: nil, timeout: 30).data
357
+ frame = @input_inhibition_refs.merge("version" => 1, "type" => "launch_input_inhibit")
358
+ unless accepted.is_a?(Hash) && accepted.keys.sort == %w[journal_commit original_binding_digest seal_event_id]
359
+ raise AttemptErrors::EvidenceUnavailable, "Retained input inhibition acknowledgement differs"
360
+ end
361
+ LaunchControlChannel.validate_inhibit_recorded!(accepted.merge("version" => 1, "type" => "launch_input_inhibit_recorded"), frame)
362
+ @reported_input_inhibition = accepted.fetch("journal_commit")
363
+ end
364
+
365
+ def close_before_native_release(state, mutation_id, deadline:)
87
366
  unless state["scope_binding_event_id"]
88
367
  return state.merge("required_action" => "inspect_exact_scope")
89
368
  end
90
369
  selectors = state.slice("assignment_id", "attempt_id")
91
- sealed = @client.call("close_execution_scope", selectors.merge("expected_generation" => state.fetch("generation")),
370
+ sealed = launch_call(deadline, "close_execution_scope", selectors.merge("expected_generation" => state.fetch("generation")),
92
371
  mutation_id: "#{mutation_id}-scope-seal").data
93
- closed = @client.call("close_execution_scope", selectors.merge("expected_generation" => sealed.fetch("generation")),
372
+ closed = launch_call(deadline, "close_execution_scope", selectors.merge("expected_generation" => sealed.fetch("generation")),
94
373
  mutation_id: "#{mutation_id}-scope-proof").data
95
374
  unless closed["state"] == "closed_no_writers"
96
375
  return state.merge("required_action" => "inspect_exact_scope")
97
376
  end
98
- inspected = @client.call("inspect_launch", selectors).data
377
+ inspected = launch_call(deadline, "inspect_launch", selectors).data
99
378
  failure = inspected.fetch("scope_failure_evidence")
100
379
  raise AttemptErrors::EvidenceUnavailable, "canonical scope failure selectors unavailable" unless failure
101
380
  bytes = JSON.generate(failure)
102
- @client.call("abort_launch", selectors.merge("launch_ticket" => state.fetch("launch_ticket"),
381
+ launch_call(deadline, "abort_launch", selectors.merge("launch_ticket" => state.fetch("launch_ticket"),
103
382
  "expected_generation" => inspected.fetch("generation"), "failure_evidence" => bytes,
104
383
  "failure_digest" => Digest::SHA256.hexdigest(bytes)), mutation_id: "#{mutation_id}-scope-abort").data
105
384
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ def launch_input_inhibit_selection!(request:, peer:, role:)
8
+ params = request.fetch("params")
9
+ strict!(params, %w[mapping_id assignment_id attempt_id original_binding_digest seal_event_id journal_commit])
10
+ raise ArgumentError, "Input inhibition selection requires null mutation ID" unless request.fetch("mutation_id").nil?
11
+ map = steering_principal!(params, peer, role)
12
+ raise AttemptErrors::UnauthorizedIdentity, "Input inhibition selection requires original launcher" unless role == :launcher
13
+ LaunchControlChannel.validate_inhibit!(params.slice("attempt_id", "original_binding_digest", "seal_event_id", "journal_commit").merge(
14
+ "version" => 1, "type" => "launch_input_inhibit"))
15
+ journal = journal_for(map)
16
+ commit = params.fetch("journal_commit")
17
+ journal.verify_canonical_prefix!(commit: commit)
18
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
19
+ authenticate_input_inhibition!(events, params, map, peer: peer)
20
+ {data: params.slice("attempt_id", "original_binding_digest", "seal_event_id", "journal_commit"), replayed: false}
21
+ end
22
+
23
+ def launch_input_inhibit_completion!(request:, peer:, role:)
24
+ params = request.fetch("params")
25
+ strict!(params, %w[mapping_id assignment_id attempt_id original_binding_digest seal_event_id guarded_evidence])
26
+ raise ArgumentError, "Input inhibition completion requires null mutation ID" unless request.fetch("mutation_id").nil?
27
+ map = steering_principal!(params, peer, role)
28
+ raise AttemptErrors::UnauthorizedIdentity, "Input inhibition completion requires original launcher" unless role == :launcher
29
+ {data: record_original_input_inhibition!(params, map, params.fetch("guarded_evidence"), peer: peer), replayed: false}
30
+ end
31
+
32
+ private
33
+
34
+ def request_original_input_inhibition!(params, map, journal)
35
+ selected = with_containment_exclusion(params, map, journal) do
36
+ @mutex.synchronize do
37
+ commit = journal.ref_value
38
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
39
+ next true if accepted_input_inhibition?(events, journal, commit)
40
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
41
+ # Before native release there is no admitted outside-unit input
42
+ # owner. Its existing native issuer/guarded abort gates remain.
43
+ next true unless issued_input_actor?(events)
44
+ original = original_prompt_record!(events, state, params: params)
45
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
46
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
47
+ raise AttemptErrors::EvidenceUnavailable, "Original scope is not sealed" unless lineage.seal_event
48
+ channel = @control_channels[steering_key(params, map)]
49
+ next false unless channel && !channel.closed?
50
+ frame = {"version" => 1, "type" => "launch_input_inhibit", "attempt_id" => params.fetch("attempt_id"),
51
+ "original_binding_digest" => original.fetch("binding_digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "journal_commit" => commit}
52
+ [channel, frame]
53
+ end
54
+ end
55
+ return selected if selected == true || selected == false
56
+ channel, frame = selected
57
+ channel.inhibit_input(frame: frame, deadline: wire.deadline(30))
58
+ true
59
+ rescue AttemptErrors::EvidenceUnavailable, Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
60
+ false
61
+ end
62
+
63
+ def record_original_input_inhibition!(params, map, evidence, peer: nil)
64
+ journal = journal_for(map)
65
+ with_containment_exclusion(params, map, journal) do
66
+ @mutex.synchronize do
67
+ commit = journal.ref_value
68
+ current = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
69
+ @kernel.live!(peer) if peer
70
+ original = authenticate_input_inhibition!(current, params, map, peer: peer)
71
+ journal.verify_input_drained_evidence!(evidence, original.fetch("origin"))
72
+ selection = params.slice("mapping_id", "assignment_id", "attempt_id", "original_binding_digest", "seal_event_id").merge("project_id" => map.fetch("project_id"))
73
+ journal.observe_input_inhibition(selection: selection, evidence: evidence) do |events, _old, _generation|
74
+ @kernel.live!(peer) if peer
75
+ authenticate_input_inhibition!(events, params, map, peer: peer)
76
+ end.fetch(:data).slice("original_binding_digest", "seal_event_id", "journal_commit")
77
+ end
78
+ end
79
+ end
80
+
81
+ def accepted_input_inhibition?(events, journal, commit)
82
+ proofs = events.select { |event| event["type"] == "input_inhibited" }
83
+ return false if proofs.empty?
84
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition proof is ambiguous" unless proofs.length == 1
85
+ proof = proofs.first
86
+ payload = proof.fetch("payload")
87
+ unless payload.is_a?(Hash) && payload.keys.sort == (Molecules::JournalInputInhibition::INPUT_INHIBITION_FIELDS + ["guarded_evidence"]).sort
88
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition proof fields are malformed"
89
+ end
90
+ id = "input-inhibit.#{payload.fetch('seal_event_id')}"
91
+ digest = Digest::SHA256.hexdigest(JSON.generate(journal.send(:canonical_prompt_value, payload)))
92
+ journal.send(:verify_input_inhibition!, payload, mutation_id: id, digest: digest,
93
+ assignment_id: payload.fetch("assignment_id"), attempt_id: payload.fetch("attempt_id"), commit: commit)
94
+ accepted = events.find { |event| event["type"] == "authority_mutation" && event["previous_digest"] == proof.fetch("digest") &&
95
+ event.dig("payload", "operation") == "input_inhibition_observation" && event.dig("payload", "mutation_id") == id }
96
+ expected = payload.slice("original_binding_digest", "seal_event_id")
97
+ data = accepted&.dig("payload", "data")
98
+ unless accepted && accepted.dig("payload", "parameters_digest") == digest &&
99
+ data.is_a?(Hash) && data.reject { |key, _| key == "generation" } == expected
100
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition acceptance differs"
101
+ end
102
+ true
103
+ rescue KeyError, TypeError, ArgumentError
104
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition proof is malformed"
105
+ end
106
+
107
+ def authenticate_input_inhibition!(events, params, map, peer: nil)
108
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
109
+ if peer && !@kernel.same?(state.fetch("launcher_identity"), peer)
110
+ raise AttemptErrors::UnauthorizedIdentity, "Input inhibition belongs to another launcher incarnation"
111
+ end
112
+ original = original_prompt_record!(events, state, params: params)
113
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
114
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
115
+ seal = lineage.seal_event
116
+ acceptance = seal && events.find { |event| event["type"] == "authority_mutation" && event["previous_digest"] == seal.fetch("digest") &&
117
+ %w[close_execution_scope stop_attempt].include?(event.dig("payload", "operation")) }
118
+ unless original.fetch("binding_digest") == params.fetch("original_binding_digest") && seal && acceptance &&
119
+ seal.fetch("digest") == params.fetch("seal_event_id")
120
+ raise AttemptErrors::EvidenceUnavailable, "Input inhibition does not join accepted original seal"
121
+ end
122
+ if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
123
+ raise AttemptErrors::EvidenceUnavailable, "Released scope cannot request original input inhibition"
124
+ end
125
+ original
126
+ rescue KeyError, TypeError, ArgumentError
127
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition lineage is malformed"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end