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
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require_relative "transfer_codec"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # A rendezvous for one already authenticated original launcher stream.
9
+ # Canonical issue permits belong to LaunchLifecycle/JournalMutation.
10
+ # This queue owns no replay, authorization, terminal state or effects.
11
+ class LaunchControlChannel
12
+ Pending = Struct.new(:frame, :bytes, :deadline, :result, :error, :finished, keyword_init: true)
13
+ PROMPT_FIELDS = %w[attempt_id intent_event_id journal_commit mutation_id original_binding_digest text_descriptor transfer_id type version].freeze
14
+ OUTCOME_FIELDS = %w[guarded_evidence intent_event_id mutation_id original_binding_digest type version].freeze
15
+ RECORDED_FIELDS = %w[intent_event_id journal_commit mutation_id original_binding_digest outcome type version].freeze
16
+ REVIEW_FIELDS = %w[attempt_id journal_commit mutation_id original_binding_digest request_event_id type version].freeze
17
+ REVIEW_ASSIGNED_FIELDS = %w[assignment_event_id journal_commit mutation_id original_binding_digest request_event_id type version].freeze
18
+ INHIBIT_FIELDS = %w[attempt_id journal_commit original_binding_digest seal_event_id type version].freeze
19
+ INHIBIT_OUTCOME_FIELDS = %w[guarded_evidence original_binding_digest seal_event_id type version].freeze
20
+ INHIBIT_RECORDED_FIELDS = %w[journal_commit original_binding_digest seal_event_id type version].freeze
21
+
22
+ def initialize(socket:, codec:, outcome:, input_inhibition: nil)
23
+ @socket, @codec, @outcome = socket, codec, outcome
24
+ @input_inhibition = input_inhibition
25
+ @mutex, @changed = Mutex.new, ConditionVariable.new
26
+ @pending = nil
27
+ @closed = false
28
+ end
29
+
30
+ def reserve_dispatch!
31
+ @mutex.synchronize do
32
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher control channel is unavailable" if @closed
33
+ raise AttemptErrors::Conflict, "Original launcher input is inhibited" if @inhibiting
34
+ raise AttemptErrors::Conflict, "Original launcher control channel is busy" if @reservation || @pending
35
+ @reservation = Object.new.freeze
36
+ end
37
+ end
38
+
39
+ def inhibit_input(frame:, deadline:)
40
+ self.class.validate_inhibit!(frame)
41
+ pending = Pending.new(frame: frame, deadline: deadline, finished: false)
42
+ @mutex.synchronize do
43
+ @inhibiting = true
44
+ if @inhibition_pending
45
+ unless @inhibition_pending.frame.slice("attempt_id", "original_binding_digest", "seal_event_id") == frame.slice("attempt_id", "original_binding_digest", "seal_event_id")
46
+ raise AttemptErrors::Conflict, "Original input inhibition names another seal"
47
+ end
48
+ pending = @inhibition_pending
49
+ else
50
+ @inhibition_pending = pending
51
+ while @reservation || @pending
52
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher control channel is unavailable" if @closed
53
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition is uncertain" unless remaining.positive?
55
+ @changed.wait(@mutex, remaining)
56
+ end
57
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher control channel is unavailable" if @closed
58
+ @pending = pending
59
+ @changed.broadcast
60
+ end
61
+ until pending.finished
62
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition is uncertain" unless remaining.positive?
64
+ @changed.wait(@mutex, remaining)
65
+ end
66
+ end
67
+ raise pending.error if pending.error
68
+ pending.result
69
+ rescue AttemptErrors::EvidenceUnavailable
70
+ close
71
+ raise
72
+ end
73
+
74
+ def dispatch_reserved?(reservation)
75
+ @mutex.synchronize { !@closed && !reservation.nil? && @reservation.equal?(reservation) }
76
+ end
77
+
78
+ def release_dispatch!(reservation)
79
+ @mutex.synchronize do
80
+ @reservation = nil if @reservation.equal?(reservation) && !@pending
81
+ @changed.broadcast if @inhibiting
82
+ end
83
+ end
84
+
85
+ def dispatch_prompt(frame:, bytes:, deadline:, reservation:)
86
+ validate_prompt!(frame)
87
+ dispatch_pending(Pending.new(frame: frame, bytes: bytes, deadline: deadline, finished: false), reservation)
88
+ end
89
+
90
+ def dispatch_review(frame:, deadline:, reservation:)
91
+ self.class.validate_review!(frame)
92
+ dispatch_pending(Pending.new(frame: frame, deadline: deadline, finished: false), reservation)
93
+ end
94
+
95
+ def dispatch_pending(pending, reservation)
96
+ @mutex.synchronize do
97
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher control channel is unavailable" if @closed
98
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher dispatch capacity is not held" unless reservation && @reservation.equal?(reservation)
99
+ raise AttemptErrors::Conflict, "Original launcher control channel is busy" if @pending
100
+ @pending = pending
101
+ @changed.broadcast
102
+ until pending.finished
103
+ remaining = pending.deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
+ raise AttemptErrors::EvidenceUnavailable, "Original launcher dispatch outcome is uncertain" unless remaining.positive?
105
+ @changed.wait(@mutex, remaining)
106
+ end
107
+ end
108
+ raise pending.error if pending.error
109
+ pending.result
110
+ end
111
+
112
+ private :dispatch_pending
113
+
114
+ # Only the existing Server connection handler calls serve. No slot,
115
+ # assignment, journal or channel mutex is held over a socket wait or
116
+ # the original owner's canonical outcome ingestion callback.
117
+ def serve
118
+ loop do
119
+ pending = @mutex.synchronize do
120
+ @changed.wait(@mutex, 1) unless @closed || @pending
121
+ break if @closed
122
+ @pending
123
+ end
124
+ break if closed?
125
+ unless pending
126
+ ping
127
+ next
128
+ end
129
+ begin
130
+ wire.write(@socket, pending.frame, deadline: pending.deadline, limit: 16_384)
131
+ if pending.frame.fetch("type") == "launch_review_delegate"
132
+ result = wire.read(@socket, deadline: pending.deadline, limit: 16_384)
133
+ self.class.validate_review_assigned!(result, pending.frame)
134
+ # This transport correlation is not assignment acceptance.
135
+ # The canonical request owner must authenticate the returned
136
+ # event/commit before recording the public response.
137
+ complete(pending, result: result)
138
+ next
139
+ end
140
+ if pending.frame.fetch("type") == "launch_input_inhibit"
141
+ result = wire.read(@socket, deadline: pending.deadline, limit: 16_384)
142
+ self.class.validate_inhibit_outcome!(result, pending.frame)
143
+ raise AttemptErrors::EvidenceUnavailable, "Canonical input inhibition owner unavailable" unless @input_inhibition
144
+ accepted = @input_inhibition.call(result)
145
+ unless accepted.is_a?(Hash) && accepted.keys.sort == %w[journal_commit original_binding_digest seal_event_id] &&
146
+ %w[original_binding_digest seal_event_id].all? { |key| accepted[key] == pending.frame.fetch(key) }
147
+ raise AttemptErrors::EvidenceUnavailable, "Canonical input inhibition acceptance differs"
148
+ end
149
+ recorded = pending.frame.slice("original_binding_digest", "seal_event_id").merge("version" => 1,
150
+ "type" => "launch_input_inhibit_recorded", "journal_commit" => accepted.fetch("journal_commit"))
151
+ self.class.validate_inhibit_recorded!(recorded, pending.frame)
152
+ wire.write(@socket, recorded, deadline: pending.deadline, limit: 16_384)
153
+ complete(pending, result: result)
154
+ next
155
+ end
156
+ @codec.send_launch_prompt(@socket, bytes: pending.bytes, descriptor: pending.frame.fetch("text_descriptor"),
157
+ transfer_id: pending.frame.fetch("transfer_id"), deadline: pending.deadline)
158
+ pending.bytes = nil
159
+ result = wire.read(@socket, deadline: pending.deadline, limit: 16_384)
160
+ validate_outcome!(result, pending.frame)
161
+ accepted = @outcome.call(result)
162
+ unless accepted.is_a?(Hash) && accepted.keys.sort == %w[journal_commit outcome] &&
163
+ accepted["outcome"] == result.dig("guarded_evidence", "outcome") &&
164
+ accepted["journal_commit"].is_a?(String) && accepted["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
165
+ raise AttemptErrors::EvidenceUnavailable, "Canonical prompt completion acknowledgement is unavailable"
166
+ end
167
+ recorded = pending.frame.slice("mutation_id", "intent_event_id", "original_binding_digest").merge(accepted).merge(
168
+ "version" => 1, "type" => "prompt_outcome_recorded")
169
+ wire.write(@socket, recorded, deadline: pending.deadline, limit: 16_384)
170
+ complete(pending, result: result)
171
+ rescue StandardError => error
172
+ complete(pending, error: error)
173
+ break
174
+ end
175
+ end
176
+ rescue IOError, SystemCallError, Ace::Runtime::RuntimeUnavailableError
177
+ nil
178
+ ensure
179
+ close
180
+ end
181
+
182
+ def close
183
+ @mutex.synchronize do
184
+ @closed = true
185
+ @reservation = nil
186
+ if @pending && !@pending.finished
187
+ @pending.error = AttemptErrors::EvidenceUnavailable.new("Original launcher control channel closed with uncertain outcome")
188
+ @pending.finished = true
189
+ @pending.bytes = nil
190
+ end
191
+ if @inhibition_pending && !@inhibition_pending.finished
192
+ @inhibition_pending.error = AttemptErrors::EvidenceUnavailable.new("Original input inhibition is uncertain")
193
+ @inhibition_pending.finished = true
194
+ end
195
+ @changed.broadcast
196
+ end
197
+ end
198
+
199
+ def closed?
200
+ @mutex.synchronize { @closed }
201
+ end
202
+
203
+ def self.validate_review!(frame)
204
+ unless frame.is_a?(Hash) && frame.keys.sort == REVIEW_FIELDS &&
205
+ frame["version"].is_a?(Integer) && frame["version"] == 1 && frame["type"] == "launch_review_delegate" &&
206
+ %w[attempt_id mutation_id].all? { |key| frame[key].is_a?(String) && frame[key].match?(Molecules::JournalMutation::ID) } &&
207
+ %w[request_event_id original_binding_digest].all? { |key| frame[key].is_a?(String) && frame[key].match?(/\A[0-9a-f]{64}\z/) } &&
208
+ frame["journal_commit"].is_a?(String) && frame["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
209
+ raise AttemptErrors::EvidenceUnavailable, "Private review delegation differs"
210
+ end
211
+ true
212
+ end
213
+
214
+ def self.validate_review_assigned!(result, frame)
215
+ unless result.is_a?(Hash) && result.keys.sort == REVIEW_ASSIGNED_FIELDS &&
216
+ result["version"].is_a?(Integer) && result["version"] == 1 && result["type"] == "launch_review_assigned" &&
217
+ %w[mutation_id request_event_id original_binding_digest].all? { |key| result[key] == frame.fetch(key) } &&
218
+ result["assignment_event_id"].is_a?(String) && result["assignment_event_id"].match?(/\A[0-9a-f]{64}\z/) &&
219
+ result["journal_commit"].is_a?(String) && result["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
220
+ raise AttemptErrors::EvidenceUnavailable, "Private review assignment does not join original request"
221
+ end
222
+ true
223
+ end
224
+
225
+ def self.validate_prompt!(frame)
226
+ unless frame.is_a?(Hash) && frame.keys.all? { |key| key.is_a?(String) } && frame.keys.sort == PROMPT_FIELDS &&
227
+ frame["version"].is_a?(Integer) && frame["version"] == 1 && frame["type"] == "prompt_dispatch" &&
228
+ %w[intent_event_id original_binding_digest].all? { |key| frame[key].is_a?(String) && frame[key].match?(/\A[0-9a-f]{64}\z/) } &&
229
+ frame["journal_commit"].is_a?(String) && frame["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
230
+ %w[attempt_id mutation_id].all? { |key| frame[key].is_a?(String) && frame[key].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) } &&
231
+ frame["transfer_id"].is_a?(String) && frame["transfer_id"].match?(/\A[0-9a-f]{32}\z/)
232
+ raise AttemptErrors::EvidenceUnavailable, "Private prompt dispatch frame is malformed"
233
+ end
234
+ TransferCodec.new.validate!(frame.fetch("text_descriptor"), :prompt_text)
235
+ true
236
+ end
237
+
238
+ def self.validate_recorded!(recorded, frame, evidence)
239
+ unless recorded.is_a?(Hash) && recorded.keys.sort == RECORDED_FIELDS && recorded["version"].is_a?(Integer) && recorded["version"] == 1 &&
240
+ recorded["type"] == "prompt_outcome_recorded" && recorded["outcome"] == evidence.fetch("outcome") &&
241
+ %w[mutation_id intent_event_id original_binding_digest].all? { |key| recorded[key] == frame.fetch(key) } &&
242
+ recorded["journal_commit"].is_a?(String) && recorded["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
243
+ raise AttemptErrors::EvidenceUnavailable, "Canonical prompt completion acknowledgement differs"
244
+ end
245
+ true
246
+ end
247
+
248
+ def self.validate_inhibit!(frame)
249
+ unless frame.is_a?(Hash) && frame.keys.sort == INHIBIT_FIELDS && frame["version"].is_a?(Integer) && frame["version"] == 1 &&
250
+ frame["type"] == "launch_input_inhibit" && frame["attempt_id"].is_a?(String) && frame["attempt_id"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
251
+ %w[original_binding_digest seal_event_id].all? { |key| frame[key].is_a?(String) && frame[key].match?(/\A[0-9a-f]{64}\z/) } &&
252
+ frame["journal_commit"].is_a?(String) && frame["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
253
+ raise AttemptErrors::EvidenceUnavailable, "Private input inhibition frame is malformed"
254
+ end
255
+ true
256
+ end
257
+
258
+ def self.validate_inhibit_outcome!(result, frame)
259
+ unless result.is_a?(Hash) && result.keys.sort == INHIBIT_OUTCOME_FIELDS && result["version"].is_a?(Integer) && result["version"] == 1 &&
260
+ result["type"] == "launch_input_inhibit_outcome" && result["guarded_evidence"].is_a?(Hash) &&
261
+ %w[original_binding_digest seal_event_id].all? { |key| result[key] == frame.fetch(key) }
262
+ raise AttemptErrors::EvidenceUnavailable, "Private input inhibition outcome differs"
263
+ end
264
+ true
265
+ end
266
+
267
+ def self.validate_inhibit_recorded!(result, frame)
268
+ unless result.is_a?(Hash) && result.keys.sort == INHIBIT_RECORDED_FIELDS && result["version"].is_a?(Integer) && result["version"] == 1 &&
269
+ result["type"] == "launch_input_inhibit_recorded" && %w[original_binding_digest seal_event_id].all? { |key| result[key] == frame.fetch(key) } &&
270
+ result["journal_commit"].is_a?(String) && result["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
271
+ raise AttemptErrors::EvidenceUnavailable, "Canonical input inhibition acknowledgement differs"
272
+ end
273
+ true
274
+ end
275
+
276
+ private
277
+
278
+ def validate_prompt!(frame) = self.class.validate_prompt!(frame)
279
+
280
+ def validate_outcome!(result, frame)
281
+ unless result.is_a?(Hash) && result.keys.all? { |key| key.is_a?(String) } && result.keys.sort == OUTCOME_FIELDS &&
282
+ result["version"].is_a?(Integer) && result["version"] == 1 && result["type"] == "prompt_dispatch_outcome" &&
283
+ %w[mutation_id intent_event_id original_binding_digest].all? { |key| result[key] == frame.fetch(key) } &&
284
+ result["guarded_evidence"].is_a?(Hash)
285
+ raise AttemptErrors::EvidenceUnavailable, "Private prompt outcome does not join original dispatch"
286
+ end
287
+ end
288
+
289
+ def complete(pending, result: nil, error: nil)
290
+ @mutex.synchronize do
291
+ @closed = true if error
292
+ pending.result, pending.error, pending.finished = result, error, true
293
+ pending.bytes = nil
294
+ @pending = nil if @pending.equal?(pending)
295
+ @reservation = nil
296
+ @changed.broadcast
297
+ end
298
+ end
299
+
300
+ def ping
301
+ nonce, deadline = SecureRandom.hex(16), wire.deadline(5)
302
+ wire.write(@socket, {"version" => 1, "type" => "launch_control_idle", "nonce" => nonce}, deadline: deadline, limit: 16_384)
303
+ result = wire.read(@socket, deadline: deadline, limit: 16_384)
304
+ unless result.is_a?(Hash) && result["version"].is_a?(Integer) && result == {"version" => 1, "type" => "launch_control_idle_ack", "nonce" => nonce}
305
+ raise AttemptErrors::EvidenceUnavailable, "Private launcher idle response differs"
306
+ end
307
+ end
308
+
309
+ def wire = Ace::Runtime::Molecules::ProtectedSocket
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../molecules/protected_control_exclusion"
3
+ require_relative "campaign_execution"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ class LaunchLifecycle
9
+ private
10
+
11
+ def control_registration_context!(params, map, journal, commit: journal.ref_value)
12
+ assignment = params.fetch("assignment_id")
13
+ mapping = params.fetch("mapping_id")
14
+ inventory = if commit.nil?
15
+ unless params.key?("definition_bytes") && journal.ref_value.nil?
16
+ raise AttemptErrors::EvidenceUnavailable, "Original control canonical ref is unavailable"
17
+ end
18
+ # Existing first-registration bootstrap. The lower CAS initializes
19
+ # and authenticates its seed prefix before accepting any event.
20
+ {"commit" => nil, "events" => {}, "introductions" => {}}
21
+ else
22
+ journal.canonical_event_inventory!(commit: commit)
23
+ end
24
+ unless inventory.fetch("commit") == commit
25
+ raise AttemptErrors::EvidenceUnavailable, "Control canonical snapshot differs"
26
+ end
27
+ events = inventory.fetch("events").fetch(assignment, [])
28
+ introductions = inventory.fetch("introductions").fetch(assignment, {})
29
+ registrations = events.select { |event| event["type"] == "authority_mutation" &&
30
+ event.dig("payload", "operation") == "register_assignment" }
31
+ first = registrations.first&.dig("payload", "data")
32
+ registration = registrations.last&.dig("payload", "data")
33
+ if params["attempt_id"]
34
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
35
+ reserves = chain.select { |event| event.dig("payload", "operation") == "reserve_attempt" }
36
+ unless reserves.one? && reserves.first.dig("payload", "data", "mapping_id") == mapping
37
+ raise AttemptErrors::EvidenceUnavailable, "Original control reservation is unavailable"
38
+ end
39
+ prefix = introductions.fetch(reserves.first.fetch("digest"))
40
+ registration = definition(journal, assignment, commit: prefix)
41
+ end
42
+ unless registration || params.key?("definition_bytes")
43
+ raise AttemptErrors::NotFound, "canonical assignment registration is missing"
44
+ end
45
+ selection = first&.fetch("lifecycle_control")
46
+ selected = selection ? control_original_descriptor!(selection) : @deployment
47
+ original_map = selected.mapping(mapping)
48
+ project = selected.project(original_map.fetch("project_id"))
49
+ unless original_map.fetch("project_id") == map.fetch("project_id") &&
50
+ project.values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
51
+ [journal.repo_root, journal.ref, journal.checkout_root]
52
+ raise AttemptErrors::EvidenceUnavailable, "Original control journal association differs"
53
+ end
54
+ control_owner_unchanged!(selected, original_map, map)
55
+ if first
56
+ authenticate_inventory_registration!(journal, assignment, events, first, mapping, original_map,
57
+ commit, introductions)
58
+ authenticate_inventory_registration!(journal, assignment, events, registration, mapping, original_map,
59
+ commit, introductions) unless registration == first
60
+ unless registrations.all? { |event| event.dig("payload", "data", "lifecycle_control") == selection }
61
+ raise AttemptErrors::EvidenceUnavailable, "Assignment control owner was replaced"
62
+ end
63
+ end
64
+ control = exclusion_for(original_map, journal, deployment: selected, selection: selection)
65
+ actual_selection = control.selection!
66
+ if actual_selection.fetch("descriptor_sha256") != selected.artifact_reference.fetch("sha256") ||
67
+ selection && actual_selection != selection
68
+ raise AttemptErrors::EvidenceUnavailable, "Original control root differs"
69
+ end
70
+ proposed = params.key?("definition_bytes") ? validated_registration_definition!(params, map) : nil
71
+ tasks = [registration&.fetch("task_id"), proposed&.task_id].compact.uniq.sort
72
+ tasks.each { |task| token!(task) }
73
+ keys = tasks.map { |task| control.task_key(task) } + [control.assignment_key(assignment)]
74
+ {commit: commit, registration: registration, descriptor: selected, map: original_map,
75
+ selection: actual_selection, exclusion: control, keys: keys.freeze}.freeze
76
+ rescue KeyError, TypeError, JSON::ParserError
77
+ raise AttemptErrors::EvidenceUnavailable, "Original control registration is unavailable"
78
+ end
79
+
80
+ def control_original_descriptor!(selection)
81
+ unless selection.is_a?(Hash) && selection.keys.sort == %w[descriptor_sha256 root_identity] &&
82
+ selection["descriptor_sha256"].is_a?(String) && selection["descriptor_sha256"].match?(/\A[0-9a-f]{64}\z/)
83
+ raise AttemptErrors::EvidenceUnavailable, "Original control selection is malformed"
84
+ end
85
+ sha = selection.fetch("descriptor_sha256")
86
+ return @deployment if sha == protected_descriptor_sha256!
87
+ unless @deployment_history
88
+ raise AttemptErrors::EvidenceUnavailable, "Original control descriptor is unavailable"
89
+ end
90
+ @deployment_history.descriptor!(sha256: sha)
91
+ end
92
+
93
+ def control_owner_unchanged!(selected, original_map, current_map)
94
+ old_authority = selected.authority(original_map.fetch("authority_id"))
95
+ current_authority = @deployment.authority(current_map.fetch("authority_id"))
96
+ slot_fields = %w[slot_id slice_unit service_unit root_directory runtime_directory network_namespace_path]
97
+ unless original_map.fetch("authority_id") == current_map.fetch("authority_id") &&
98
+ old_authority.values_at("state_root", "uid", "gid") == current_authority.values_at("state_root", "uid", "gid") &&
99
+ original_map.fetch("execution_scope").slice(*slot_fields) == current_map.fetch("execution_scope").slice(*slot_fields)
100
+ raise AttemptErrors::Conflict, "Control ownership changes require a fresh assignment"
101
+ end
102
+ end
103
+
104
+ def recheck_control_registration!(context, params, map, journal, commit: journal.ref_value, held_exclusion: context.fetch(:exclusion))
105
+ fresh = if context.fetch(:commit) == commit
106
+ context
107
+ else
108
+ control_registration_context!(params, map, journal, commit: commit)
109
+ end
110
+ unless fresh.fetch(:registration) == context.fetch(:registration) &&
111
+ fresh.fetch(:selection) == context.fetch(:selection) && fresh.fetch(:keys) == context.fetch(:keys) &&
112
+ journal.ref_value == commit && context.fetch(:exclusion).selection! == context.fetch(:selection)
113
+ raise AttemptErrors::Conflict, "Control registration changed while entering exclusion"
114
+ end
115
+ held_exclusion.verify_unchanged!
116
+ true
117
+ end
118
+
119
+ def validated_registration_definition!(params, map)
120
+ bytes = params.fetch("definition_bytes")
121
+ unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && params["definition_digest"].is_a?(String) &&
122
+ Digest::SHA256.hexdigest(bytes) == params["definition_digest"]
123
+ raise ArgumentError, "invalid assignment definition digest or size"
124
+ end
125
+ value = JSON.parse(bytes)
126
+ allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
127
+ unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
128
+ %w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
129
+ value["session_id"] == params["assignment_id"] && value["project_id"] == map["project_id"]
130
+ raise ArgumentError, "invalid managed assignment definition"
131
+ end
132
+ CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
133
+ assignment = Models::Assignment.from_h(value)
134
+ raise ArgumentError, "definition is not managed" unless assignment.managed?
135
+ token!(assignment.task_id)
136
+ assignment
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end