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,903 @@
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 "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"
11
+ require_relative "../molecules/execution_scope_lineage"
12
+ require_relative "../molecules/lifecycle_exclusion"
13
+ require_relative "posix_acl"
14
+ require_relative "maintenance_workspace_retirement"
15
+ require "digest"
16
+ require "json"
17
+ require "ace/herdr/molecules/protected_native_control"
18
+
19
+ module Ace
20
+ module Assign
21
+ module Authority
22
+ # Joins already canonical scope facts to current native owner objects.
23
+ # This does not manufacture readiness, a network-policy assertion, or a
24
+ # canonical no-writer event from a population snapshot.
25
+ class ExecutionScopeObservation
26
+ LOCAL_FILESYSTEMS = %w[ext4 xfs btrfs tmpfs].freeze
27
+ BOUNDARY_SCHEMA = "ace.execution-boundary-manifest/v1"
28
+
29
+ class Files
30
+ def boot_id
31
+ value = File.read("/proc/sys/kernel/random/boot_id", 128).strip
32
+ unless value.match?(Molecules::ExecutionScopeLineage::BOOT)
33
+ raise Ace::Runtime::RuntimeUnavailableError, "kernel boot identity is malformed"
34
+ end
35
+ value
36
+ end
37
+
38
+ def resource_identity(path)
39
+ unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && File.realpath(path) == path
40
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource path is substituted"
41
+ end
42
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |directory|
43
+ stat = directory.stat
44
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource is not a directory" unless stat.directory?
45
+ info = File.read("/proc/self/fdinfo/#{directory.fileno}", 16_385)
46
+ ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
47
+ raise Ace::Runtime::RuntimeUnavailableError, "resource mount identity is ambiguous" unless ids.size == 1
48
+ bytes = File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1)
49
+ mount = Ace::Runtime::Molecules::LinuxMountInfo.new(bytes).by_id(Integer(ids.first, 10))
50
+ current = File.stat(path)
51
+ unless current.dev == stat.dev && current.ino == stat.ino && File.realpath(path) == path
52
+ raise Ace::Runtime::RuntimeUnavailableError, "scope resource changed while observing it"
53
+ end
54
+ {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
55
+ "filesystem_type" => mount.fetch("filesystem_type"), "mount_id" => mount.fetch("mount_id")}
56
+ end
57
+ end
58
+
59
+ def namespace_identity
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
72
+ end
73
+
74
+ def pin_network_namespace(path)
75
+ unless RUBY_PLATFORM.include?("linux") && path.is_a?(String) && path.start_with?("/") &&
76
+ path.bytesize.between?(1, 4096) && !path.include?("\0") && File.expand_path(path) == path && File.realpath(path) == path
77
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace path is substituted"
78
+ end
79
+ parts = path.split("/").reject(&:empty?)
80
+ paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
81
+ paths.each do |ancestor|
82
+ stat = File.lstat(ancestor)
83
+ unless stat.uid.zero? && !stat.symlink? && (stat.mode & 0o022).zero? &&
84
+ (ancestor == path || stat.directory?)
85
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ancestry is writable"
86
+ end
87
+ # The leaf is authenticated as nsfs below. nsfs has no access
88
+ # ACL/xattr owner and exposes only namespace ioctls, not writes.
89
+ next if ancestor == path
90
+ acl = PosixAcl.new.entries(ancestor)
91
+ if acl && acl.any? { |tag, permissions, uid| (permissions & 2) != 0 && !(tag == 1 || tag == 2 && uid.zero?) }
92
+ raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ACL permits untrusted mutation"
93
+ end
94
+ end
95
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW)
96
+ stat = handle.stat
97
+ info = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385)
98
+ ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
99
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
100
+ unless ids.size == 1 && table.by_id(Integer(ids.first, 10)).fetch("filesystem_type") == "nsfs" &&
101
+ handle.ioctl(0xb703) == 0x40000000 && [File.stat(path).dev, File.stat(path).ino] == [stat.dev, stat.ino]
102
+ raise Ace::Runtime::RuntimeUnavailableError, "installed object is not the exact nsfs network namespace"
103
+ end
104
+ {handle: handle, identity: {"device" => stat.dev, "inode" => stat.ino}}
105
+ rescue StandardError
106
+ handle&.close
107
+ raise
108
+ end
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
+
116
+ def resource_boundary_policy(path)
117
+ stat = File.lstat(path)
118
+ unless stat.directory? && File.realpath(path) == path
119
+ raise Ace::Runtime::RuntimeUnavailableError, "protected root policy object is substituted"
120
+ end
121
+ {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
122
+ "mode" => stat.mode & 0o7777, "acl" => PosixAcl.new.entries(path)}
123
+ end
124
+
125
+ def resource_aliases(path, identity:)
126
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
127
+ original = table.by_id(identity.fetch("mount_id"))
128
+ physical = table.filesystem_path(original, path)
129
+ table.records.filter_map do |mount|
130
+ next unless mount.fetch("major_minor") == original.fetch("major_minor") && mount.fetch("options").include?("rw")
131
+ root = mount.fetch("root")
132
+ if physical == root || root == "/" || physical.start_with?(root + "/")
133
+ suffix = physical.delete_prefix(root).delete_prefix("/")
134
+ File.join(mount.fetch("mountpoint"), suffix)
135
+ elsif root.start_with?(physical + "/")
136
+ mount.fetch("mountpoint")
137
+ end
138
+ end
139
+ end
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
+
172
+ def worker_uid_quiescent!(uid)
173
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
174
+ proc_mounts = table.records.select { |record| record.fetch("mountpoint") == "/proc" }
175
+ unless proc_mounts.size == 1 && proc_mounts.first.fetch("filesystem_type") == "proc" && proc_mounts.first.fetch("root") == "/" &&
176
+ (proc_mounts.first.fetch("options") + proc_mounts.first.fetch("super_options")).none? { |option| option.start_with?("hidepid=") && option != "hidepid=0" }
177
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline requires a fully visible authority proc view"
178
+ end
179
+ pids = Dir.children("/proc").select { |name| name.match?(/\A[1-9][0-9]*\z/) }
180
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline is oversized" if pids.size > 100_000
181
+ pids.each do |pid|
182
+ begin
183
+ bytes = File.open("/proc/#{pid}/status", File::RDONLY | File::NOFOLLOW) { |file| file.read(65_537) }
184
+ rescue Errno::ENOENT, Errno::ESRCH
185
+ next # A dead process cannot retain a writable userspace handle.
186
+ end
187
+ rows = bytes.lines.select { |line| line.start_with?("Uid:") }
188
+ match = rows.size == 1 && /\AUid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*\z/.match(rows.first)
189
+ unless bytes.bytesize <= 65_536 && match
190
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline has unreadable credential evidence"
191
+ end
192
+ if match.captures.map { |value| Integer(value, 10) }.include?(uid)
193
+ raise Ace::Runtime::RuntimeUnavailableError, "worker UID has a process outside the never-admitted generation"
194
+ end
195
+ end
196
+ true
197
+ end
198
+
199
+ def resource_present?(path)
200
+ File.lstat(path)
201
+ true
202
+ rescue Errno::ENOENT
203
+ false
204
+ end
205
+
206
+ def boundary_manifest(scope)
207
+ path = "/etc/ace/execution-slots/#{scope.fetch('slot_id')}/boundary-manifest.json"
208
+ bytes = Ace::Runtime::Molecules::ExecutionUnitInstallation::Files.new.read(path, limit: 65_536)
209
+ unless Digest::SHA256.hexdigest(bytes) == scope.fetch("boundary_manifest_sha256")
210
+ raise Ace::Runtime::RuntimeUnavailableError, "boundary inventory differs from fixed deployment"
211
+ end
212
+ JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
213
+ rescue JSON::ParserError, EncodingError
214
+ raise Ace::Runtime::RuntimeUnavailableError, "fixed boundary inventory is not strict UTF-8 JSON"
215
+ end
216
+ end
217
+
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)
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
227
+ @map = deployment.mapping(mapping_id)
228
+ @scope = @map.fetch("execution_scope")
229
+ @manager = manager || Ace::Runtime::Molecules::SystemdScopeManager.new(
230
+ slice_unit: @scope.fetch("slice_unit"), service_unit: @scope.fetch("service_unit"))
231
+ end
232
+
233
+ def observe(lineage, retirement: nil)
234
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
235
+ binding = lineage.binding
236
+ unless binding && binding.fetch("slot_id") == @scope.fetch("slot_id") && binding.fetch("boot_id") == @files.boot_id &&
237
+ binding.fetch("deployment_digest") == Digest::SHA256.hexdigest(JSON.generate(canonical(@map))) &&
238
+ binding.fetch("resource_mount_namespace_identity") == @files.namespace_identity
239
+ unavailable!("retained scope deployment or boot changed")
240
+ end
241
+ network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
242
+ unless network.fetch(:identity) == binding.fetch("network_namespace_identity") &&
243
+ network_selection! == binding.fetch("network_installation_selection")
244
+ unavailable!("retained installed network namespace or selection changed")
245
+ end
246
+ boot_baseline!(binding)
247
+ before = @manager.inspect_activation
248
+ verify_parent!(binding, before.fetch("slice"))
249
+ pinned = @cgroups.pin(binding.fetch("cgroup_identity").fetch("path"), expected: binding.fetch("cgroup_identity"))
250
+ unless parent_resources!(retirement: retirement) == binding.fetch("resource_identities")
251
+ unavailable!("retained parent inventory changed")
252
+ end
253
+ verify_resources!(binding.fetch("resource_identities"), retirement: retirement)
254
+ value = @cgroups.observe(pinned)
255
+ after = @manager.inspect_activation
256
+ unless before == after && @files.boot_id == binding.fetch("boot_id")
257
+ unavailable!("scope activation changed while observing it")
258
+ end
259
+ {"populated" => value.fetch("populated"), "activation" => after}
260
+ rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError
261
+ unavailable!("retained scope observation is unavailable")
262
+ ensure
263
+ pinned&.fetch(:handle)&.close
264
+ network&.fetch(:handle)&.close
265
+ end
266
+
267
+ # Called only by the fresh reservation winner with slot exclusion held.
268
+ # It creates no service or payload. Original activation facts are taken
269
+ # after this exact successful StartUnit, never adopted on a later retry.
270
+ def activate_parent!(context)
271
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
272
+ inventory = parent_resources!
273
+ selection = network_selection!
274
+ network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
275
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
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))
279
+ namespace = @files.namespace_identity
280
+ before = @manager.inspect_activation
281
+ service = before.fetch("service")
282
+ slice = before.fetch("slice")
283
+ unless %w[inactive failed].include?(slice.fetch("ActiveState")) && slice.fetch("Job") == [0, "/"] &&
284
+ %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
285
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
286
+ unavailable!("fresh parent activation has retained or pending native ownership")
287
+ end
288
+ @manager.start_slice
289
+ activation = @manager.inspect_activation
290
+ parent = activation.fetch("slice")
291
+ unless parent.fetch("ActiveState") == "active" && parent.fetch("SubState") == "active" &&
292
+ Molecules::ExecutionScopeLineage::INVOCATION.match?(parent.fetch("InvocationID")) && parent.fetch("Job") == [0, "/"] &&
293
+ !parent.fetch("ControlGroup").empty? && activation.fetch("service") == service
294
+ unavailable!("fresh parent job did not produce its exact independent activation")
295
+ end
296
+ pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + parent.fetch("ControlGroup"))
297
+ unless @cgroups.observe(pinned).fetch("populated").zero? && @files.boot_id == boot &&
298
+ @files.namespace_identity == namespace && @manager.inspect_activation == activation
299
+ unavailable!("fresh parent changed or acquired processes before binding")
300
+ end
301
+ verify_resources!(inventory)
302
+ context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => digest,
303
+ "boot_id" => boot, "slice_invocation_id" => parent.fetch("InvocationID"), "cgroup_identity" => pinned.fetch(:identity),
304
+ "resource_mount_namespace_identity" => namespace, "resource_identities" => inventory,
305
+ "network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity),
306
+ "boot_baseline_selection" => baseline.fetch("selection"))
307
+ rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError, JSON::ParserError
308
+ unavailable!("fresh parent inventory or activation is unavailable")
309
+ ensure
310
+ pinned&.fetch(:handle)&.close
311
+ network&.fetch(:handle)&.close
312
+ end
313
+
314
+ def verify_closed!(lineage, retirement: nil)
315
+ value = observe(lineage, retirement: retirement)
316
+ service = value.fetch("activation").fetch("service")
317
+ unless lineage.sealed? && lineage.proof_event && value.fetch("populated").zero? &&
318
+ %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("MainPID").zero? &&
319
+ service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
320
+ unavailable!("canonical closure has a live or pending native activation")
321
+ end
322
+ native_cleanup_complete!
323
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
324
+ if lineage.native_event
325
+ native = lineage.native_event.fetch("payload")
326
+ invocation = service.fetch("InvocationID")
327
+ unless invocation.empty? || invocation == native.fetch("service_invocation_id")
328
+ unavailable!("closed native incarnation was replaced")
329
+ end
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")
336
+ end
337
+ true
338
+ end
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
+
445
+ def native_admission_ready!(lineage)
446
+ value = observe(lineage)
447
+ service = value.fetch("activation").fetch("service")
448
+ unless value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
449
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
450
+ unavailable!("native admission has an occupied or pending generation")
451
+ end
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
468
+ end
469
+
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!
473
+ @manager.start_service
474
+ end
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
+
480
+ def sealed_service_stop_required?(lineage)
481
+ unavailable!("service stop requires the canonical original seal") unless lineage.sealed?
482
+ service = observe(lineage).fetch("activation").fetch("service")
483
+ !(%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
484
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?)
485
+ end
486
+
487
+
488
+
489
+ def stop_sealed_service!
490
+ @manager.stop_service
491
+ end
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
+
526
+ def retire_released_parent!(lineages)
527
+ verify_maintenance_closed!(lineages)
528
+ @deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
529
+ activation = @manager.inspect_activation
530
+ service = activation.fetch("service")
531
+ unless %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
532
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
533
+ unavailable!("released parent retirement has a live or pending service")
534
+ end
535
+ slice = activation.fetch("slice")
536
+ unless slice.fetch("Job") == [0, "/"]
537
+ unavailable!("released parent retirement has a pending parent job")
538
+ end
539
+ if %w[inactive failed].include?(slice.fetch("ActiveState"))
540
+ unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
541
+ unavailable!("stopped parent metadata belongs to an unknown generation")
542
+ end
543
+ return {"state" => "already_retired"}
544
+ end
545
+ exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
546
+ unavailable!("active parent has no unique released canonical owner") unless exact.size == 1
547
+ verify_closed!(exact.first)
548
+ @manager.stop_slice
549
+ after = @manager.inspect_activation
550
+ unless %w[inactive failed].include?(after.fetch("slice").fetch("ActiveState")) &&
551
+ after.fetch("slice").fetch("Job") == [0, "/"] && after.fetch("service") == service
552
+ unavailable!("released parent retirement outcome is unavailable")
553
+ end
554
+ {"state" => "retired", "scope_binding_event_id" => exact.first.binding_event.fetch("digest")}
555
+ end
556
+
557
+ def closed_observation_for_proof!(lineage, events:)
558
+ value = observe(lineage)
559
+ service = value.fetch("activation").fetch("service")
560
+ unless lineage.sealed? && value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
561
+ service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
562
+ unavailable!("sealed parent still has writers or pending activation")
563
+ end
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)
575
+ end
576
+ current = parent_resources!
577
+ unless current == lineage.binding.fetch("resource_identities") &&
578
+ @files.namespace_identity == lineage.binding.fetch("resource_mount_namespace_identity")
579
+ unavailable!("sealed parent resources or observation namespace changed")
580
+ end
581
+ @files.worker_uid_quiescent!(@map.fetch("worker_uid"))
582
+ native_cleanup_complete!
583
+ # Rejoin object and population after the credential baseline, keeping
584
+ # the snapshot bounded by the same retained activation and exclusion.
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")
588
+ end
589
+ proof = lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
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")
666
+ end
667
+
668
+ private
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
+
681
+ # Inventory only: no configurable security booleans or alternative
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
+
693
+ def network_selection!
694
+ manifest = @files.boundary_manifest(@scope)
695
+ @network_selection.select!(static_selection: manifest.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
696
+ end
697
+
698
+ def parent_resources!(retirement: nil)
699
+ manifest = @files.boundary_manifest(@scope)
700
+ unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
701
+ manifest["schema"] == BOUNDARY_SCHEMA && manifest["slot_id"] == @scope.fetch("slot_id") &&
702
+ manifest["resources"].is_a?(Array) && manifest["resources"].size.between?(1, 64)
703
+ unavailable!("fixed boundary resource inventory differs")
704
+ end
705
+ pairs = []
706
+ resources = manifest.fetch("resources").filter_map do |resource|
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"])
711
+ unavailable!("boundary resource inventory is not closed")
712
+ end
713
+ paths = resource.values_at("host_path", "view_path")
714
+ unless paths.all? { |path| path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
715
+ !path.include?("\0") && File.expand_path(path) == path } && !pairs.include?(paths)
716
+ unavailable!("boundary resource paths are substituted or repeated")
717
+ end
718
+ pairs << paths
719
+ next if resource.fetch("stage") == "native"
720
+ actual = retirement_resource_identity(resource.fetch("host_path"), retirement) || @files.resource_identity(resource.fetch("host_path"))
721
+ unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type"))
722
+ unavailable!("parent resource backing is not local kernel-managed storage")
723
+ end
724
+ resource.slice("host_path", "view_path").merge(actual)
725
+ end
726
+ unavailable!("parent resource inventory is empty") if resources.empty?
727
+ verify_parent_access_boundary!(resources, declarations: manifest.fetch("resources"), retirement: retirement)
728
+ resources
729
+ end
730
+
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
766
+ worker = @map.fetch("worker_uid")
767
+ authority = @deployment.authority(@map.fetch("authority_id")).fetch("uid")
768
+ readers = @deployment.project(@map.fetch("project_id")).fetch("supervisor_uids")
769
+ trusted = [worker, authority, @map.fetch("launcher_uid"), *readers].uniq
770
+ policies = resources.to_h do |resource|
771
+ path = resource.fetch("host_path")
772
+ observed_path = retirement_observation_path(resource, retirement)
773
+ policy = @files.resource_boundary_policy(observed_path)
774
+ unless %w[device inode uid gid].all? { |key| policy.fetch(key) == resource.fetch(key) }
775
+ unavailable!("parent access policy belongs to a replaced object")
776
+ end
777
+ [path, policy]
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) }
786
+ barriers = policies.select do |_path, policy|
787
+ next false unless policy.fetch("uid").zero? && (policy.fetch("mode") & 0o7022).zero?
788
+ acl = policy.fetch("acl")
789
+ if acl.nil?
790
+ next (policy.fetch("mode") & 0o077).zero?
791
+ end
792
+ acl.all? do |tag, permissions, uid|
793
+ case tag
794
+ when 1 then permissions == 7
795
+ when 2 then trusted.include?(uid) && (permissions & 2).zero?
796
+ when 4, 8, 32 then permissions.zero?
797
+ when 16 then (permissions & 2).zero?
798
+ else false
799
+ end
800
+ end
801
+ end
802
+ unless (resources + vanished).any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
803
+ path = retirement_observation_path(resource, retirement)
804
+ [0, worker].include?(resource.fetch("uid")) && barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
805
+ unavailable!("parent roots lack an immutable exclusive host traversal boundary")
806
+ end
807
+ resources.each do |resource|
808
+ aliases = @files.resource_aliases(retirement_observation_path(resource, retirement), identity: resource)
809
+ unless aliases.all? { |path| barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
810
+ unavailable!("protected parent backing is exposed through an outside writable mount")
811
+ end
812
+ end
813
+ end
814
+
815
+ def verify_parent!(binding, slice)
816
+ expected = binding.fetch("cgroup_identity").fetch("path").delete_prefix(Ace::Runtime::Molecules::CgroupObservation::ROOT)
817
+ unless slice.fetch("Id") == @scope.fetch("slice_unit") && slice.fetch("ActiveState") == "active" && slice.fetch("SubState") == "active" &&
818
+ slice.fetch("InvocationID") == binding.fetch("slice_invocation_id") && slice.fetch("ControlGroup") == expected &&
819
+ slice.fetch("Job") == [0, "/"]
820
+ unavailable!("retained exact parent slice changed")
821
+ end
822
+ end
823
+
824
+ def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true, retirement: nil)
825
+ resources.each do |resource|
826
+ path = resource.fetch("host_path")
827
+ begin
828
+ actual = retirement_resource_identity(path, retirement) || @files.resource_identity(path)
829
+ rescue Errno::ENOENT
830
+ runtime = @scope.fetch("runtime_directory")
831
+ # Fixed stopped RuntimeDirectoryPreserve=no cleanup may remove
832
+ # native-stage descendants. Absence never supplies the proof:
833
+ # parent identity/population and settled activation did above.
834
+ next if allow_runtime_absence && (path == runtime || path.start_with?(runtime + "/"))
835
+ raise
836
+ end
837
+ unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type")) &&
838
+ (%w[device inode uid gid filesystem_type] + (same_namespace ? ["mount_id"] : [])).all? { |key| actual.fetch(key) == resource.fetch(key) }
839
+ unavailable!("retained resource backing object changed")
840
+ end
841
+ end
842
+ end
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
+
889
+ def canonical(value)
890
+ case value
891
+ when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
892
+ when Array then value.map { |item| canonical(item) }
893
+ else value
894
+ end
895
+ end
896
+
897
+ def unavailable!(message)
898
+ raise Ace::Runtime::RuntimeUnavailableError, message
899
+ end
900
+ end
901
+ end
902
+ end
903
+ end