ace-assign 0.63.0 → 0.64.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
@@ -0,0 +1,476 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/cgroup_observation"
4
+ require "ace/runtime/molecules/systemd_scope_manager"
5
+ require "ace/runtime/molecules/linux_mount_info"
6
+ require "ace/runtime/molecules/execution_unit_installation"
7
+ require_relative "../molecules/execution_scope_lineage"
8
+ require_relative "posix_acl"
9
+ require "digest"
10
+ require "json"
11
+
12
+ module Ace
13
+ module Assign
14
+ module Authority
15
+ # Joins already canonical scope facts to current native owner objects.
16
+ # This does not manufacture readiness, a network-policy assertion, or a
17
+ # canonical no-writer event from a population snapshot.
18
+ class ExecutionScopeObservation
19
+ LOCAL_FILESYSTEMS = %w[ext4 xfs btrfs tmpfs].freeze
20
+ BOUNDARY_SCHEMA = "ace.execution-boundary-manifest/v1"
21
+
22
+ class Files
23
+ def boot_id
24
+ value = File.read("/proc/sys/kernel/random/boot_id", 128).strip
25
+ unless value.match?(Molecules::ExecutionScopeLineage::BOOT)
26
+ raise Ace::Runtime::RuntimeUnavailableError, "kernel boot identity is malformed"
27
+ end
28
+ value
29
+ end
30
+
31
+ def resource_identity(path)
32
+ unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && File.realpath(path) == path
33
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource path is substituted"
34
+ end
35
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |directory|
36
+ stat = directory.stat
37
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource is not a directory" unless stat.directory?
38
+ info = File.read("/proc/self/fdinfo/#{directory.fileno}", 16_385)
39
+ ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
40
+ raise Ace::Runtime::RuntimeUnavailableError, "resource mount identity is ambiguous" unless ids.size == 1
41
+ bytes = File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1)
42
+ mount = Ace::Runtime::Molecules::LinuxMountInfo.new(bytes).by_id(Integer(ids.first, 10))
43
+ current = File.stat(path)
44
+ unless current.dev == stat.dev && current.ino == stat.ino && File.realpath(path) == path
45
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource changed while observing it"
46
+ end
47
+ {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
48
+ "filesystem_type" => mount.fetch("filesystem_type"), "mount_id" => mount.fetch("mount_id")}
49
+ end
50
+ end
51
+
52
+ def namespace_identity
53
+ stat = File.stat("/proc/self/ns/mnt")
54
+ {"device" => stat.dev, "inode" => stat.ino}
55
+ end
56
+
57
+ def pin_network_namespace(path)
58
+ unless RUBY_PLATFORM.include?("linux") && path.is_a?(String) && path.start_with?("/") &&
59
+ path.bytesize.between?(1, 4096) && !path.include?("\0") && File.expand_path(path) == path && File.realpath(path) == path
60
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace path is substituted"
61
+ end
62
+ parts = path.split("/").reject(&:empty?)
63
+ paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
64
+ paths.each do |ancestor|
65
+ stat = File.lstat(ancestor)
66
+ unless stat.uid.zero? && !stat.symlink? && (stat.mode & 0o022).zero? &&
67
+ (ancestor == path || stat.directory?)
68
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ancestry is writable"
69
+ end
70
+ # The leaf is authenticated as nsfs below. nsfs has no access
71
+ # ACL/xattr owner and exposes only namespace ioctls, not writes.
72
+ next if ancestor == path
73
+ acl = PosixAcl.new.entries(ancestor)
74
+ if acl && acl.any? { |tag, permissions, uid| (permissions & 2) != 0 && !(tag == 1 || tag == 2 && uid.zero?) }
75
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ACL permits untrusted mutation"
76
+ end
77
+ end
78
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW)
79
+ stat = handle.stat
80
+ info = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385)
81
+ ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
82
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
83
+ unless ids.size == 1 && table.by_id(Integer(ids.first, 10)).fetch("filesystem_type") == "nsfs" &&
84
+ handle.ioctl(0xb703) == 0x40000000 && [File.stat(path).dev, File.stat(path).ino] == [stat.dev, stat.ino]
85
+ raise Ace::Runtime::RuntimeUnavailableError, "installed object is not the exact nsfs network namespace"
86
+ end
87
+ {handle: handle, identity: {"device" => stat.dev, "inode" => stat.ino}}
88
+ rescue StandardError
89
+ handle&.close
90
+ raise
91
+ end
92
+
93
+ def resource_boundary_policy(path)
94
+ stat = File.lstat(path)
95
+ unless stat.directory? && File.realpath(path) == path
96
+ raise Ace::Runtime::RuntimeUnavailableError, "protected root policy object is substituted"
97
+ end
98
+ {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
99
+ "mode" => stat.mode & 0o7777, "acl" => PosixAcl.new.entries(path)}
100
+ end
101
+
102
+ def resource_aliases(path, identity:)
103
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
104
+ original = table.by_id(identity.fetch("mount_id"))
105
+ physical = table.filesystem_path(original, path)
106
+ table.records.filter_map do |mount|
107
+ next unless mount.fetch("major_minor") == original.fetch("major_minor") && mount.fetch("options").include?("rw")
108
+ root = mount.fetch("root")
109
+ if physical == root || root == "/" || physical.start_with?(root + "/")
110
+ suffix = physical.delete_prefix(root).delete_prefix("/")
111
+ File.join(mount.fetch("mountpoint"), suffix)
112
+ elsif root.start_with?(physical + "/")
113
+ mount.fetch("mountpoint")
114
+ end
115
+ end
116
+ end
117
+
118
+ def worker_uid_quiescent!(uid)
119
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
120
+ proc_mounts = table.records.select { |record| record.fetch("mountpoint") == "/proc" }
121
+ unless proc_mounts.size == 1 && proc_mounts.first.fetch("filesystem_type") == "proc" && proc_mounts.first.fetch("root") == "/" &&
122
+ (proc_mounts.first.fetch("options") + proc_mounts.first.fetch("super_options")).none? { |option| option.start_with?("hidepid=") && option != "hidepid=0" }
123
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline requires a fully visible authority proc view"
124
+ end
125
+ pids = Dir.children("/proc").select { |name| name.match?(/\A[1-9][0-9]*\z/) }
126
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline is oversized" if pids.size > 100_000
127
+ pids.each do |pid|
128
+ begin
129
+ bytes = File.open("/proc/#{pid}/status", File::RDONLY | File::NOFOLLOW) { |file| file.read(65_537) }
130
+ rescue Errno::ENOENT, Errno::ESRCH
131
+ next # A dead process cannot retain a writable userspace handle.
132
+ end
133
+ rows = bytes.lines.select { |line| line.start_with?("Uid:") }
134
+ match = rows.size == 1 && /\AUid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*\z/.match(rows.first)
135
+ unless bytes.bytesize <= 65_536 && match
136
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline has unreadable credential evidence"
137
+ end
138
+ if match.captures.map { |value| Integer(value, 10) }.include?(uid)
139
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID has a process outside the never-admitted generation"
140
+ end
141
+ end
142
+ true
143
+ end
144
+
145
+ def boundary_manifest(scope)
146
+ path = "/etc/ace/execution-slots/#{scope.fetch('slot_id')}/boundary-manifest.json"
147
+ bytes = Ace::Runtime::Molecules::ExecutionUnitInstallation::Files.new.read(path, limit: 65_536)
148
+ unless Digest::SHA256.hexdigest(bytes) == scope.fetch("boundary_manifest_sha256")
149
+ raise Ace::Runtime::RuntimeUnavailableError, "boundary inventory differs from fixed deployment"
150
+ end
151
+ JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
152
+ rescue JSON::ParserError, EncodingError
153
+ raise Ace::Runtime::RuntimeUnavailableError, "fixed boundary inventory is not strict UTF-8 JSON"
154
+ end
155
+ end
156
+
157
+ def initialize(mapping_id:, deployment:, kernel:, manager: nil, cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: Files.new)
158
+ @mapping_id, @deployment, @kernel, @cgroups, @files = mapping_id, deployment, kernel, cgroups, files
159
+ @map = deployment.mapping(mapping_id)
160
+ @scope = @map.fetch("execution_scope")
161
+ @manager = manager || Ace::Runtime::Molecules::SystemdScopeManager.new(
162
+ slice_unit: @scope.fetch("slice_unit"), service_unit: @scope.fetch("service_unit"))
163
+ end
164
+
165
+ def observe(lineage)
166
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
167
+ binding = lineage.binding
168
+ unless binding && binding.fetch("slot_id") == @scope.fetch("slot_id") && binding.fetch("boot_id") == @files.boot_id &&
169
+ binding.fetch("deployment_digest") == Digest::SHA256.hexdigest(JSON.generate(canonical(@map))) &&
170
+ binding.fetch("resource_mount_namespace_identity") == @files.namespace_identity
171
+ unavailable!("retained scope deployment or boot changed")
172
+ end
173
+ network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
174
+ unless network.fetch(:identity) == binding.fetch("network_namespace_identity") &&
175
+ network_selection! == binding.fetch("network_installation_selection")
176
+ unavailable!("retained installed network namespace or selection changed")
177
+ end
178
+ before = @manager.inspect_activation
179
+ verify_parent!(binding, before.fetch("slice"))
180
+ pinned = @cgroups.pin(binding.fetch("cgroup_identity").fetch("path"), expected: binding.fetch("cgroup_identity"))
181
+ unless parent_resources! == binding.fetch("resource_identities")
182
+ unavailable!("retained parent inventory changed")
183
+ end
184
+ verify_resources!(binding.fetch("resource_identities"))
185
+ value = @cgroups.observe(pinned)
186
+ after = @manager.inspect_activation
187
+ unless before == after && @files.boot_id == binding.fetch("boot_id")
188
+ unavailable!("scope activation changed while observing it")
189
+ end
190
+ {"populated" => value.fetch("populated"), "activation" => after}
191
+ rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError
192
+ unavailable!("retained scope observation is unavailable")
193
+ ensure
194
+ pinned&.fetch(:handle)&.close
195
+ network&.fetch(:handle)&.close
196
+ end
197
+
198
+ # Called only by the fresh reservation winner with slot exclusion held.
199
+ # It creates no service or payload. Original activation facts are taken
200
+ # after this exact successful StartUnit, never adopted on a later retry.
201
+ def activate_parent!(context)
202
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
203
+ inventory = parent_resources!
204
+ selection = network_selection!
205
+ network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
206
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
207
+ boot = @files.boot_id
208
+ namespace = @files.namespace_identity
209
+ before = @manager.inspect_activation
210
+ service = before.fetch("service")
211
+ slice = before.fetch("slice")
212
+ unless %w[inactive failed].include?(slice.fetch("ActiveState")) && slice.fetch("Job") == [0, "/"] &&
213
+ %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
214
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
215
+ unavailable!("fresh parent activation has retained or pending native ownership")
216
+ end
217
+ @manager.start_slice
218
+ activation = @manager.inspect_activation
219
+ parent = activation.fetch("slice")
220
+ unless parent.fetch("ActiveState") == "active" && parent.fetch("SubState") == "active" &&
221
+ Molecules::ExecutionScopeLineage::INVOCATION.match?(parent.fetch("InvocationID")) && parent.fetch("Job") == [0, "/"] &&
222
+ !parent.fetch("ControlGroup").empty? && activation.fetch("service") == service
223
+ unavailable!("fresh parent job did not produce its exact independent activation")
224
+ end
225
+ pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + parent.fetch("ControlGroup"))
226
+ unless @cgroups.observe(pinned).fetch("populated").zero? && @files.boot_id == boot &&
227
+ @files.namespace_identity == namespace && @manager.inspect_activation == activation
228
+ unavailable!("fresh parent changed or acquired processes before binding")
229
+ end
230
+ verify_resources!(inventory)
231
+ context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => Digest::SHA256.hexdigest(JSON.generate(canonical(@map))),
232
+ "boot_id" => boot, "slice_invocation_id" => parent.fetch("InvocationID"), "cgroup_identity" => pinned.fetch(:identity),
233
+ "resource_mount_namespace_identity" => namespace, "resource_identities" => inventory,
234
+ "network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity))
235
+ rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError, JSON::ParserError
236
+ unavailable!("fresh parent inventory or activation is unavailable")
237
+ ensure
238
+ pinned&.fetch(:handle)&.close
239
+ network&.fetch(:handle)&.close
240
+ end
241
+
242
+ def verify_closed!(lineage)
243
+ value = observe(lineage)
244
+ service = value.fetch("activation").fetch("service")
245
+ unless lineage.sealed? && lineage.proof_event && value.fetch("populated").zero? &&
246
+ %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("MainPID").zero? &&
247
+ service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
248
+ unavailable!("canonical closure has a live or pending native activation")
249
+ end
250
+ if lineage.native_event
251
+ native = lineage.native_event.fetch("payload")
252
+ invocation = service.fetch("InvocationID")
253
+ unless invocation.empty? || invocation == native.fetch("service_invocation_id")
254
+ unavailable!("closed native incarnation was replaced")
255
+ end
256
+ verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false)
257
+ end
258
+ true
259
+ end
260
+
261
+ def native_admission_ready!(lineage)
262
+ value = observe(lineage)
263
+ service = value.fetch("activation").fetch("service")
264
+ unless value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
265
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
266
+ unavailable!("native admission has an occupied or pending generation")
267
+ end
268
+ # Effective firewall evidence cannot be obtained through this fixed
269
+ # unprivileged owner. The reviewed evidence/lifetime contract is still
270
+ # open: unit hashes and /proc routes must never substitute for it.
271
+ unavailable!("required effective network boundary evidence is unavailable")
272
+ end
273
+
274
+ def start_admitted_service!
275
+ @manager.start_service
276
+ end
277
+
278
+ def sealed_service_stop_required?(lineage)
279
+ unavailable!("service stop requires the canonical original seal") unless lineage.sealed?
280
+ service = observe(lineage).fetch("activation").fetch("service")
281
+ !(%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
282
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?)
283
+ end
284
+
285
+ def stop_sealed_service!
286
+ @manager.stop_service
287
+ end
288
+
289
+ def retire_released_parent!(lineages)
290
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
291
+ activation = @manager.inspect_activation
292
+ service = activation.fetch("service")
293
+ unless %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
294
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
295
+ unavailable!("released parent retirement has a live or pending service")
296
+ end
297
+ slice = activation.fetch("slice")
298
+ unless slice.fetch("Job") == [0, "/"]
299
+ unavailable!("released parent retirement has a pending parent job")
300
+ end
301
+ if %w[inactive failed].include?(slice.fetch("ActiveState"))
302
+ unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
303
+ unavailable!("stopped parent metadata belongs to an unknown generation")
304
+ end
305
+ return {"state" => "already_retired"}
306
+ end
307
+ exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
308
+ unavailable!("active parent has no unique released canonical owner") unless exact.size == 1
309
+ verify_closed!(exact.first)
310
+ @manager.stop_slice
311
+ after = @manager.inspect_activation
312
+ unless %w[inactive failed].include?(after.fetch("slice").fetch("ActiveState")) &&
313
+ after.fetch("slice").fetch("Job") == [0, "/"] && after.fetch("service") == service
314
+ unavailable!("released parent retirement outcome is unavailable")
315
+ end
316
+ {"state" => "retired", "scope_binding_event_id" => exact.first.binding_event.fetch("digest")}
317
+ end
318
+
319
+ def closed_observation_for_proof!(lineage, events:)
320
+ value = observe(lineage)
321
+ service = value.fetch("activation").fetch("service")
322
+ unless lineage.sealed? && value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
323
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
324
+ unavailable!("sealed parent still has writers or pending activation")
325
+ end
326
+ admitted = events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
327
+ if admitted && !lineage.native_event
328
+ unavailable!("admitted native activation lacks verified writer-boundary evidence")
329
+ end
330
+ if admitted || lineage.native_event || lineage.child_event || events.any? { |event|
331
+ %w[process_start service_claim service_transition inbox_binding].include?(event["type"]) ||
332
+ event["type"] == "authority_mutation" && %w[record_launch bind_process release_launch].include?(event.dig("payload", "operation")) }
333
+ unavailable!("post-native writer-boundary revalidation is unavailable")
334
+ end
335
+ # This bounded path proves a generation that never admitted native,
336
+ # child or effect creation. It is not post-native readiness proof.
337
+ current = parent_resources!
338
+ unless current == lineage.binding.fetch("resource_identities") &&
339
+ @files.namespace_identity == lineage.binding.fetch("resource_mount_namespace_identity")
340
+ unavailable!("never-admitted parent resources or observation namespace changed")
341
+ end
342
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
343
+ # Rejoin object and population after the credential baseline, keeping
344
+ # the snapshot bounded by the same retained activation and exclusion.
345
+ unless observe(lineage).fetch("populated").zero?
346
+ unavailable!("never-admitted parent acquired a writer during baseline observation")
347
+ end
348
+ lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
349
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
350
+ end
351
+
352
+ private
353
+
354
+ # Inventory only: no configurable security booleans or alternative
355
+ # policy backend. Native-only objects are observed at their later stage.
356
+ def network_selection!
357
+ manifest = @files.boundary_manifest(@scope)
358
+ Molecules::ExecutionScopeLineage.validate_network_selection!(manifest.fetch("network_installation"))
359
+ end
360
+
361
+ def parent_resources!
362
+ manifest = @files.boundary_manifest(@scope)
363
+ unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
364
+ manifest["schema"] == BOUNDARY_SCHEMA && manifest["slot_id"] == @scope.fetch("slot_id") &&
365
+ manifest["resources"].is_a?(Array) && manifest["resources"].size.between?(1, 64)
366
+ unavailable!("fixed boundary resource inventory differs")
367
+ end
368
+ pairs = []
369
+ resources = manifest.fetch("resources").filter_map do |resource|
370
+ unless resource.is_a?(Hash) && resource.keys.sort == %w[host_path stage view_path] && %w[parent native].include?(resource["stage"])
371
+ unavailable!("boundary resource inventory is not closed")
372
+ end
373
+ paths = resource.values_at("host_path", "view_path")
374
+ unless paths.all? { |path| path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
375
+ !path.include?("\0") && File.expand_path(path) == path } && !pairs.include?(paths)
376
+ unavailable!("boundary resource paths are substituted or repeated")
377
+ end
378
+ pairs << paths
379
+ next if resource.fetch("stage") == "native"
380
+ actual = @files.resource_identity(resource.fetch("host_path"))
381
+ unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type"))
382
+ unavailable!("parent resource backing is not local kernel-managed storage")
383
+ end
384
+ resource.slice("host_path", "view_path").merge(actual)
385
+ end
386
+ unavailable!("parent resource inventory is empty") if resources.empty?
387
+ verify_parent_access_boundary!(resources)
388
+ resources
389
+ end
390
+
391
+ def verify_parent_access_boundary!(resources)
392
+ worker = @map.fetch("worker_uid")
393
+ authority = @deployment.authority(@map.fetch("authority_id")).fetch("uid")
394
+ readers = @deployment.project(@map.fetch("project_id")).fetch("supervisor_uids")
395
+ trusted = [worker, authority, @map.fetch("launcher_uid"), *readers].uniq
396
+ policies = resources.to_h do |resource|
397
+ path = resource.fetch("host_path")
398
+ policy = @files.resource_boundary_policy(path)
399
+ unless %w[device inode uid gid].all? { |key| policy.fetch(key) == resource.fetch(key) }
400
+ unavailable!("parent access policy belongs to a replaced object")
401
+ end
402
+ [path, policy]
403
+ end
404
+ barriers = policies.select do |_path, policy|
405
+ next false unless policy.fetch("uid").zero? && (policy.fetch("mode") & 0o7022).zero?
406
+ acl = policy.fetch("acl")
407
+ if acl.nil?
408
+ next (policy.fetch("mode") & 0o077).zero?
409
+ end
410
+ acl.all? do |tag, permissions, uid|
411
+ case tag
412
+ when 1 then permissions == 7
413
+ when 2 then trusted.include?(uid) && (permissions & 2).zero?
414
+ when 4, 8, 32 then permissions.zero?
415
+ when 16 then (permissions & 2).zero?
416
+ else false
417
+ end
418
+ end
419
+ end
420
+ unless resources.any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
421
+ path = resource.fetch("host_path")
422
+ [0, worker].include?(resource.fetch("uid")) && barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
423
+ unavailable!("parent roots lack an immutable exclusive host traversal boundary")
424
+ end
425
+ resources.each do |resource|
426
+ aliases = @files.resource_aliases(resource.fetch("host_path"), identity: resource)
427
+ unless aliases.all? { |path| barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
428
+ unavailable!("protected parent backing is exposed through an outside writable mount")
429
+ end
430
+ end
431
+ end
432
+
433
+ def verify_parent!(binding, slice)
434
+ expected = binding.fetch("cgroup_identity").fetch("path").delete_prefix(Ace::Runtime::Molecules::CgroupObservation::ROOT)
435
+ unless slice.fetch("Id") == @scope.fetch("slice_unit") && slice.fetch("ActiveState") == "active" && slice.fetch("SubState") == "active" &&
436
+ slice.fetch("InvocationID") == binding.fetch("slice_invocation_id") && slice.fetch("ControlGroup") == expected &&
437
+ slice.fetch("Job") == [0, "/"]
438
+ unavailable!("retained exact parent slice changed")
439
+ end
440
+ end
441
+
442
+ def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true)
443
+ resources.each do |resource|
444
+ path = resource.fetch("host_path")
445
+ begin
446
+ actual = @files.resource_identity(path)
447
+ rescue Errno::ENOENT
448
+ runtime = @scope.fetch("runtime_directory")
449
+ # Fixed stopped RuntimeDirectoryPreserve=no cleanup may remove
450
+ # native-stage descendants. Absence never supplies the proof:
451
+ # parent identity/population and settled activation did above.
452
+ next if allow_runtime_absence && (path == runtime || path.start_with?(runtime + "/"))
453
+ raise
454
+ end
455
+ unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type")) &&
456
+ (%w[device inode uid gid filesystem_type] + (same_namespace ? ["mount_id"] : [])).all? { |key| actual.fetch(key) == resource.fetch(key) }
457
+ unavailable!("retained resource backing object changed")
458
+ end
459
+ end
460
+ end
461
+
462
+ def canonical(value)
463
+ case value
464
+ when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
465
+ when Array then value.map { |item| canonical(item) }
466
+ else value
467
+ end
468
+ end
469
+
470
+ def unavailable!(message)
471
+ raise Ace::Runtime::RuntimeUnavailableError, message
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end
@@ -0,0 +1,114 @@
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
+
8
+ module Ace
9
+ module Assign
10
+ module Authority
11
+ class LaunchDriver
12
+ def initialize(mapping_id:, deployment: Deployment.load,
13
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil, native: nil)
14
+ @mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
15
+ @map = deployment.mapping(mapping_id)
16
+ @client = client || Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
17
+ @native = native
18
+ end
19
+
20
+ def preflight
21
+ @deployment.verify!(@mapping_id, kernel: @kernel)
22
+ @client.call("launch_preflight", {}).data
23
+ end
24
+
25
+ def launch(assignment_id:, definition_bytes:, scope:, base_head:, mutation_id: SecureRandom.hex(16))
26
+ preflight
27
+ registration = @client.call("registration_status", {"assignment_id" => assignment_id}).data
28
+ digest = Digest::SHA256.hexdigest(definition_bytes)
29
+ registered = if registration["definition_digest"] == digest
30
+ registration
31
+ else
32
+ @client.call("register_assignment", {"assignment_id" => assignment_id,
33
+ "definition_bytes" => definition_bytes, "definition_digest" => digest,
34
+ "expected_generation" => registration.fetch("generation", 0)}, mutation_id: "#{mutation_id}-register").data
35
+ end
36
+ reserved = @client.call("reserve_attempt", {"assignment_id" => assignment_id, "scope" => scope,
37
+ "worker_uid" => @map.fetch("worker_uid"), "runtime" => "herdr", "base_head" => base_head,
38
+ "launcher_process_binding" => @kernel.capture(Process.pid),
39
+ "expected_generation" => registered.fetch("definition_generation")}, mutation_id: "#{mutation_id}-reserve")
40
+ # Only this actual CAS winner may create. A cached canonical reply is
41
+ # evidence of ownership, never evidence that native creation is fresh.
42
+ return reserved.data.merge("required_action" => "inspect_retained_reservation_no_creation_permission") if reserved.replayed
43
+ state = reserved.data
44
+ inspected = @client.call("inspect_launch", state.slice("assignment_id", "attempt_id")).data
45
+ state = inspected
46
+ origin = inspected.fetch("native_binding")
47
+ return close_before_native_release(state, mutation_id) unless origin
48
+ fixed = JSON.parse(JSON.generate(@map))
49
+ fixed.fetch("native").merge!(origin.slice("server_identity", "socket_identity", "workspace_id"))
50
+ @native ||= Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: fixed, kernel: @kernel)
51
+ binding = @native.create(mapping_id: @mapping_id, ticket: state.fetch("launch_ticket"))
52
+ child_handle = @kernel.pin(binding.fetch("process_identity"))
53
+ recorded = @client.call("record_launch", lifecycle_params(state, binding), mutation_id: "#{mutation_id}-record").data
54
+ bound = @client.call("bind_process", lifecycle_params(recorded, binding), mutation_id: "#{mutation_id}-bind").data
55
+ # Exact original child is reobserved after canonical bind, before release.
56
+ @native.observe(binding.fetch("native_origin").merge("process_binding" => binding))
57
+ @kernel.live!(binding.fetch("process_identity"))
58
+ issued = @client.call("release_launch", lifecycle_params(bound, binding), mutation_id: "#{mutation_id}-release").data
59
+ issued
60
+ rescue StandardError
61
+ # Response loss or partial creation never retries native creation.
62
+ # Canonical state is retained by the authority. Expose its ticket if
63
+ # it was received, but do not fabricate an accepted abort/termination.
64
+ raise if state.nil?
65
+ state.slice("assignment_id", "attempt_id", "launch_ticket", "journal_commit", "generation").merge(
66
+ "phase" => "uncertain", "required_action" => "inspect_exact_native_child_and_canonical_release_state")
67
+ ensure
68
+ child_handle&.close
69
+ end
70
+
71
+ def terminate(state:, binding:, evidence:, mutation_id: SecureRandom.hex(16))
72
+ observed = state["termination_observation"]
73
+ unless observed.is_a?(Hash) && observed["status"] == "exited" && observed["process_identity"] == binding["process_identity"]
74
+ handle = @kernel.pin(binding.fetch("process_identity"))
75
+ exited = @native.terminate(binding, handle: handle)
76
+ raise AttemptErrors::EvidenceUnavailable, "native close did not prove exact child exit" unless exited
77
+ end
78
+ @client.call("abort_launch", {"assignment_id" => state.fetch("assignment_id"), "attempt_id" => state.fetch("attempt_id"),
79
+ "launch_ticket" => state.fetch("launch_ticket"), "expected_generation" => state.fetch("generation"),
80
+ "failure_evidence" => evidence, "failure_digest" => Digest::SHA256.hexdigest(evidence)}, mutation_id: mutation_id).data
81
+ ensure
82
+ handle&.close
83
+ end
84
+
85
+ private
86
+ def close_before_native_release(state, mutation_id)
87
+ unless state["scope_binding_event_id"]
88
+ return state.merge("required_action" => "inspect_exact_scope")
89
+ end
90
+ selectors = state.slice("assignment_id", "attempt_id")
91
+ sealed = @client.call("close_execution_scope", selectors.merge("expected_generation" => state.fetch("generation")),
92
+ mutation_id: "#{mutation_id}-scope-seal").data
93
+ closed = @client.call("close_execution_scope", selectors.merge("expected_generation" => sealed.fetch("generation")),
94
+ mutation_id: "#{mutation_id}-scope-proof").data
95
+ unless closed["state"] == "closed_no_writers"
96
+ return state.merge("required_action" => "inspect_exact_scope")
97
+ end
98
+ inspected = @client.call("inspect_launch", selectors).data
99
+ failure = inspected.fetch("scope_failure_evidence")
100
+ raise AttemptErrors::EvidenceUnavailable, "canonical scope failure selectors unavailable" unless failure
101
+ bytes = JSON.generate(failure)
102
+ @client.call("abort_launch", selectors.merge("launch_ticket" => state.fetch("launch_ticket"),
103
+ "expected_generation" => inspected.fetch("generation"), "failure_evidence" => bytes,
104
+ "failure_digest" => Digest::SHA256.hexdigest(bytes)), mutation_id: "#{mutation_id}-scope-abort").data
105
+ end
106
+
107
+ def lifecycle_params(state, binding)
108
+ state.slice("assignment_id", "attempt_id", "launch_ticket").merge("process_binding" => binding,
109
+ "expected_generation" => state.fetch("generation"))
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end