ace-assign 0.64.0 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -4,10 +4,17 @@ require "ace/runtime/molecules/cgroup_observation"
4
4
  require "ace/runtime/molecules/systemd_scope_manager"
5
5
  require "ace/runtime/molecules/linux_mount_info"
6
6
  require "ace/runtime/molecules/execution_unit_installation"
7
+ require "ace/runtime/molecules/network_installation_evidence"
8
+ require "ace/runtime/molecules/execution_network_selection"
9
+ require "ace/runtime/molecules/execution_boot_baseline"
10
+ require "ace/runtime/molecules/kernel_view_topology"
7
11
  require_relative "../molecules/execution_scope_lineage"
12
+ require_relative "../molecules/lifecycle_exclusion"
8
13
  require_relative "posix_acl"
14
+ require_relative "maintenance_workspace_retirement"
9
15
  require "digest"
10
16
  require "json"
17
+ require "ace/herdr/molecules/protected_native_control"
11
18
 
12
19
  module Ace
13
20
  module Assign
@@ -50,8 +57,18 @@ module Ace
50
57
  end
51
58
 
52
59
  def namespace_identity
53
- stat = File.stat("/proc/self/ns/mnt")
54
- {"device" => stat.dev, "inode" => stat.ino}
60
+ File.open("/proc/self/ns/mnt", File::RDONLY) do |namespace|
61
+ stat = namespace.stat
62
+ {"device" => stat.dev, "inode" => stat.ino}
63
+ end
64
+ end
65
+
66
+ def authority_socket_identity(authority)
67
+ wire = Ace::Runtime::Molecules::ProtectedSocket
68
+ wire.root_path!(File.dirname(authority.fetch("socket_path")), directory: true, owner: authority.fetch("uid"))
69
+ identity = wire.socket_identity(authority.fetch("socket_path"))
70
+ raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless identity.last == authority.fetch("uid")
71
+ identity
55
72
  end
56
73
 
57
74
  def pin_network_namespace(path)
@@ -90,6 +107,12 @@ module Ace
90
107
  raise
91
108
  end
92
109
 
110
+ def resource_topology(path, mount_id:)
111
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
112
+ record = table.by_id(mount_id)
113
+ {"major_minor" => record.fetch("major_minor"), "filesystem_path" => table.filesystem_path(record, path)}
114
+ end
115
+
93
116
  def resource_boundary_policy(path)
94
117
  stat = File.lstat(path)
95
118
  unless stat.directory? && File.realpath(path) == path
@@ -115,6 +138,37 @@ module Ace
115
138
  end
116
139
  end
117
140
 
141
+ def lifecycle_resource_policy!(path, authority:, identity:)
142
+ state_root = authority.fetch("state_root")
143
+ unless path.start_with?(state_root + "/lifecycle-exclusion/") && File.expand_path(path) == path
144
+ raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource ancestry differs"
145
+ end
146
+ parts = path.split("/").reject(&:empty?)
147
+ paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
148
+ paths.each do |ancestor|
149
+ policy = resource_boundary_policy(ancestor)
150
+ unless [0, authority.fetch("uid")].include?(policy.fetch("uid")) &&
151
+ (policy.fetch("mode") & 0o7022).zero? && policy.fetch("acl").nil? && resource_default_acl(ancestor).nil?
152
+ raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource ancestry is writable"
153
+ end
154
+ if ancestor == state_root || ancestor == path
155
+ mode = ancestor == path ? 0o755 : 0o700
156
+ unless policy.values_at("uid", "gid", "mode") == authority.values_at("uid", "gid") + [mode]
157
+ raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource selected ownership differs"
158
+ end
159
+ end
160
+ if ancestor == path && policy.values_at("device", "inode", "uid", "gid") !=
161
+ identity.values_at("device", "inode", "uid", "gid")
162
+ raise Ace::Runtime::RuntimeUnavailableError, "lifecycle policy belongs to a replaced object"
163
+ end
164
+ end
165
+ true
166
+ end
167
+
168
+ def resource_default_acl(path)
169
+ PosixAcl.new.entries(path, attribute: "system.posix_acl_default")
170
+ end
171
+
118
172
  def worker_uid_quiescent!(uid)
119
173
  table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
120
174
  proc_mounts = table.records.select { |record| record.fetch("mountpoint") == "/proc" }
@@ -142,6 +196,13 @@ module Ace
142
196
  true
143
197
  end
144
198
 
199
+ def resource_present?(path)
200
+ File.lstat(path)
201
+ true
202
+ rescue Errno::ENOENT
203
+ false
204
+ end
205
+
145
206
  def boundary_manifest(scope)
146
207
  path = "/etc/ace/execution-slots/#{scope.fetch('slot_id')}/boundary-manifest.json"
147
208
  bytes = Ace::Runtime::Molecules::ExecutionUnitInstallation::Files.new.read(path, limit: 65_536)
@@ -154,15 +215,22 @@ module Ace
154
215
  end
155
216
  end
156
217
 
157
- def initialize(mapping_id:, deployment:, kernel:, manager: nil, cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: Files.new)
218
+ def initialize(mapping_id:, deployment:, kernel:, manager: nil, cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: Files.new, network_evidence: Ace::Runtime::Molecules::NetworkInstallationEvidence.new, boot_evidence: Ace::Runtime::Molecules::ExecutionBootBaseline.new, network_selection: Ace::Runtime::Molecules::ExecutionNetworkSelection.new, workspace_reader_factory: nil)
158
219
  @mapping_id, @deployment, @kernel, @cgroups, @files = mapping_id, deployment, kernel, cgroups, files
220
+ @network_evidence = network_evidence
221
+ @boot_evidence = boot_evidence
222
+ @network_selection = network_selection
223
+ @workspace_reader_factory = workspace_reader_factory || ->(projection:) {
224
+ Molecules::LifecycleExclusion::WorkspaceNativeReader.new(projection: projection)
225
+ }
226
+ @native_workspace_lifetime = nil
159
227
  @map = deployment.mapping(mapping_id)
160
228
  @scope = @map.fetch("execution_scope")
161
229
  @manager = manager || Ace::Runtime::Molecules::SystemdScopeManager.new(
162
230
  slice_unit: @scope.fetch("slice_unit"), service_unit: @scope.fetch("service_unit"))
163
231
  end
164
232
 
165
- def observe(lineage)
233
+ def observe(lineage, retirement: nil)
166
234
  @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
167
235
  binding = lineage.binding
168
236
  unless binding && binding.fetch("slot_id") == @scope.fetch("slot_id") && binding.fetch("boot_id") == @files.boot_id &&
@@ -175,13 +243,14 @@ module Ace
175
243
  network_selection! == binding.fetch("network_installation_selection")
176
244
  unavailable!("retained installed network namespace or selection changed")
177
245
  end
246
+ boot_baseline!(binding)
178
247
  before = @manager.inspect_activation
179
248
  verify_parent!(binding, before.fetch("slice"))
180
249
  pinned = @cgroups.pin(binding.fetch("cgroup_identity").fetch("path"), expected: binding.fetch("cgroup_identity"))
181
- unless parent_resources! == binding.fetch("resource_identities")
250
+ unless parent_resources!(retirement: retirement) == binding.fetch("resource_identities")
182
251
  unavailable!("retained parent inventory changed")
183
252
  end
184
- verify_resources!(binding.fetch("resource_identities"))
253
+ verify_resources!(binding.fetch("resource_identities"), retirement: retirement)
185
254
  value = @cgroups.observe(pinned)
186
255
  after = @manager.inspect_activation
187
256
  unless before == after && @files.boot_id == binding.fetch("boot_id")
@@ -205,6 +274,8 @@ module Ace
205
274
  network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
206
275
  @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
207
276
  boot = @files.boot_id
277
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@map)))
278
+ baseline = @boot_evidence.select!(expected: boot_baseline_expected(boot, digest, selection))
208
279
  namespace = @files.namespace_identity
209
280
  before = @manager.inspect_activation
210
281
  service = before.fetch("service")
@@ -228,10 +299,11 @@ module Ace
228
299
  unavailable!("fresh parent changed or acquired processes before binding")
229
300
  end
230
301
  verify_resources!(inventory)
231
- context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => Digest::SHA256.hexdigest(JSON.generate(canonical(@map))),
302
+ context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => digest,
232
303
  "boot_id" => boot, "slice_invocation_id" => parent.fetch("InvocationID"), "cgroup_identity" => pinned.fetch(:identity),
233
304
  "resource_mount_namespace_identity" => namespace, "resource_identities" => inventory,
234
- "network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity))
305
+ "network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity),
306
+ "boot_baseline_selection" => baseline.fetch("selection"))
235
307
  rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError, JSON::ParserError
236
308
  unavailable!("fresh parent inventory or activation is unavailable")
237
309
  ensure
@@ -239,25 +311,137 @@ module Ace
239
311
  network&.fetch(:handle)&.close
240
312
  end
241
313
 
242
- def verify_closed!(lineage)
243
- value = observe(lineage)
314
+ def verify_closed!(lineage, retirement: nil)
315
+ value = observe(lineage, retirement: retirement)
244
316
  service = value.fetch("activation").fetch("service")
245
317
  unless lineage.sealed? && lineage.proof_event && value.fetch("populated").zero? &&
246
318
  %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("MainPID").zero? &&
247
319
  service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
248
320
  unavailable!("canonical closure has a live or pending native activation")
249
321
  end
322
+ native_cleanup_complete!
323
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
250
324
  if lineage.native_event
251
325
  native = lineage.native_event.fetch("payload")
252
326
  invocation = service.fetch("InvocationID")
253
327
  unless invocation.empty? || invocation == native.fetch("service_invocation_id")
254
328
  unavailable!("closed native incarnation was replaced")
255
329
  end
256
- verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false)
330
+ verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false, retirement: retirement)
331
+ end
332
+ native_cleanup_complete!
333
+ repeated = observe(lineage, retirement: retirement)
334
+ unless repeated.fetch("populated").zero? && repeated.fetch("activation") == value.fetch("activation")
335
+ unavailable!("scope activation changed during closure verification")
257
336
  end
258
337
  true
259
338
  end
260
339
 
340
+ def readiness_peer!(lineage, peer)
341
+ lineage.require_open!
342
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
343
+ activation = observe(lineage).fetch("activation")
344
+ service = activation.fetch("service")
345
+ profile = @manager.inspect_profile
346
+ command = profile.fetch("service").fetch("ExecStartPostEx").first
347
+ unless service.fetch("ControlPID").positive? && peer.fetch("pid") == service.fetch("ControlPID") &&
348
+ command.fetch(7) == peer.fetch("pid") && service.fetch("MainPID").positive? &&
349
+ service.fetch("MainPID") != peer.fetch("pid") &&
350
+ peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
351
+ unavailable!("peer is not the exact fixed readiness actor")
352
+ end
353
+ server = @kernel.capture(service.fetch("MainPID"))
354
+ unless server.values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
355
+ unavailable!("native server principal differs")
356
+ end
357
+ @kernel.live!(peer)
358
+ unavailable!("readiness activation changed") unless @manager.inspect_activation == activation
359
+ server
360
+ end
361
+
362
+ def verify_readiness_report!(lineage, peer, report, challenge:)
363
+ server = readiness_peer!(lineage, peer)
364
+ unless report.is_a?(Hash) && report.keys.sort == %w[challenge_id kernel_view_topology mount_namespace_identity network_namespace_identity resource_identities resource_observer_identity resource_topology server_identity version] &&
365
+ report["version"] == 1 && report["challenge_id"] == challenge.fetch("challenge_id") &&
366
+ @kernel.same?(report.fetch("server_identity"), server) && @kernel.same?(report.fetch("resource_observer_identity"), peer)
367
+ unavailable!("private readiness report binding differs")
368
+ end
369
+ network = report.fetch("network_namespace_identity")
370
+ unless network.is_a?(Hash) && network.keys.sort == %w[device inode] &&
371
+ network.values.all? { |value| value.is_a?(Integer) && value.positive? } &&
372
+ network == lineage.binding.fetch("network_namespace_identity") &&
373
+ network == lineage.admission_event.dig("payload", "data", "network_installation", "namespace_identity")
374
+ unavailable!("original server network namespace differs from admitted installation")
375
+ end
376
+ entries = @files.boundary_manifest(@scope).fetch("resources").select { |entry| entry.fetch("worker_visible") }
377
+ resources = report.fetch("resource_identities")
378
+ unless resources.is_a?(Array) && resources.map { |entry| entry.values_at("host_path", "view_path") }.sort ==
379
+ entries.map { |entry| entry.values_at("host_path", "view_path") }.sort
380
+ unavailable!("readiness reachable resource union differs")
381
+ end
382
+ topology = report.fetch("resource_topology")
383
+ unless topology.is_a?(Array) && topology.size == resources.size &&
384
+ topology.map { |entry| entry.values_at("host_path", "view_path") }.sort ==
385
+ entries.map { |entry| entry.values_at("host_path", "view_path") }.sort
386
+ unavailable!("readiness resource mount topology differs")
387
+ end
388
+ resources.each do |resource|
389
+ declaration = entries.find { |entry| entry.values_at("host_path", "view_path") == resource.values_at("host_path", "view_path") }
390
+ view = topology.find { |entry| entry.values_at("host_path", "view_path") == resource.values_at("host_path", "view_path") }
391
+ unless view.keys.sort == %w[host_path major_minor mountpoint options root view_path] &&
392
+ view["options"].is_a?(Array) && view["options"].include?(declaration.fetch("read_only") ? "ro" : "rw") &&
393
+ view["mountpoint"].is_a?(String) && (resource.fetch("view_path") == view["mountpoint"] ||
394
+ resource.fetch("view_path").start_with?(view["mountpoint"].delete_suffix("/") + "/"))
395
+ unavailable!("readiness mount flags or projection differ")
396
+ end
397
+ host_identity = @files.resource_identity(resource.fetch("host_path"))
398
+ host = @files.resource_topology(resource.fetch("host_path"), mount_id: host_identity.fetch("mount_id"))
399
+ physical = File.expand_path(File.join(view.fetch("root"), resource.fetch("view_path").delete_prefix(view.fetch("mountpoint")).delete_prefix("/")))
400
+ unless host.fetch("major_minor") == view.fetch("major_minor") && host.fetch("filesystem_path") == physical
401
+ unavailable!("readiness mounted source or subtree differs")
402
+ end
403
+ end
404
+ verify_resources!(resources, same_namespace: false)
405
+ binding = lineage.binding
406
+ baseline = boot_baseline!(binding)
407
+ authority = @deployment.authority(@map.fetch("authority_id"))
408
+ endpoint = @files.authority_socket_identity(authority)
409
+ writable = entries.reject { |entry| entry.fetch("read_only") }.map do |entry|
410
+ host = @files.resource_identity(entry.fetch("host_path"))
411
+ @files.resource_topology(entry.fetch("host_path"), mount_id: host.fetch("mount_id"))
412
+ .merge("view_path" => entry.fetch("view_path"), "filesystem_type" => host.fetch("filesystem_type"))
413
+ end
414
+ Ace::Runtime::Molecules::KernelViewTopology.new.verify!(topology: report.fetch("kernel_view_topology"),
415
+ host_ipc: baseline.fetch("host_ipc_namespace_identity"), authority_socket: endpoint, writable_resources: writable,
416
+ host_devpts: baseline.fetch("host_devpts_identity"), selected_devpts: baseline.fetch("selected_devpts"))
417
+ unavailable!("authority endpoint changed during view verification") unless endpoint == @files.authority_socket_identity(authority)
418
+ report.slice("server_identity", "resource_observer_identity", "mount_namespace_identity", "resource_identities").merge(
419
+ "scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
420
+ "service_invocation_id" => @manager.inspect_activation.fetch("service").fetch("InvocationID"),
421
+ "workspace_id" => @map.fetch("native").fetch("workspace_id"),
422
+ "network_namespace_identity" => network.dup,
423
+ "network_admission_event_id" => lineage.admission_event.fetch("digest"))
424
+ end
425
+
426
+ def complete_native_readiness!(lineage, payload)
427
+ unavailable!("completed activation lacks authenticated readiness") unless payload.is_a?(Hash)
428
+ activation = observe(lineage).fetch("activation")
429
+ service = activation.fetch("service")
430
+ unless service.fetch("ActiveState") == "active" && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"] &&
431
+ service.fetch("MainPID") == payload.dig("server_identity", "pid") &&
432
+ service.fetch("InvocationID") == payload.fetch("service_invocation_id")
433
+ unavailable!("native service has not completed the exact admitted activation")
434
+ end
435
+ @kernel.live!(payload.fetch("server_identity"))
436
+ socket = Ace::Runtime::Molecules::ProtectedSocket.socket_identity(@map.fetch("native").fetch("socket_path"))
437
+ value = payload.merge("socket_identity" => socket)
438
+ native_map = @map.merge("native" => @map.fetch("native").merge(value.slice("server_identity", "socket_identity")))
439
+ Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: native_map, kernel: @kernel).preflight!
440
+ verify_resources!(value.fetch("resource_identities"), same_namespace: false)
441
+ unavailable!("native activation changed during preflight") unless @manager.inspect_activation == activation
442
+ value
443
+ end
444
+
261
445
  def native_admission_ready!(lineage)
262
446
  value = observe(lineage)
263
447
  service = value.fetch("activation").fetch("service")
@@ -265,16 +449,34 @@ module Ace
265
449
  service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
266
450
  unavailable!("native admission has an occupied or pending generation")
267
451
  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")
452
+ binding = lineage.binding
453
+ selection = binding.fetch("network_installation_selection")
454
+ evidence = @network_evidence.verify!(selection: selection, expected: {
455
+ "slot_id" => @scope.fetch("slot_id"), "namespace_path" => @scope.fetch("network_namespace_path"),
456
+ "boot_id" => binding.fetch("boot_id"), "namespace_identity" => binding.fetch("network_namespace_identity"),
457
+ "installer_artifact_sha256" => selection.fetch("installer_artifact").fetch("sha256")})
458
+ selector = lineage.binding_event.fetch("digest")
459
+ if @native_workspace_lifetime
460
+ unavailable!("native workspace lifetime belongs to another scope") unless @native_workspace_lifetime.fetch(:selector) == selector
461
+ @native_workspace_lifetime.fetch(:reader).verify_unchanged!
462
+ else
463
+ reader = @workspace_reader_factory.call(projection: workspace_exclusion_projection!(lineage))
464
+ reader.acquire!
465
+ @native_workspace_lifetime = {selector: selector, reader: reader}
466
+ end
467
+ evidence
272
468
  end
273
469
 
274
470
  def start_admitted_service!
471
+ unavailable!("native workspace lifetime is not retained") unless @native_workspace_lifetime
472
+ @native_workspace_lifetime.fetch(:reader).verify_unchanged!
275
473
  @manager.start_service
276
474
  end
277
475
 
476
+ # The original source callback passes the maintained dedicated selection.
477
+ # It cannot choose a different slot, slice, principal or unit at launch.
478
+
479
+
278
480
  def sealed_service_stop_required?(lineage)
279
481
  unavailable!("service stop requires the canonical original seal") unless lineage.sealed?
280
482
  service = observe(lineage).fetch("activation").fetch("service")
@@ -282,11 +484,47 @@ module Ace
282
484
  service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?)
283
485
  end
284
486
 
487
+
488
+
285
489
  def stop_sealed_service!
286
490
  @manager.stop_service
287
491
  end
288
492
 
493
+ # Read-only original owner check used before all-root retirement.
494
+ def verify_maintenance_closed!(lineages, retirement: nil)
495
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
496
+ activation = @manager.inspect_activation
497
+ service, slice = activation.values_at("service", "slice")
498
+ unless %w[inactive failed].include?(service.fetch("ActiveState")) &&
499
+ service.fetch("Job") == [0, "/"] && service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? &&
500
+ slice.fetch("Job") == [0, "/"]
501
+ unavailable!("maintenance has live or pending fixed-unit activation")
502
+ end
503
+ parent_resources!(retirement: retirement)
504
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
505
+ if %w[inactive failed].include?(slice.fetch("ActiveState"))
506
+ unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
507
+ unavailable!("stopped maintenance parent is unknown")
508
+ end
509
+ unless slice.fetch("ControlGroup").empty?
510
+ pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + slice.fetch("ControlGroup"))
511
+ unavailable!("stopped maintenance parent has writers") unless @cgroups.observe(pinned).fetch("populated").zero?
512
+ end
513
+ else
514
+ exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
515
+ unavailable!("maintenance parent has no unique canonical released owner") unless exact.size == 1
516
+ verify_closed!(exact.first, retirement: retirement)
517
+ end
518
+ unless @manager.inspect_activation == activation
519
+ unavailable!("maintenance fixed activation changed during observation")
520
+ end
521
+ true
522
+ ensure
523
+ pinned&.fetch(:handle)&.close
524
+ end
525
+
289
526
  def retire_released_parent!(lineages)
527
+ verify_maintenance_closed!(lineages)
290
528
  @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
291
529
  activation = @manager.inspect_activation
292
530
  service = activation.fetch("service")
@@ -323,42 +561,141 @@ module Ace
323
561
  service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
324
562
  unavailable!("sealed parent still has writers or pending activation")
325
563
  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")
564
+ native_cleanup_complete!
565
+ # A stopped admitted-but-unbound start can have executed. Its proof
566
+ # therefore uses the same positive writer baseline as a native bound
567
+ # generation; absence of a binding is never a no-execution assertion.
568
+ if lineage.native_event
569
+ native = lineage.native_event.fetch("payload")
570
+ invocation = service.fetch("InvocationID")
571
+ unless invocation.empty? || invocation == native.fetch("service_invocation_id")
572
+ unavailable!("closed native incarnation was replaced")
573
+ end
574
+ verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false)
334
575
  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
576
  current = parent_resources!
338
577
  unless current == lineage.binding.fetch("resource_identities") &&
339
578
  @files.namespace_identity == lineage.binding.fetch("resource_mount_namespace_identity")
340
- unavailable!("never-admitted parent resources or observation namespace changed")
579
+ unavailable!("sealed parent resources or observation namespace changed")
341
580
  end
342
581
  @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
582
+ native_cleanup_complete!
343
583
  # Rejoin object and population after the credential baseline, keeping
344
584
  # 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")
585
+ repeated = observe(lineage)
586
+ unless repeated.fetch("populated").zero? && repeated.fetch("activation") == value.fetch("activation")
587
+ unavailable!("sealed parent acquired a writer or pending activation during baseline observation")
347
588
  end
348
- lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
589
+ proof = lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
349
590
  "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
591
+ if @native_workspace_lifetime
592
+ unavailable!("closed proof differs from native workspace lifetime") unless @native_workspace_lifetime.fetch(:selector) == lineage.binding_event.fetch("digest")
593
+ @native_workspace_lifetime.fetch(:reader).verify_unchanged!
594
+ @native_workspace_lifetime.fetch(:reader).close!
595
+ @native_workspace_lifetime = nil
596
+ end
597
+ proof
598
+ end
599
+
600
+ # Declaration bytes are selected by this original observer's pinned
601
+ # deployment digest. A workspace cannot inherit eligibility merely
602
+ # because another parent resource has the same host pathname.
603
+ def maintenance_workspace_resource!(lineage)
604
+ manifest = @files.boundary_manifest(@scope)
605
+ declarations = manifest.fetch("resources").select { |entry| entry.fetch("host_path") == @map.fetch("worker_cwd") }
606
+ unless declarations.one? && declarations.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, false]
607
+ unavailable!("workspace original declaration is not a writable parent view")
608
+ end
609
+ declaration = declarations.first
610
+ resources = lineage.binding.fetch("resource_identities").select do |entry|
611
+ entry.values_at("host_path", "view_path") == declaration.values_at("host_path", "view_path")
612
+ end
613
+ unless resources.one? && %w[mount_id device inode].all? { |key| resources.first.fetch(key).is_a?(Integer) && resources.first.fetch(key).positive? }
614
+ unavailable!("workspace original parent identity is unavailable")
615
+ end
616
+ resources.first
617
+ rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError
618
+ unavailable!("workspace original boundary is unavailable")
619
+ end
620
+
621
+ def maintenance_parent_resource_declarations!(lineage)
622
+ declarations = @files.boundary_manifest(@scope).fetch("resources").select { |entry| entry.fetch("stage") == "parent" }.map do |entry|
623
+ entry.slice("host_path", "view_path", "stage", "worker_visible", "read_only")
624
+ end
625
+ identities = lineage.binding.fetch("resource_identities")
626
+ selectors = declarations.map { |entry| entry.values_at("host_path", "view_path") }
627
+ bound = identities.map { |entry| entry.values_at("host_path", "view_path") }
628
+ unless selectors.uniq == selectors && bound.uniq == bound && selectors.sort == bound.sort &&
629
+ declarations.all? { |entry| entry.keys.sort == %w[host_path read_only stage view_path worker_visible] } &&
630
+ identities.all? { |entry| entry.keys.sort == Molecules::ExecutionScopeLineage::RESOURCE_FIELDS.sort }
631
+ unavailable!("original parent declarations differ from bound identities")
632
+ end
633
+ declarations
634
+ rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError
635
+ unavailable!("original parent resource declarations are unavailable")
636
+ end
637
+
638
+ def workspace_exclusion_projection!(lineage)
639
+ declarations = maintenance_parent_resource_declarations!(lineage)
640
+ cwd = maintenance_workspace_resource!(lineage)
641
+ authority = @deployment.authority(@map.fetch("authority_id"))
642
+ selection = Molecules::LifecycleExclusion.workspace_selection(mapping_id: @mapping_id,
643
+ project_id: @map.fetch("project_id"), authority: authority, cwd_resource: cwd)
644
+ matches = declarations.select do |entry|
645
+ entry.values_at("host_path", "view_path") == selection.values_at("host_path", "view_path")
646
+ end
647
+ roots = lineage.binding.fetch("resource_identities").select do |entry|
648
+ entry.values_at("host_path", "view_path") == selection.values_at("host_path", "view_path")
649
+ end
650
+ unless matches.one? && matches.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, true] &&
651
+ roots.one? && roots.first.values_at("uid", "gid") == authority.values_at("uid", "gid")
652
+ unavailable!("original workspace lifecycle exclusion is unavailable")
653
+ end
654
+ value = JSON.parse(JSON.generate({"key" => selection.fetch("key"), "root_resource" => roots.first,
655
+ "worker_cwd_resource" => cwd, "authority_uid" => authority.fetch("uid"), "authority_gid" => authority.fetch("gid")}))
656
+ pending = [value]
657
+ until pending.empty?
658
+ entry = pending.pop
659
+ pending.concat(entry.values) if entry.is_a?(Hash)
660
+ pending.concat(entry) if entry.is_a?(Array)
661
+ entry.freeze
662
+ end
663
+ value
664
+ rescue KeyError, TypeError, ArgumentError
665
+ unavailable!("original workspace lifecycle projection differs")
350
666
  end
351
667
 
352
668
  private
353
669
 
670
+ def boot_baseline!(binding)
671
+ @boot_evidence.verify!(selection: binding.fetch("boot_baseline_selection"), expected:
672
+ boot_baseline_expected(binding.fetch("boot_id"), binding.fetch("deployment_digest"),
673
+ binding.fetch("network_installation_selection")))
674
+ end
675
+
676
+ def boot_baseline_expected(boot, digest, selection)
677
+ {"slot_id" => @scope.fetch("slot_id"), "boot_id" => boot, "deployment_digest" => digest,
678
+ "installer_artifact" => selection.fetch("installer_artifact")}
679
+ end
680
+
354
681
  # Inventory only: no configurable security booleans or alternative
355
682
  # policy backend. Native-only objects are observed at their later stage.
683
+ def native_cleanup_complete!
684
+ @files.boundary_manifest(@scope).fetch("resources").each do |entry|
685
+ next unless entry.fetch("stage") == "native"
686
+ if @files.resource_present?(entry.fetch("host_path"))
687
+ unavailable!("fixed service cleanup retained a service-created resource")
688
+ end
689
+ end
690
+ true
691
+ end
692
+
356
693
  def network_selection!
357
694
  manifest = @files.boundary_manifest(@scope)
358
- Molecules::ExecutionScopeLineage.validate_network_selection!(manifest.fetch("network_installation"))
695
+ @network_selection.select!(static_selection: manifest.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
359
696
  end
360
697
 
361
- def parent_resources!
698
+ def parent_resources!(retirement: nil)
362
699
  manifest = @files.boundary_manifest(@scope)
363
700
  unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
364
701
  manifest["schema"] == BOUNDARY_SCHEMA && manifest["slot_id"] == @scope.fetch("slot_id") &&
@@ -367,7 +704,10 @@ module Ace
367
704
  end
368
705
  pairs = []
369
706
  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"])
707
+ unless resource.is_a?(Hash) && resource.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
708
+ [true, false].include?(resource["worker_visible"]) && [true, false].include?(resource["read_only"]) &&
709
+ (resource["worker_visible"] || resource["stage"] == "parent" && resource["read_only"]) &&
710
+ (resource["stage"] != "native" || resource["worker_visible"]) && %w[parent native].include?(resource["stage"])
371
711
  unavailable!("boundary resource inventory is not closed")
372
712
  end
373
713
  paths = resource.values_at("host_path", "view_path")
@@ -377,30 +717,72 @@ module Ace
377
717
  end
378
718
  pairs << paths
379
719
  next if resource.fetch("stage") == "native"
380
- actual = @files.resource_identity(resource.fetch("host_path"))
720
+ actual = retirement_resource_identity(resource.fetch("host_path"), retirement) || @files.resource_identity(resource.fetch("host_path"))
381
721
  unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type"))
382
722
  unavailable!("parent resource backing is not local kernel-managed storage")
383
723
  end
384
724
  resource.slice("host_path", "view_path").merge(actual)
385
725
  end
386
726
  unavailable!("parent resource inventory is empty") if resources.empty?
387
- verify_parent_access_boundary!(resources)
727
+ verify_parent_access_boundary!(resources, declarations: manifest.fetch("resources"), retirement: retirement)
388
728
  resources
389
729
  end
390
730
 
391
- def verify_parent_access_boundary!(resources)
731
+ def lifecycle_parent_resource!(resources, declarations)
732
+ view = "/run/ace/lifecycle-exclusion/#{@mapping_id}"
733
+ selected = declarations.select { |entry| entry.fetch("view_path") == view }
734
+ return nil if selected.empty?
735
+ unless selected.one? && selected.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, true]
736
+ unavailable!("lifecycle resource is not the exact readonly parent view")
737
+ end
738
+ cwd = resources.select { |resource| resource.fetch("host_path") == @map.fetch("worker_cwd") }
739
+ cwd_declarations = declarations.select { |entry| entry.fetch("host_path") == @map.fetch("worker_cwd") }
740
+ unless cwd.one? && cwd_declarations.one? &&
741
+ cwd_declarations.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, false]
742
+ unavailable!("lifecycle resource lacks original writable workspace identity")
743
+ end
744
+ authority = @deployment.authority(@map.fetch("authority_id"))
745
+ selection = Molecules::LifecycleExclusion.workspace_selection(mapping_id: @mapping_id,
746
+ project_id: @map.fetch("project_id"), authority: authority, cwd_resource: cwd.first)
747
+ expected = selection.fetch("host_path")
748
+ resource = resources.find { |entry| entry.values_at("host_path", "view_path") == [expected, view] }
749
+ unless selected.first.fetch("host_path") == expected && resource &&
750
+ resource.values_at("uid", "gid") == authority.values_at("uid", "gid")
751
+ unavailable!("lifecycle resource is not selected by original workspace and authority")
752
+ end
753
+ @files.lifecycle_resource_policy!(expected, authority: authority, identity: resource)
754
+ @files.resource_aliases(expected, identity: resource).each do |path|
755
+ @files.lifecycle_resource_policy!(path, authority: authority, identity: resource)
756
+ end
757
+ resource
758
+ end
759
+
760
+ def verify_parent_access_boundary!(resources, declarations:, retirement: nil)
761
+ lifecycle = lifecycle_parent_resource!(resources, declarations)
762
+ resources = resources.reject { |resource| resource.equal?(lifecycle) }
763
+ covered = resources.select { |resource| retirement_covered_resource(resource, retirement) }
764
+ vanished = retirement && retirement.descriptor.fetch("phase") != "captured" ? covered : []
765
+ resources = resources - vanished
392
766
  worker = @map.fetch("worker_uid")
393
767
  authority = @deployment.authority(@map.fetch("authority_id")).fetch("uid")
394
768
  readers = @deployment.project(@map.fetch("project_id")).fetch("supervisor_uids")
395
769
  trusted = [worker, authority, @map.fetch("launcher_uid"), *readers].uniq
396
770
  policies = resources.to_h do |resource|
397
771
  path = resource.fetch("host_path")
398
- policy = @files.resource_boundary_policy(path)
772
+ observed_path = retirement_observation_path(resource, retirement)
773
+ policy = @files.resource_boundary_policy(observed_path)
399
774
  unless %w[device inode uid gid].all? { |key| policy.fetch(key) == resource.fetch(key) }
400
775
  unavailable!("parent access policy belongs to a replaced object")
401
776
  end
402
777
  [path, policy]
403
778
  end
779
+ captured_ancestors = covered.flat_map do |resource|
780
+ path = retirement_observation_path(resource, retirement)
781
+ next [] if path == resource.fetch("host_path")
782
+ parts = File.dirname(path).split("/").reject(&:empty?)
783
+ ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
784
+ end.uniq
785
+ captured_ancestors.each { |path| policies[path] = @files.resource_boundary_policy(path) }
404
786
  barriers = policies.select do |_path, policy|
405
787
  next false unless policy.fetch("uid").zero? && (policy.fetch("mode") & 0o7022).zero?
406
788
  acl = policy.fetch("acl")
@@ -417,13 +799,13 @@ module Ace
417
799
  end
418
800
  end
419
801
  end
420
- unless resources.any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
421
- path = resource.fetch("host_path")
802
+ unless (resources + vanished).any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
803
+ path = retirement_observation_path(resource, retirement)
422
804
  [0, worker].include?(resource.fetch("uid")) && barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
423
805
  unavailable!("parent roots lack an immutable exclusive host traversal boundary")
424
806
  end
425
807
  resources.each do |resource|
426
- aliases = @files.resource_aliases(resource.fetch("host_path"), identity: resource)
808
+ aliases = @files.resource_aliases(retirement_observation_path(resource, retirement), identity: resource)
427
809
  unless aliases.all? { |path| barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
428
810
  unavailable!("protected parent backing is exposed through an outside writable mount")
429
811
  end
@@ -439,11 +821,11 @@ module Ace
439
821
  end
440
822
  end
441
823
 
442
- def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true)
824
+ def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true, retirement: nil)
443
825
  resources.each do |resource|
444
826
  path = resource.fetch("host_path")
445
827
  begin
446
- actual = @files.resource_identity(path)
828
+ actual = retirement_resource_identity(path, retirement) || @files.resource_identity(path)
447
829
  rescue Errno::ENOENT
448
830
  runtime = @scope.fetch("runtime_directory")
449
831
  # Fixed stopped RuntimeDirectoryPreserve=no cleanup may remove
@@ -459,6 +841,51 @@ module Ace
459
841
  end
460
842
  end
461
843
 
844
+ def retirement_covered_resource(resource, retirement)
845
+ return nil unless retirement
846
+ unless retirement.is_a?(MaintenanceWorkspaceRetirement::Evidence)
847
+ unavailable!("maintenance retirement requires source-owned proof")
848
+ end
849
+ retirement.verify_consumption!
850
+ entry = retirement.covered_resource(resource.fetch("host_path"))
851
+ unavailable!("retirement row differs from original resource") if entry && entry.fetch("original") != resource
852
+ entry
853
+ end
854
+
855
+ def retirement_resource_identity(path, retirement)
856
+ return nil unless retirement
857
+ unless retirement.is_a?(MaintenanceWorkspaceRetirement::Evidence)
858
+ unavailable!("maintenance retirement requires source-owned proof")
859
+ end
860
+ retirement.verify_consumption!
861
+ entry = retirement.covered_resource(path)
862
+ return nil unless entry
863
+ resource = entry.fetch("original")
864
+ return resource.reject { |key, _| %w[host_path view_path].include?(key) } unless retirement.descriptor.fetch("phase") == "captured"
865
+ observed_path = retirement_observation_path(resource, retirement)
866
+ actual = @files.resource_identity(observed_path)
867
+ unless %w[device inode uid gid mount_id filesystem_type].all? { |key| actual.fetch(key) == resource.fetch(key) }
868
+ unavailable!("retirement captured path differs from original resource")
869
+ end
870
+ actual
871
+ end
872
+
873
+ def retirement_observation_path(resource, retirement)
874
+ entry = retirement_covered_resource(resource, retirement)
875
+ return resource.fetch("host_path") unless entry && retirement.descriptor.fetch("phase") == "captured"
876
+ held = retirement.owner.retirement_resource_handle!(evidence: retirement, resource: resource)
877
+ unless held.is_a?(Hash) && held.keys.sort == %i[captured_host_path handle].sort &&
878
+ held.fetch(:captured_host_path) == entry.fetch("captured_host_path")
879
+ unavailable!("retirement captured resource handle differs")
880
+ end
881
+ handle = held.fetch(:handle)
882
+ stat = handle.stat
883
+ unless !handle.closed? && stat.directory? && [stat.dev, stat.ino, stat.uid, stat.gid] == resource.values_at("device", "inode", "uid", "gid")
884
+ unavailable!("retirement captured resource is not original held inode")
885
+ end
886
+ held.fetch(:captured_host_path)
887
+ end
888
+
462
889
  def canonical(value)
463
890
  case value
464
891
  when Hash then value.sort.to_h.transform_values { |item| canonical(item) }