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,300 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "fileutils"
4
+ require_relative "deployment"
5
+ require_relative "transfer_codec"
6
+ require_relative "../molecules/evidence_journal"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Authority
11
+ # Existing assignment owner transport; kernel peers select roles, never argv.
12
+ class Server
13
+ CONNECTIONS = 16
14
+ def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
15
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", hitl_service: nil)
16
+ @authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
17
+ @composition = composition
18
+ @hitl_service = hitl_service
19
+ deployment.verify_composition!(authority_id, composition: composition)
20
+ @service = deployment.authority(authority_id)
21
+ @mutex = Mutex.new
22
+ @connections = []
23
+ @handlers = []
24
+ @transfers = 0
25
+ @stopped = false
26
+ end
27
+
28
+ def serve
29
+ @kernel.supported!
30
+ me = @kernel.capture(Process.pid)
31
+ unless principal?(me, @service, "uid", "gid", "groups")
32
+ raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
33
+ end
34
+ @deployment.verify_receiver_paths!(@authority_id)
35
+ directory = File.dirname(@service.fetch("socket_path"))
36
+ wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
37
+ lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
38
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
39
+ raise AttemptErrors::Conflict, "assignment authority already owns its listener"
40
+ end
41
+ start_hitl_service!
42
+ path = @service.fetch("socket_path")
43
+ if File.exist?(path) || File.symlink?(path)
44
+ raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
45
+ end
46
+ @listener = UNIXServer.new(path)
47
+ File.chmod(0o660, path)
48
+ @endpoint = wire.socket_identity(path)
49
+ until @stopped
50
+ if @hitl_thread && !@hitl_thread.alive?
51
+ raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary stopped before its authority"
52
+ end
53
+ ready = IO.select([@listener], nil, nil, 0.25)
54
+ next unless ready
55
+ socket = @listener.accept_nonblock(exception: false)
56
+ next if socket == :wait_readable
57
+ admitted = @mutex.synchronize do
58
+ if @connections.size < CONNECTIONS
59
+ @connections << socket
60
+ true
61
+ end
62
+ end
63
+ unless admitted
64
+ refusal(socket, "busy")
65
+ socket.close
66
+ next
67
+ end
68
+ @mutex.synchronize do
69
+ @handlers.reject! { |handler| !handler.alive? }
70
+ @handlers << Thread.new(socket) { |connection| receive(connection) }
71
+ end
72
+ end
73
+ rescue IOError, Errno::EBADF
74
+ raise unless @stopped
75
+ ensure
76
+ stop
77
+ stop_hitl_service!
78
+ # Closing a stream does not cancel a handler already inside dispatch.
79
+ # The owner lock and endpoint remain held until every handler ends.
80
+ @mutex.synchronize { @handlers.dup }.each(&:join)
81
+ @lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
82
+ if @endpoint && same_endpoint?(path)
83
+ File.unlink(path)
84
+ end
85
+ lock&.close
86
+ end
87
+
88
+ def request_stop
89
+ @stopped = true
90
+ end
91
+
92
+ def stop
93
+ @stopped = true
94
+ @listener&.close unless @listener&.closed?
95
+ @mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
96
+ end
97
+
98
+ private
99
+
100
+ # The existing HITL boundary is a sibling owned by this listener's
101
+ # lifetime. It must be ready before assignment ingress is opened.
102
+ def start_hitl_service!
103
+ return unless @hitl_service
104
+ ready = Queue.new
105
+ @hitl_thread = Thread.new do
106
+ @hitl_service.run(on_ready: -> { ready << :ready })
107
+ rescue Exception => error
108
+ @hitl_failure = error
109
+ ready << error
110
+ end
111
+ @hitl_thread.report_on_exception = false
112
+ result = ready.pop(timeout: 5)
113
+ raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary startup timed out" if result.nil?
114
+ raise result if result.is_a?(Exception)
115
+ end
116
+
117
+ def stop_hitl_service!
118
+ return unless @hitl_thread
119
+ @hitl_service.stop
120
+ @hitl_thread.join
121
+ end
122
+
123
+ def same_endpoint?(path)
124
+ wire.socket_identity(path) == @endpoint
125
+ rescue StandardError
126
+ false
127
+ end
128
+
129
+ def receive(socket)
130
+ deadline = wire.deadline(30)
131
+ peer = @kernel.peer(socket)
132
+ frame = wire.read(socket, deadline: [deadline, wire.deadline(5)].min, with_size: true)
133
+ request = frame.fetch(:data)
134
+ unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
135
+ request["version"].is_a?(Integer) && request["version"] == 1 && request["operation"].is_a?(String) && request["params"].is_a?(Hash)
136
+ raise ArgumentError, "invalid authority envelope"
137
+ end
138
+ deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope stop_attempt].include?(request.fetch("operation"))
139
+ params = request.fetch("params")
140
+ map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
141
+ unless map["authority_id"] == @authority_id && map["project_id"] == request["project_id"]
142
+ raise AttemptErrors::UnauthorizedIdentity, "mapping belongs to another authority or project"
143
+ end
144
+ project = @deployment.project(map.fetch("project_id"))
145
+ owner_contexts = project.fetch("inbox_contexts", {}).select do |_id, context|
146
+ context.fetch("native_mapping_id") == params.fetch("mapping_id") &&
147
+ context.fetch("owner_credentials").values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
148
+ end
149
+ context_query = %w[inbox_context_original].include?(request["operation"])
150
+ if context_query || !owner_contexts.empty?
151
+ unless context_query && @composition == "services" && request["mutation_id"].nil? &&
152
+ owner_contexts.key?(params.fetch("inbox_context_id")) && frame.fetch(:bytesize) <= 16_384
153
+ raise AttemptErrors::UnauthorizedIdentity, "context owner is exclusive to its fixed read-only inbox queries"
154
+ end
155
+ role = :context_owner
156
+ end
157
+ if request["operation"] == "native_readiness"
158
+ unless @composition == "launch" && request["mutation_id"].nil? && params.keys == ["mapping_id"] &&
159
+ frame.fetch(:bytesize) <= 16_384
160
+ raise ArgumentError, "private readiness envelope differs"
161
+ end
162
+ admitted_transfer = @mutex.synchronize do
163
+ if @transfers < 2
164
+ @transfers += 1
165
+ true
166
+ end
167
+ end
168
+ raise AttemptErrors::Conflict, "readiness transfer capacity is busy" unless admitted_transfer
169
+ @lifecycle.native_readiness!(mapping_id: params.fetch("mapping_id"), peer: peer, socket: socket,
170
+ codec: transfer_codec, deadline: wire.deadline(10))
171
+ return
172
+ end
173
+ role ||= if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
174
+ :launcher
175
+ elsif principal?(peer, map, "worker_uid", "worker_gid", "worker_groups")
176
+ :worker
177
+ end
178
+ unless role
179
+ project = @deployment.project(map.fetch("project_id"))
180
+ configured = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
181
+ if configured && peer["gid"] == configured["gid"] && peer["groups"] == configured["groups"]
182
+ {reviewer: "reviewer_uids", executor: "service_executor_uids", supervisor: "supervisor_uids"}.each do |candidate, key|
183
+ role ||= candidate if project.fetch(key).include?(peer.fetch("uid"))
184
+ end
185
+ end
186
+ end
187
+ raise AttemptErrors::UnauthorizedIdentity, "unmapped kernel peer" unless role
188
+ if request["operation"] == "launch_control"
189
+ raise AttemptErrors::UnauthorizedIdentity, "Private launcher control requires launcher peer" unless role == :launcher
190
+ raise ArgumentError, "Private launcher header is oversized" unless frame.fetch(:bytesize) <= 16_384
191
+ @lifecycle.serve_launch_control!(request: request, peer: peer, socket: socket, codec: transfer_codec, deadline: deadline)
192
+ return
193
+ end
194
+ if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review evidence_fetch assignment_inventory observe_execution_scope close_execution_scope stop_attempt prompt_status launch_input_inhibit_selection launch_input_inhibit_completion launch_prompt_intent launch_prompt_completion claim_service_settlement workspace_prune_preview_context].include?(request["operation"]) ||
195
+ @composition == "services" && %w[attempt_status inbox_context_original].include?(request["operation"])
196
+ raise ArgumentError, "review control header is oversized" if %w[request_review review_status cancel_review launch_review_intent].include?(request["operation"]) && frame.fetch(:bytesize) > 16_384
197
+ bodyless_read!(socket, deadline)
198
+ end
199
+ if request["operation"] == "gate_ready"
200
+ raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
201
+ @lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
202
+ else
203
+ Molecules::EvidenceJournal.with_event_read_operation do
204
+ binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
205
+ if binding
206
+ raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
207
+ raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
208
+ @lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
209
+ admitted_transfer = @mutex.synchronize do
210
+ if @transfers < 2
211
+ @transfers += 1
212
+ true
213
+ end
214
+ end
215
+ raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
216
+ codec = transfer_codec
217
+ if binding.fetch(:direction) == :upload
218
+ result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
219
+ @lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
220
+ end
221
+ else
222
+ result = @lifecycle.dispatch(request: request, peer: peer, role: role)
223
+ parts = result.fetch(:transfer_parts)
224
+ descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
225
+ result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
226
+ end
227
+ else
228
+ raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
229
+ result = @lifecycle.dispatch(request: request, peer: peer, role: role)
230
+ end
231
+ wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
232
+ "transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
233
+ if binding && binding.fetch(:direction) == :download
234
+ codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
235
+ end
236
+ end
237
+ end
238
+ rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
239
+ refusal(socket, "invalid_input")
240
+ rescue AttemptErrors::UnauthorizedIdentity, SecurityError
241
+ refusal(socket, "unauthorized")
242
+ rescue AttemptErrors::Conflict
243
+ refusal(socket, "conflict")
244
+ rescue AttemptErrors::NotFound
245
+ refusal(socket, "missing")
246
+ rescue AttemptErrors::BoundedResultUnavailable
247
+ refusal(socket, "bounded_result")
248
+ rescue StandardError
249
+ refusal(socket, "evidence_unavailable")
250
+ ensure
251
+ socket.close unless socket.closed?
252
+ @mutex.synchronize do
253
+ @connections.delete(socket)
254
+ @transfers -= 1 if admitted_transfer
255
+ end
256
+ end
257
+
258
+ # These closed read operations have no request body. Half-close proves
259
+ # framing completion before status or artifact data can be exposed.
260
+ def bodyless_read!(socket, deadline)
261
+ loop do
262
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
264
+ bytes = socket.read_nonblock(1, exception: false)
265
+ next if bytes == :wait_readable
266
+ raise ArgumentError, "unexpected read request body" unless bytes.nil?
267
+ return
268
+ end
269
+ end
270
+
271
+ def transfer_codec
272
+ @mutex.synchronize do
273
+ @transfer_codec ||= begin
274
+ root = @service.fetch("state_root")
275
+ PrivateDirectory.verify!(root)
276
+ spool = File.join(root, "transfers")
277
+ Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
278
+ TransferCodec.new(root: spool)
279
+ end
280
+ end
281
+ end
282
+
283
+ def principal?(identity, mapping, uid, gid, groups)
284
+ identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
285
+ end
286
+
287
+ def refusal(socket, code)
288
+ wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
289
+ deadline: wire.deadline(0.1))
290
+ rescue StandardError
291
+ nil
292
+ end
293
+
294
+ def wire
295
+ Ace::Runtime::Molecules::ProtectedSocket
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Canonical-only original dispatch proof shared by status, root admission
7
+ # and initial/retained inspection imports. Installed peer policy stays in
8
+ # the fixed root transport owner.
9
+ module ServiceCleanupDispatch
10
+ CLEANUP_MUTABLE_SETTLEMENT_FIELDS = %w[state receipt reason claimed_at failed_at no_effect_challenge
11
+ challenge_generation challenge_event_digest completion_digest no_effect_completion_digest].freeze
12
+
13
+ def cleanup_dispatch_context!(record, commit:, selectors: nil, pending: nil)
14
+ unless @journal.evidence_mode == :protected &&
15
+ record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"]
16
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch is unavailable"
17
+ end
18
+ pending ||= {commit: commit}
19
+ @journal.verify_canonical_prefix!(commit: commit) unless service_read_view(pending)
20
+ # The existing index authenticates accepted record provenance without
21
+ # recursively asking this terminal collection to validate itself.
22
+ selected = @journal.service_request_records(commit: commit)
23
+ .select { |entry| entry["request_id"] == record.fetch("request_id") }
24
+ # Initial import receives the lower writer's proposed terminal
25
+ # replacement. Its settlement fields are validated by that writer
26
+ # and collection; the original immutable dispatch must still match.
27
+ unless selected.one? && selected.first.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) ==
28
+ record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS)
29
+ raise AttemptErrors::EvidenceUnavailable, "cleanup selected record differs"
30
+ end
31
+ context(record, pending: {commit: commit})
32
+ events = service_read_events(record, pending)
33
+ names = %w[request_event_digest dispatch_event_digest]
34
+ operations = %w[request_service begin_dispatch]
35
+ if selectors.nil?
36
+ selectors = names.zip(operations).to_h do |name, operation|
37
+ matches = events.select do |event|
38
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == operation &&
39
+ event.dig("payload", "data", "request_id") == record.fetch("request_id") &&
40
+ event.dig("payload", "data", "claim_binding") == record.fetch("claim_binding") &&
41
+ event.dig("payload", "data", operation == "request_service" ? "claim" : "invocation") ==
42
+ (operation == "request_service" ? "created" : "permitted")
43
+ end
44
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance is ambiguous" unless matches.one?
45
+ [name, matches.first.fetch("digest")]
46
+ end
47
+ end
48
+ unless selectors.is_a?(Hash) && selectors.keys.sort == names.sort &&
49
+ selectors.values.all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) } && selectors.values.uniq.size == 2
50
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance selectors differ"
51
+ end
52
+ introductions = service_read_introductions(record, selectors.values, pending)
53
+ dispatch_record = nil
54
+ names.zip(operations).each do |name, operation|
55
+ selector = selectors.fetch(name)
56
+ matches = events.select { |event| event.fetch("digest") == selector }
57
+ event = matches.one? && matches.first
58
+ payload = event && event.fetch("payload")
59
+ unless event && event.fetch("type") == "authority_mutation" && payload.fetch("operation") == operation &&
60
+ payload.slice("assignment_id", "attempt_id") == record.slice("assignment_id", "attempt_id") &&
61
+ payload.fetch("data").slice("request_id", "claim_binding") == record.slice("request_id", "claim_binding") &&
62
+ payload.fetch("data").fetch(operation == "request_service" ? "claim" : "invocation") ==
63
+ (operation == "request_service" ? "created" : "permitted")
64
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance differs"
65
+ end
66
+ prefix = @journal.read_events(record.fetch("assignment_id"), commit: introductions.fetch(selector))
67
+ .select { |entry| entry.fetch("attempt_id") == record.fetch("attempt_id") }
68
+ original = @journal.service_request(record.fetch("request_id"), commit: introductions.fetch(selector))
69
+ expected = operation == "request_service" ? original&.merge("dispatch_phase" => "dispatch_started",
70
+ "operation_owner_binding" => record.fetch("operation_owner_binding"),
71
+ "executor_process_binding" => record.fetch("executor_process_binding")) : original
72
+ unless expected&.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) == record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) &&
73
+ prefix.last == event && prefix[-2] &&
74
+ prefix[-2].fetch("type") == (operation == "request_service" ? "service_claim" : "service_transition") &&
75
+ prefix[-2].dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(original) &&
76
+ event.fetch("previous_digest") == prefix[-2].fetch("digest")
77
+ raise AttemptErrors::EvidenceUnavailable, "cleanup acceptance is not its original canonical introduction"
78
+ end
79
+ dispatch_record = original if operation == "begin_dispatch"
80
+ end
81
+ freeze_projection(JSON.parse(JSON.generate(selectors.merge("commit" => commit, "dispatch_record" => dispatch_record))))
82
+ rescue KeyError, TypeError, ArgumentError
83
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch proof is incomplete"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_artifact_set"
4
+ require_relative "cleanup_result_protection"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # The existing service evidence owner authenticates this complete pair.
10
+ # Root-private archives remain outside the authority evidence graph.
11
+ module ServiceCleanupEvidence
12
+ ROOT_SELECTION_SCHEMA = "ace.protected-workspace-prune-root-selection/v1"
13
+ ROOT_RECEIPT_SCHEMA = "ace.protected-workspace-prune-receipt/v1"
14
+ ROOT_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest receipt_ref].freeze
15
+ ROOT_INSPECTION_SELECTION_SCHEMA = "ace.protected-workspace-prune-inspection-selection/v1"
16
+ ROOT_INSPECTION_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest inspection_ref].freeze
17
+ ROOT_RECEIPT_FIELDS = %w[schema request_id input_digest maintenance target publication canonical_snapshots preservation removal].freeze
18
+
19
+ # Pre-import decoding reuses the collection validator. Only the lower
20
+ # writer's pending collection can authenticate protected storage and
21
+ # accept a terminal update.
22
+ def cleanup_inspection_inputs!(contents, record, challenge)
23
+ unless record["operation"] == "prune-preserved-workspace" && contents.is_a?(Array) && contents.size == 2
24
+ raise AttemptErrors::ReceiptRejected, "cleanup inspection requires its complete pair"
25
+ end
26
+ cleanup_documents!(contents, record, no_effect: true, challenge: challenge)
27
+ true
28
+ rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError
29
+ raise AttemptErrors::ReceiptRejected, "cleanup inspection pair is unavailable"
30
+ end
31
+
32
+ private
33
+
34
+ def cleanup_collection!(references, record, state, pending)
35
+ unless record["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state) && references.size == 2
36
+ raise AttemptErrors::ReceiptRejected, "cleanup completion requires its complete original evidence pair"
37
+ end
38
+ no_effect = state == "failed-settled"
39
+ challenge = no_effect ? challenge!(record, pending: pending) : nil
40
+ expected = no_effect ? context_for_challenge(record, challenge) : context(record)
41
+ proof = cleanup_dispatch_context!(record, commit: pending && pending[:commit] || @journal.ref_value, pending: pending)
42
+ contents = references.map do |reference|
43
+ if pending && pending[:pending_events]
44
+ @canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
45
+ else
46
+ @canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
47
+ end
48
+ end
49
+ reference, operation_bytes = cleanup_documents!(contents, record, no_effect: no_effect,
50
+ challenge: challenge)
51
+ path = reference.fetch("path")
52
+ # The lower writer supplies its pending import transaction. Historical
53
+ # consumers have only a pinned commit and never reopen mutable storage.
54
+ if pending && pending[:pending_events]
55
+ selected_artifacts = @cleanup_artifacts || Ace::Runtime::Molecules::ProtectedArtifactSet.new(
56
+ protection: CleanupResultProtection.new(result_path: path, authority_gid: Process.egid))
57
+ selected_artifacts.with do |artifacts|
58
+ unless artifacts.read!(reference) == operation_bytes
59
+ raise AttemptErrors::ReceiptRejected, "cleanup retained result bytes differ"
60
+ end
61
+ artifacts.verify_unchanged!
62
+ end
63
+ end
64
+ if (view = service_read_view(pending))
65
+ view.projection = settlement_projection(record, pending, challenge, proof)
66
+ end
67
+ contents.freeze
68
+ rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError, Ace::Runtime::RuntimeUnavailableError
69
+ raise AttemptErrors::ReceiptRejected, "cleanup original evidence is unavailable"
70
+ end
71
+
72
+ def cleanup_documents!(contents, record, no_effect:, challenge:)
73
+ documents = contents.map { |bytes| cleanup_json!(bytes) }
74
+ selection_schema = no_effect ? ROOT_INSPECTION_SELECTION_SCHEMA : ROOT_SELECTION_SCHEMA
75
+ selection_fields = no_effect ? ROOT_INSPECTION_SELECTION_FIELDS : ROOT_SELECTION_FIELDS
76
+ selection_index = documents.each_index.select { |index| documents[index]["schema"] == selection_schema }
77
+ receipt_index = documents.each_index.select do |index|
78
+ no_effect ? documents[index].keys.sort == ServiceEvidence::INSPECTION_FIELDS.sort : documents[index]["schema"] == ROOT_RECEIPT_SCHEMA
79
+ end
80
+ unless selection_index.one? && receipt_index.one?
81
+ raise AttemptErrors::ReceiptRejected, "cleanup original evidence selection is ambiguous"
82
+ end
83
+ selection = documents.fetch(selection_index.first)
84
+ receipt = documents.fetch(receipt_index.first)
85
+ closed_cleanup!(selection, selection_fields)
86
+ closed_cleanup!(receipt, no_effect ? ServiceEvidence::INSPECTION_FIELDS : ROOT_RECEIPT_FIELDS)
87
+ unless [selection, receipt].all? { |value| value["request_id"] == record.fetch("request_id") && value["input_digest"] == record.fetch("input_digest") } &&
88
+ selection["operation_owner_binding_digest"] == Atoms::EvidenceDigest.digest(record.fetch("operation_owner_binding"))
89
+ raise AttemptErrors::ReceiptRejected, "cleanup original owner or request differs"
90
+ end
91
+ reference = selection.fetch(no_effect ? "inspection_ref" : "receipt_ref")
92
+ closed_cleanup!(reference, %w[path bytes sha256])
93
+ path = reference.fetch("path")
94
+ operation_bytes = contents.fetch(receipt_index.first)
95
+ unless path.is_a?(String) && path.bytesize <= 4096 && path.valid_encoding? && !path.include?("\0") &&
96
+ path.start_with?("/") && File.expand_path(path) == path &&
97
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536) &&
98
+ reference["bytes"] == operation_bytes.bytesize && reference["sha256"] == Digest::SHA256.hexdigest(operation_bytes)
99
+ raise AttemptErrors::ReceiptRejected, "cleanup retained result reference differs"
100
+ end
101
+ if no_effect
102
+ inspection_projection!(receipt, record, challenge)
103
+ else
104
+ cleanup_receipt_projection!(receipt, record)
105
+ end
106
+ [reference, operation_bytes]
107
+ end
108
+
109
+ def cleanup_json!(bytes)
110
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536)
111
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence exceeds bounds"
112
+ end
113
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
114
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence is invalid UTF8" unless text.valid_encoding? && !text.include?("\0")
115
+ value = JSON.parse(text, create_additions: false, max_nesting: 16, allow_duplicate_key: false, allow_comments: false)
116
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence is not an object" unless value.is_a?(Hash)
117
+ value
118
+ end
119
+
120
+ def closed_cleanup!(value, fields)
121
+ unless value.is_a?(Hash) && value.keys.sort == fields.sort
122
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence fields differ"
123
+ end
124
+ end
125
+
126
+ def cleanup_receipt_projection!(receipt, record)
127
+ preservation = receipt.fetch("preservation")
128
+ closed_cleanup!(preservation, %w[head branch destinations manifest_sha256 inventory_sha256 file_count total_bytes private_manifest_sha256 archives_sha256])
129
+ unless preservation["file_count"].is_a?(Integer) && preservation["file_count"].between?(0, 4096) &&
130
+ preservation["total_bytes"].is_a?(Integer) && preservation["total_bytes"].between?(0, 256 * 1024 * 1024) &&
131
+ %w[manifest_sha256 inventory_sha256 private_manifest_sha256 archives_sha256].all? { |key| cleanup_sha?(preservation[key]) } &&
132
+ preservation.values_at("manifest_sha256", "inventory_sha256", "private_manifest_sha256").uniq.one?
133
+ raise AttemptErrors::ReceiptRejected, "cleanup preservation observation differs"
134
+ end
135
+ input = receipt.slice("maintenance", "target", "publication").merge("schema" => "ace.protected-workspace-prune/v1",
136
+ "preservation" => preservation.slice("head", "branch", "destinations", "manifest_sha256"))
137
+ unless Atoms::EvidenceDigest.digest(input) == record.fetch("input_digest") &&
138
+ receipt.fetch("maintenance") == record.slice("project_id", "mapping_id", "assignment_id", "attempt_id")
139
+ raise AttemptErrors::ReceiptRejected, "cleanup original input differs"
140
+ end
141
+ snapshots = receipt.fetch("canonical_snapshots")
142
+ unless snapshots.is_a?(Array) && snapshots.size.between?(1, 256)
143
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots exceed bounds"
144
+ end
145
+ snapshots.each do |snapshot|
146
+ closed_cleanup!(snapshot, %w[mapping_id journal_commit binding_event_digest release_event_digest proof_event_digest])
147
+ unless snapshot["mapping_id"].is_a?(String) && snapshot["mapping_id"].match?(Molecules::JournalMutation::ID) &&
148
+ snapshot["journal_commit"].is_a?(String) && snapshot["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
149
+ %w[binding_event_digest release_event_digest proof_event_digest].all? { |key| cleanup_sha?(snapshot[key]) }
150
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshot differs"
151
+ end
152
+ end
153
+ ids = snapshots.map { |snapshot| snapshot.fetch("mapping_id") }
154
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots are not uniquely ordered" unless ids == ids.uniq.sort
155
+ removal = receipt.fetch("removal")
156
+ closed_cleanup!(removal, %w[original captured outcome fence_digest])
157
+ unless removal["outcome"] == "removed" && cleanup_sha?(removal["fence_digest"])
158
+ raise AttemptErrors::ReceiptRejected, "cleanup has no positive removal observation"
159
+ end
160
+ %w[original captured].each do |key|
161
+ object = removal.fetch(key)
162
+ closed_cleanup!(object, %w[device inode worktree_admin_id head branch])
163
+ unless %w[device inode].all? { |field| object[field].is_a?(Integer) && object[field].positive? } &&
164
+ object["worktree_admin_id"].is_a?(String) && object["worktree_admin_id"].bytesize.between?(1, 200) &&
165
+ object["worktree_admin_id"].match?(/\A[a-zA-Z0-9_.-]+\z/) &&
166
+ object["head"] == preservation["head"] && object["branch"] == preservation["branch"]
167
+ raise AttemptErrors::ReceiptRejected, "cleanup captured object differs"
168
+ end
169
+ end
170
+ unless removal.fetch("original") == removal.fetch("captured")
171
+ raise AttemptErrors::ReceiptRejected, "cleanup replaced object cannot attest removal"
172
+ end
173
+ true
174
+ end
175
+
176
+ def cleanup_sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
177
+ end
178
+ end
179
+ end
180
+ end