ace-assign 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lifecycle_exclusion"
4
+ require_relative "../authority/posix_acl"
5
+ require "ace/runtime/molecules/protected_artifact_set"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ class LifecycleExclusion
11
+ # A retained reader lease only. It cannot provision, reset or publish.
12
+ # Source-owned original PreparedInput selects its closed projection.
13
+ class WorkspaceReader
14
+ attr_reader :projection
15
+ class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
16
+ def initialize(projection:, acl: Authority::PosixAcl.new, **options)
17
+ super(**options)
18
+ @projection, @acl = projection, acl
19
+ end
20
+
21
+ def verify!(path, handle, directory:)
22
+ super
23
+ unless (handle.stat.mode & 0o7000).zero? && @acl.entries(path).nil? &&
24
+ (!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
25
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader protection differs"
26
+ end
27
+ root = @projection.fetch("root_resource")
28
+ if path == root_path(root)
29
+ actual = handle.stat
30
+ unless directory && [actual.dev, actual.ino, actual.uid, actual.gid, actual.mode & 0o7777] ==
31
+ root.values_at("device", "inode", "uid", "gid") + [0o755]
32
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader root identity differs"
33
+ end
34
+ verify_root_mount!(path, handle, root)
35
+ elsif !directory
36
+ unless handle.stat.nlink == 1 && (handle.stat.mode & 0o7777) == 0o644
37
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader file protection differs"
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def root_path(root) = root.fetch("view_path")
45
+
46
+ def verify_root_mount!(path, handle, root)
47
+ mount = @mounts.mount_identity(handle)
48
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(
49
+ @mounts.read("/proc/self/mountinfo", limit: Ace::Runtime::Molecules::LinuxMountInfo::LIMIT))
50
+ selected = table.by_id(mount.fetch("mount_id"))
51
+ unless mount == selected.slice(*mount.keys) && selected.fetch("mountpoint") == path &&
52
+ selected.fetch("filesystem_type") == root.fetch("filesystem_type") &&
53
+ selected.fetch("options").include?("ro") && !selected.fetch("options").include?("rw")
54
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader view is not the selected readonly mount"
55
+ end
56
+ end
57
+
58
+ def trusted_owner?(stat)
59
+ [0, @projection.fetch("authority_uid")].include?(stat.uid)
60
+ end
61
+ end
62
+
63
+ def initialize(projection:, protection: nil, files: File)
64
+ @projection = JSON.parse(JSON.generate(projection))
65
+ validate_projection!
66
+ pending = [@projection]
67
+ until pending.empty?
68
+ value = pending.pop
69
+ pending.concat(value.values) if value.is_a?(Hash)
70
+ pending.concat(value) if value.is_a?(Array)
71
+ value.freeze
72
+ end
73
+ @root = @projection.fetch("root_resource").fetch("view_path")
74
+ @protection = protection || Protection.new(projection: @projection)
75
+ @files = files
76
+ @handles, @lock, @active = {}, nil, false
77
+ end
78
+
79
+ def acquire!
80
+ raise AttemptErrors::Conflict, "Lifecycle reader already acquired" if @active || !@handles.empty?
81
+ parts = @root.split("/").reject(&:empty?)
82
+ (["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
83
+ pin!(path, directory: true)
84
+ end
85
+ @lock = pin!(path_for("lock"), directory: false)
86
+ stat = @lock.stat
87
+ unless stat.size.zero? && [stat.uid, stat.gid] == @projection.values_at("authority_uid", "authority_gid")
88
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader immutable lock differs"
89
+ end
90
+ unless @lock.flock(lock_mode | File::LOCK_NB)
91
+ raise AttemptErrors::MaintenanceBusy, "Lifecycle workspace is under maintenance"
92
+ end
93
+ @active = true
94
+ marker = pin!(path_for("state.json"), directory: false)
95
+ state = read_marker!(marker)
96
+ owner = [marker.stat.uid, marker.stat.gid]
97
+ unless owner == @projection.values_at("authority_uid", "authority_gid") || state.fetch("removed") && owner == [0, 0]
98
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker selected owner differs"
99
+ end
100
+ require_live_marker!(state)
101
+ verify_unchanged!
102
+ self
103
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, ArgumentError
104
+ close!
105
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle reader admission is unavailable"
106
+ rescue Exception
107
+ close!
108
+ raise
109
+ end
110
+
111
+ def verify_unchanged!
112
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader is not held" unless @active
113
+ @handles.each do |path, entry|
114
+ unless identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
115
+ identity(@files.lstat(path)) == entry.fetch(:identity)
116
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader retained object changed"
117
+ end
118
+ @protection.verify!(path, entry.fetch(:handle), directory: entry.fetch(:directory))
119
+ end
120
+ true
121
+ end
122
+
123
+ # The maintained creator calls this only after confirmed completion.
124
+ # Unknown child ownership retains the lease until its owner exits.
125
+ def close!
126
+ begin
127
+ @lock.flock(File::LOCK_UN) if @active && @lock && !@lock.closed?
128
+ ensure
129
+ close_error = nil
130
+ @handles.values.reverse_each do |entry|
131
+ begin
132
+ entry.fetch(:handle).close unless entry.fetch(:handle).closed?
133
+ rescue IOError, SystemCallError => error
134
+ close_error ||= error
135
+ end
136
+ end
137
+ @handles.clear
138
+ @active = false
139
+ raise close_error if close_error
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def require_live_marker!(state)
146
+ raise AttemptErrors::Conflict, "Original lifecycle workspace was fenced for removal" if state.fetch("removed")
147
+ end
148
+
149
+ def lock_mode = File::LOCK_SH
150
+
151
+ def validate_projection!
152
+ fields = %w[authority_gid authority_uid key root_resource worker_cwd_resource]
153
+ resource_fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
154
+ unless @projection.is_a?(Hash)
155
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
156
+ end
157
+ root, cwd = @projection.values_at("root_resource", "worker_cwd_resource")
158
+ unless @projection.keys.sort == fields && [root, cwd].all? { |value| value.is_a?(Hash) && value.keys.sort == resource_fields } &&
159
+ %w[authority_uid authority_gid].all? { |key| @projection[key].is_a?(Integer) && @projection[key].positive? }
160
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
161
+ end
162
+ unless [root, cwd].all? { |value|
163
+ %w[host_path view_path].all? { |key| value[key].is_a?(String) && value[key].valid_encoding? &&
164
+ value[key].bytesize.between?(1, 4096) && !value[key].include?("\0") && value[key].start_with?("/") &&
165
+ File.expand_path(value[key]) == value[key] } &&
166
+ %w[device inode uid gid].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 } &&
167
+ value["mount_id"].is_a?(Integer) && value["mount_id"].positive? &&
168
+ %w[ext4 xfs btrfs tmpfs].include?(value["filesystem_type"]) }
169
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader resource differs"
170
+ end
171
+ mapping = root.fetch("view_path").delete_prefix("/run/ace/lifecycle-exclusion/")
172
+ digest = Atoms::EvidenceDigest.digest(cwd)
173
+ unless mapping.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) &&
174
+ @projection.fetch("key") == "workspace:#{mapping}:#{digest}" &&
175
+ root.fetch("host_path").end_with?("/workspaces/#{mapping}/#{digest}") &&
176
+ root.values_at("uid", "gid") == @projection.values_at("authority_uid", "authority_gid")
177
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader association differs"
178
+ end
179
+ end
180
+
181
+ def pin!(path, directory:)
182
+ file = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
183
+ @protection.verify!(path, file, directory: directory)
184
+ stat = file.stat
185
+ unless (directory ? stat.directory? : stat.file?) && identity(stat) == identity(@files.lstat(path))
186
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader object is substituted"
187
+ end
188
+ @handles[path] = {handle: file, identity: identity(stat), directory: directory}
189
+ file
190
+ ensure
191
+ file&.close unless @handles.key?(path)
192
+ end
193
+
194
+ def identity(stat)
195
+ core = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
196
+ stat.directory? ? core : core + [stat.nlink, stat.size, stat.mtime, stat.ctime]
197
+ end
198
+
199
+ def path_for(suffix)
200
+ File.join(@root, "#{Digest::SHA256.hexdigest(@projection.fetch('key'))}.#{suffix}")
201
+ end
202
+
203
+ def read_marker!(file)
204
+ size = file.stat.size
205
+ unless size.between?(1, 8192)
206
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
207
+ end
208
+ bytes = file.read(8193).force_encoding(Encoding::UTF_8)
209
+ unless bytes.valid_encoding? && bytes.bytesize == size
210
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
211
+ end
212
+ state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @projection.fetch("key"))
213
+ @marker_bytes = bytes.freeze
214
+ state
215
+ end
216
+ end
217
+ # Native activation owns this host lease for the original scope lifetime.
218
+ # An admission deadline must not become a provider lifetime ceiling.
219
+ class WorkspaceNativeReader < WorkspaceReader
220
+ def initialize(projection:, protection: nil, files: File)
221
+ super(projection: projection, protection: protection, files: files)
222
+ @root = @projection.fetch("root_resource").fetch("host_path")
223
+ @protection = protection || WorkspaceHostReader::Protection.new(projection: @projection)
224
+ end
225
+ end
226
+
227
+ class WorkspaceHostReader < WorkspaceReader
228
+ class Protection < WorkspaceReader::Protection
229
+ private
230
+ def root_path(root) = root.fetch("host_path")
231
+ def verify_root_mount!(_path, handle, root)
232
+ unless @mounts.mount_identity(handle).fetch("filesystem_type") == root.fetch("filesystem_type")
233
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle writer filesystem differs"
234
+ end
235
+ end
236
+ end
237
+
238
+ def initialize(projection:, protection: nil, files: File)
239
+ super(projection: projection, protection: protection, files: files)
240
+ @root = @projection.fetch("root_resource").fetch("host_path")
241
+ @protection = protection || Protection.new(projection: @projection)
242
+ end
243
+
244
+ def acquire!(deadline:)
245
+ @deadline = deadline
246
+ deadline!(deadline)
247
+ super()
248
+ deadline!(deadline)
249
+ self
250
+ rescue Exception
251
+ close!
252
+ raise
253
+ end
254
+
255
+ def verify_unchanged!
256
+ deadline!(@deadline) unless @deadline.nil?
257
+ super
258
+ end
259
+
260
+ private
261
+ def deadline!(value)
262
+ unless value.is_a?(Numeric) && value.finite? && value > Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ raise AttemptErrors::MaintenanceBusy, "Lifecycle maintenance deadline elapsed"
264
+ end
265
+ end
266
+ end
267
+
268
+ # Inspection evidence only: never permits creation or publication.
269
+ class WorkspaceFenceReader < WorkspaceHostReader
270
+ def fence_evidence!
271
+ verify_unchanged!
272
+ bytes = @marker_bytes
273
+ reference = {"path" => path_for("state.json").freeze,
274
+ "sha256" => Digest::SHA256.hexdigest(bytes).freeze, "bytes" => bytes.bytesize}.freeze
275
+ {"reference" => reference, "bytes" => bytes}.freeze
276
+ end
277
+
278
+ private
279
+ def require_live_marker!(_state); end
280
+ end
281
+
282
+ class WorkspaceWriter < WorkspaceHostReader
283
+ # Refusal fence only; canonical physical proof is owned separately.
284
+ def publish_removed!(removed_at:)
285
+ verify_unchanged!
286
+ unless removed_at.is_a?(String) && Time.iso8601(removed_at).utc.iso8601 == removed_at
287
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle fence timestamp differs"
288
+ end
289
+ bytes = Atoms::EvidenceDigest.canonical_json({"key" => @projection.fetch("key"),
290
+ "removed" => true, "removed_at" => removed_at}).freeze
291
+ target = path_for("state.json")
292
+ temporary = File.join(@root, ".#{SecureRandom.hex(16)}.fence")
293
+ begin
294
+ @files.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o644) do |file|
295
+ file.chmod(0o644)
296
+ file.write(bytes)
297
+ file.flush
298
+ unless file.stat.file? && file.stat.nlink == 1 && file.stat.size == bytes.bytesize &&
299
+ [file.stat.uid, file.stat.gid, file.stat.mode & 0o7777] == [0, 0, 0o644]
300
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle temporary fence protection differs"
301
+ end
302
+ file.fsync
303
+ end
304
+ verify_unchanged!
305
+ @files.rename(temporary, target)
306
+ @handles.fetch(@root).fetch(:handle).fsync
307
+ previous = @handles.delete(target)
308
+ previous.fetch(:handle).close
309
+ marker = pin!(target, directory: false)
310
+ unless [marker.stat.uid, marker.stat.gid] == [0, 0] && read_marker!(marker).fetch("removed")
311
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle published fence owner differs"
312
+ end
313
+ verify_unchanged!
314
+ reference = {"path" => target.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
315
+ "bytes" => bytes.bytesize}.freeze
316
+ {"reference" => reference, "bytes" => bytes}.freeze
317
+ ensure
318
+ @files.unlink(temporary) if @files.exist?(temporary)
319
+ end
320
+ rescue SystemCallError, IOError, ArgumentError
321
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle removal fence publication is unavailable"
322
+ end
323
+
324
+ private
325
+ def lock_mode = File::LOCK_EX
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_workspace_exclusion"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ class LifecycleExclusion
9
+ # Original full-privilege operator phase, before publication/start.
10
+ # It only provisions derived exclusion artifacts, never a workspace.
11
+ class WorkspaceProvisioner
12
+ class Protection < WorkspaceHostReader::Protection
13
+ private
14
+ def root_path(_root) = nil
15
+ end
16
+
17
+ def initialize(mapping_id:, project_id:, authority:, cwd_resource:, files: File, directories: Dir, protection: nil)
18
+ @selection = LifecycleExclusion.workspace_selection(mapping_id: mapping_id, project_id: project_id,
19
+ authority: authority, cwd_resource: cwd_resource)
20
+ @authority = authority.slice("state_root", "uid", "gid").freeze
21
+ @files, @directories = files, directories
22
+ @protection = protection || Protection.new(projection: {"authority_uid" => authority.fetch("uid"),
23
+ "authority_gid" => authority.fetch("gid"), "root_resource" => {}})
24
+ @handles = {}
25
+ end
26
+
27
+ def provision!
28
+ root = @authority.fetch("state_root")
29
+ parts = root.split("/").reject(&:empty?)
30
+ (["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
31
+ hold_directory!(path)
32
+ end
33
+ selected_directory!(@handles.fetch(root), mode: 0o700)
34
+ relative = @selection.fetch("host_path").delete_prefix(root + "/").split("/")
35
+ parent = root
36
+ relative.each_with_index do |part, index|
37
+ path = File.join(parent, part)
38
+ mode = index == relative.length - 1 ? 0o755 : 0o700
39
+ provision_directory!(path, parent, mode)
40
+ verify_unchanged!
41
+ parent = path
42
+ end
43
+ project = File.dirname(File.dirname(File.dirname(@selection.fetch("host_path"))))
44
+ provision_directory!(File.join(project, "control"), project, 0o700)
45
+ root_stat = @handles.fetch(@selection.fetch("host_path")).stat
46
+ host_identity = {"device" => root_stat.dev, "inode" => root_stat.ino,
47
+ "uid" => root_stat.uid, "gid" => root_stat.gid}.freeze
48
+ # Resource mount identity is observed by the existing boundary owner;
49
+ # provision returns only association/host inode, not a namespace pin.
50
+ provision_file!("lock", "")
51
+ marker = provision_file!("state.json", LifecycleExclusion.workspace_initial_marker(key: @selection.fetch("key")))
52
+ bytes = marker.read(8193)
53
+ state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @selection.fetch("key"))
54
+ raise AttemptErrors::Conflict, "Original lifecycle workspace is already fenced" if state.fetch("removed")
55
+ unless [marker.stat.uid, marker.stat.gid] == @authority.values_at("uid", "gid")
56
+ raise AttemptErrors::EvidenceUnavailable, "Initial lifecycle marker owner differs"
57
+ end
58
+ verify_unchanged!
59
+ result = @selection.merge("host_identity" => host_identity)
60
+ result.fetch("host_identity").freeze
61
+ result.each_value(&:freeze)
62
+ result.freeze
63
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
64
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle provisioning is unavailable"
65
+ ensure
66
+ close!
67
+ end
68
+
69
+ private
70
+
71
+ def provision_directory!(path, parent, mode)
72
+ created = begin
73
+ @directories.mkdir(path, 0o700)
74
+ true
75
+ rescue Errno::EEXIST
76
+ false
77
+ end
78
+ handle = hold_directory!(path)
79
+ if created
80
+ handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
81
+ handle.chmod(mode)
82
+ handle.fsync
83
+ @handles.fetch(parent).fsync
84
+ end
85
+ selected_directory!(handle, mode: mode)
86
+ end
87
+
88
+ def hold_directory!(path)
89
+ handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
90
+ unless handle.stat.directory? && identity(handle.stat) == identity(@files.lstat(path))
91
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning directory differs"
92
+ end
93
+ @protection.verify!(path, handle, directory: true)
94
+ @handles[path] = handle
95
+ ensure
96
+ handle&.close unless @handles[path] == handle
97
+ end
98
+
99
+ def selected_directory!(handle, mode:)
100
+ stat = handle.stat
101
+ unless [stat.uid, stat.gid, stat.mode & 0o7777] == @authority.values_at("uid", "gid") + [mode]
102
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning selected directory differs"
103
+ end
104
+ end
105
+
106
+ def provision_file!(suffix, bytes)
107
+ root = @selection.fetch("host_path")
108
+ path = File.join(root, "#{Digest::SHA256.hexdigest(@selection.fetch('key'))}.#{suffix}")
109
+ created = true
110
+ handle = begin
111
+ @files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
112
+ rescue Errno::EEXIST
113
+ created = false
114
+ @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
115
+ end
116
+ if created
117
+ handle.write(bytes)
118
+ handle.flush
119
+ handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
120
+ handle.chmod(0o644)
121
+ handle.fsync
122
+ @handles.fetch(root).fsync
123
+ end
124
+ stat = handle.stat
125
+ unless stat.file? && stat.nlink == 1 && (stat.mode & 0o7777) == 0o644 &&
126
+ identity(stat) == identity(@files.lstat(path)) && stat.size <= 8192 &&
127
+ (suffix != "lock" || stat.size.zero? && [stat.uid, stat.gid] == @authority.values_at("uid", "gid"))
128
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning file differs"
129
+ end
130
+ @protection.verify!(path, handle, directory: false)
131
+ handle.rewind
132
+ @handles[path] = handle
133
+ ensure
134
+ handle&.close unless @handles[path] == handle
135
+ end
136
+
137
+ def verify_unchanged!
138
+ @handles.each do |path, handle|
139
+ unless identity(handle.stat) == identity(@files.lstat(path))
140
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioned object was replaced"
141
+ end
142
+ @protection.verify!(path, handle, directory: handle.stat.directory?)
143
+ end
144
+ end
145
+
146
+ def identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
147
+
148
+ def close!
149
+ error = nil
150
+ @handles.values.reverse_each do |handle|
151
+ begin
152
+ handle.close unless handle.closed?
153
+ rescue SystemCallError, IOError => caught
154
+ error ||= caught
155
+ end
156
+ end
157
+ @handles.clear
158
+ raise error if error
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -53,19 +53,42 @@ module Ace
53
53
  end.compact
54
54
  end
55
55
 
56
+ # Source-authenticated callers retain exact step/report bytes through
57
+ # validation and parsing. No pathname reopen chooses executable work.
58
+ def scan_captured(steps:, assignment:, reports:)
59
+ parsed = Atoms::StepSorter.sort(steps.keys).map do |filename|
60
+ path = File.join(assignment.steps_dir, filename)
61
+ info = Atoms::StepFileParser.parse_filename(filename)
62
+ report_name = Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
63
+ report = reports[report_name]
64
+ report = Atoms::StepFileParser.parse(report).fetch(:body).strip if report
65
+ parse_step_content(path, steps.fetch(filename), report)
66
+ end
67
+ Models::QueueState.new(steps: parsed, assignment: assignment)
68
+ end
69
+
56
70
  private
57
71
 
58
72
  def parse_step_file(file_path)
59
73
  content = File.read(file_path)
74
+ filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
75
+ report = load_report(file_path, filename_info[:number], filename_info[:name])
76
+ parse_step_content(file_path, content, report)
77
+ rescue ArgumentError => e
78
+ warn "Invalid step file #{file_path}: #{e.message}"
79
+ nil
80
+ rescue => e
81
+ warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
82
+ nil
83
+ end
84
+
85
+ def parse_step_content(file_path, content, report)
60
86
  parsed = Atoms::StepFileParser.parse(content)
61
87
  fields = Atoms::StepFileParser.extract_fields(parsed)
62
88
 
63
89
  # Extract number and name from filename
64
90
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
65
91
 
66
- # Load report from separate file if it exists
67
- report = load_report(file_path, filename_info[:number], filename_info[:name])
68
-
69
92
  Models::Step.new(
70
93
  number: filename_info[:number],
71
94
  name: fields[:name] || filename_info[:name],
@@ -93,14 +116,6 @@ module Ace
93
116
  stall_reason: fields[:stall_reason],
94
117
  file_path: file_path
95
118
  )
96
- rescue ArgumentError => e
97
- # ArgumentError indicates invalid data (e.g., invalid context value)
98
- # Surface these errors visibly to help users fix configuration
99
- warn "Invalid step file #{file_path}: #{e.message}"
100
- nil
101
- rescue => e
102
- warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
103
- nil
104
119
  end
105
120
 
106
121
  # Load report from the reports/ directory
@@ -24,12 +24,16 @@ module Ace
24
24
  # @param identity_resolver [ExecutionIdentityResolver] Trust boundary
25
25
  # @param artifact_reader [#call, nil] Source-owned canonical byte
26
26
  # reader for protected composition; nil selects explicit local files
27
- def initialize(identity_resolver: nil, artifact_reader: nil)
27
+ def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
28
28
  @identity_resolver = identity_resolver || ExecutionIdentityResolver.new
29
29
  unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
30
30
  raise ArgumentError, "artifact reader must be a source-owned callable"
31
31
  end
32
32
  @artifact_reader = artifact_reader
33
+ unless campaign_verifier.nil? || campaign_verifier.respond_to?(:call)
34
+ raise ArgumentError, "campaign verifier must be a source-owned callable"
35
+ end
36
+ @campaign_verifier = campaign_verifier
33
37
  end
34
38
 
35
39
  # Validate a submitted receipt against an attempt.
@@ -93,7 +97,11 @@ module Ace
93
97
  # readers always reverify imported bytes, including historical reads.
94
98
  def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
95
99
  verify_head(data, live_head)
96
- verify_artifacts(data, repo_root) if @artifact_reader || !historical
100
+ artifacts = data["artifacts"]
101
+ reject_unless(artifacts.is_a?(Array), "accepted artifacts must be an array")
102
+ if (@artifact_reader || !historical) && (data["verdict"] == "succeeded" || !artifacts.empty?)
103
+ verify_artifacts(data, repo_root)
104
+ end
97
105
  verify_checks(data)
98
106
  verify_review(data)
99
107
  verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
@@ -239,20 +247,18 @@ module Ace
239
247
  end
240
248
  result = JSON.parse(artifact_bytes(data, reference, repo_root))
241
249
  reject_unless(result.is_a?(Hash), "campaign result must be an object")
242
- require "ace/review"
243
- current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
244
- unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
245
- result["dry_run"] == false && current["accepted"] == true &&
246
- result["result_identity"] == current["result_identity"] &&
247
- result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
248
- reject("campaign result is stale, incomplete, blocked or does not match live authority")
249
- end
250
- approval = current["rounds"].last["approval"]
251
- unless approval["producer"] == data.dig("producer", "actor") &&
252
- approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
253
- approval["head"] == data["head"]
254
- reject("campaign producer/reviewer attribution does not match independently validated receipt")
250
+ if @campaign_verifier
251
+ @campaign_verifier.call(data, result, live_head)
252
+ return
255
253
  end
254
+ require "ace/review"
255
+ manager = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root)
256
+ current = manager.status(campaign["id"])
257
+ reject("campaign result ID differs from receipt") unless result["campaign_id"] == campaign["id"]
258
+ manager.with_verified_result!(result: result, subject: current.fetch("subject"),
259
+ contract_identity: current.fetch("contract_identity"), policy: current.fetch("effective_policy"),
260
+ head: live_head, base: current.fetch("evidence").fetch("current_base"),
261
+ producer: data.dig("producer", "actor"), reviewer: data.dig("review", "reviewer", "actor")) { true }
256
262
  rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
257
263
  reject("invalid campaign result: #{e.message}")
258
264
  end