ace-assign 0.63.1 → 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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,393 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "json"
4
+ require "securerandom"
5
+ require "ace/herdr/molecules/protected_native_control"
6
+ require_relative "client"
7
+ require_relative "candidate_transfer"
8
+ require_relative "launch_control_channel"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Authority
13
+ class LaunchDriver
14
+ LAUNCH_DEADLINE = 30
15
+ def initialize(mapping_id:, deployment: Deployment.load,
16
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil, native: nil,
17
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
18
+ @mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
19
+ @map = deployment.mapping(mapping_id)
20
+ @client = client || Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
21
+ @native = native
22
+ @clock = clock
23
+ end
24
+
25
+ def preflight
26
+ @deployment.verify!(@mapping_id, kernel: @kernel)
27
+ @client.call("launch_preflight", {}).data
28
+ end
29
+
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
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)
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
48
+ registration
49
+ else
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
54
+ end
55
+ reserved = launch_call(deadline, "reserve_attempt", {"assignment_id" => assignment_id, "scope" => scope,
56
+ "worker_uid" => @map.fetch("worker_uid"), "runtime" => "herdr", "base_head" => base_head,
57
+ "launcher_process_binding" => @kernel.capture(Process.pid),
58
+ "expected_generation" => registered.fetch("definition_generation")}, mutation_id: "#{mutation_id}-reserve") { |reply| state = reply.data }
59
+ # Only this actual CAS winner may create. A cached canonical reply is
60
+ # evidence of ownership, never evidence that native creation is fresh.
61
+ return reserved.data.merge("required_action" => "inspect_retained_reservation_no_creation_permission") if reserved.replayed
62
+ state = reserved.data
63
+ inspected = launch_call(deadline, "inspect_launch", state.slice("assignment_id", "attempt_id")).data
64
+ state = inspected
65
+ origin = inspected.fetch("native_binding")
66
+ return close_before_native_release(state, mutation_id, deadline: deadline) unless origin
67
+ fixed = JSON.parse(JSON.generate(@map))
68
+ fixed.fetch("native").merge!(origin.slice("server_identity", "socket_identity", "workspace_id"))
69
+ @native ||= Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: fixed, kernel: @kernel)
70
+ launch_remaining!(deadline)
71
+ binding = @native.create(mapping_id: @mapping_id, ticket: state.fetch("launch_ticket"))
72
+ child_handle = @kernel.pin(binding.fetch("process_identity"))
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
80
+ # Exact original child is reobserved after canonical bind, before release.
81
+ @native.observe(binding.fetch("native_origin").merge("process_binding" => binding))
82
+ @kernel.live!(binding.fetch("process_identity"))
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"))
86
+ issued
87
+ rescue StandardError
88
+ # Response loss or partial creation never retries native creation.
89
+ # Canonical state is retained by the authority. Expose its ticket if
90
+ # it was received, but do not fabricate an accepted abort/termination.
91
+ raise if state.nil?
92
+ state.slice("assignment_id", "attempt_id", "launch_ticket", "journal_commit", "generation").merge(
93
+ "phase" => "uncertain", "required_action" => "inspect_exact_native_child_and_canonical_release_state")
94
+ ensure
95
+ child_handle&.close
96
+ end
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
+
139
+ def terminate(state:, binding:, evidence:, mutation_id: SecureRandom.hex(16))
140
+ observed = state["termination_observation"]
141
+ unless observed.is_a?(Hash) && observed["status"] == "exited" && observed["process_identity"] == binding["process_identity"]
142
+ handle = @kernel.pin(binding.fetch("process_identity"))
143
+ exited = @native.terminate(binding, handle: handle)
144
+ raise AttemptErrors::EvidenceUnavailable, "native close did not prove exact child exit" unless exited
145
+ end
146
+ @client.call("abort_launch", {"assignment_id" => state.fetch("assignment_id"), "attempt_id" => state.fetch("attempt_id"),
147
+ "launch_ticket" => state.fetch("launch_ticket"), "expected_generation" => state.fetch("generation"),
148
+ "failure_evidence" => evidence, "failure_digest" => Digest::SHA256.hexdigest(evidence)}, mutation_id: mutation_id).data
149
+ ensure
150
+ handle&.close
151
+ end
152
+
153
+ private
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:)
366
+ unless state["scope_binding_event_id"]
367
+ return state.merge("required_action" => "inspect_exact_scope")
368
+ end
369
+ selectors = state.slice("assignment_id", "attempt_id")
370
+ sealed = launch_call(deadline, "close_execution_scope", selectors.merge("expected_generation" => state.fetch("generation")),
371
+ mutation_id: "#{mutation_id}-scope-seal").data
372
+ closed = launch_call(deadline, "close_execution_scope", selectors.merge("expected_generation" => sealed.fetch("generation")),
373
+ mutation_id: "#{mutation_id}-scope-proof").data
374
+ unless closed["state"] == "closed_no_writers"
375
+ return state.merge("required_action" => "inspect_exact_scope")
376
+ end
377
+ inspected = launch_call(deadline, "inspect_launch", selectors).data
378
+ failure = inspected.fetch("scope_failure_evidence")
379
+ raise AttemptErrors::EvidenceUnavailable, "canonical scope failure selectors unavailable" unless failure
380
+ bytes = JSON.generate(failure)
381
+ launch_call(deadline, "abort_launch", selectors.merge("launch_ticket" => state.fetch("launch_ticket"),
382
+ "expected_generation" => inspected.fetch("generation"), "failure_evidence" => bytes,
383
+ "failure_digest" => Digest::SHA256.hexdigest(bytes)), mutation_id: "#{mutation_id}-scope-abort").data
384
+ end
385
+
386
+ def lifecycle_params(state, binding)
387
+ state.slice("assignment_id", "attempt_id", "launch_ticket").merge("process_binding" => binding,
388
+ "expected_generation" => state.fetch("generation"))
389
+ end
390
+ end
391
+ end
392
+ end
393
+ 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