ace-assign 0.64.0 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -6,9 +6,14 @@ require "rbconfig"
6
6
  require "openssl"
7
7
  require_relative "private_directory"
8
8
  require_relative "posix_acl"
9
+ require_relative "task_context_entry"
10
+ require_relative "campaign_consumer_policy"
11
+ require "ace/runtime/molecules/protected_worker_entry"
9
12
  require "ace/runtime/molecules/protected_socket"
13
+ require "ace/runtime/molecules/protected_artifact_set"
10
14
  require "ace/runtime/molecules/protected_linux"
11
15
  require "ace/runtime/molecules/execution_unit_installation"
16
+ require "ace/runtime/molecules/readiness_configuration"
12
17
 
13
18
  module Ace
14
19
  module Assign
@@ -16,15 +21,48 @@ module Ace
16
21
  class Deployment
17
22
  PATH = "/etc/ace/assignment-authorities.json"
18
23
  TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
19
- attr_reader :data
24
+ attr_reader :data, :artifact_reference
20
25
 
21
26
  def self.load
22
- wire = Ace::Runtime::Molecules::ProtectedSocket
23
- wire.root_path!(PATH)
24
- raise ArgumentError, "deployment map is oversized" if File.size(PATH) > 65_536
25
- new(JSON.parse(File.read(PATH)))
27
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
28
+ bytes, reference = artifacts.read_path!(PATH, limit: 65_536)
29
+ deployment = from_verified_bytes(bytes, reference)
30
+ artifacts.verify_unchanged!
31
+ deployment
32
+ end
33
+ end
34
+
35
+ # Trusted installer source call only; this is not a transport selector.
36
+ # Authentication establishes fixed descriptor bytes, not installed readiness.
37
+ def self.load_artifact(reference)
38
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
39
+ reference["path"].is_a?(String) && reference["path"].start_with?("/") &&
40
+ !reference["path"].include?("\0") && File.expand_path(reference["path"]) == reference["path"] &&
41
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
42
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536)
43
+ raise ArgumentError, "invalid protected deployment artifact reference"
44
+ end
45
+ selected = reference.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
46
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
47
+ deployment = from_verified_bytes(artifacts.read!(selected), selected)
48
+ artifacts.verify_unchanged!
49
+ deployment
50
+ end
26
51
  end
27
52
 
53
+ def self.from_verified_bytes(raw, reference)
54
+ bytes = raw.dup.force_encoding(Encoding::UTF_8)
55
+ raise ArgumentError, "deployment artifact is not UTF-8" unless bytes.valid_encoding?
56
+ raise ArgumentError, "deployment artifact exceeds bounds" unless bytes.bytesize.between?(1, 65_536)
57
+ value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
58
+ allow_duplicate_key: false, allow_comments: false)
59
+ deployment = new(value)
60
+ deployment.send(:freeze_data!, deployment.data)
61
+ deployment.instance_variable_set(:@artifact_reference, reference)
62
+ deployment.freeze
63
+ end
64
+ private_class_method :from_verified_bytes
65
+
28
66
  def initialize(data)
29
67
  unless data.is_a?(Hash) && data["schema"] == "ace.assign.authorities/v2" &&
30
68
  data.keys.sort == %w[authorities launch_mappings projects schema]
@@ -46,16 +84,18 @@ module Ace
46
84
  end
47
85
  @data["projects"].each_value do |project|
48
86
  raise ArgumentError, "invalid project mapping" unless project.is_a?(Hash)
49
- keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root
87
+ keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root campaign_policy
50
88
  launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids peer_credentials]
51
89
  keys << "service_receivers" if project.key?("service_receivers")
52
90
  keys << "inbox_contexts" if project.key?("inbox_contexts")
53
91
  strict!(project, keys)
54
- %w[journal_repository evidence_checkout_root assignment_root candidate_root].each { |key| path!(project.fetch(key)) }
92
+ CampaignConsumerPolicy.reference!(project.fetch("campaign_policy"))
93
+ %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].each { |key| path!(project.fetch(key)) }
55
94
  unless project["evidence_git_ref"] == "refs/ace/execution"
56
95
  raise ArgumentError, "protected authority uses the canonical execution ref"
57
96
  end
58
- role_uids = %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].flat_map do |key|
97
+ role_keys = %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids]
98
+ role_uids = role_keys.flat_map do |key|
59
99
  values = project.fetch(key)
60
100
  unless values.is_a?(Array) && values.all? { |id| id.is_a?(Integer) && id.positive? } && values == values.sort.uniq
61
101
  raise ArgumentError, "invalid project principal allowlist"
@@ -105,10 +145,19 @@ module Ace
105
145
  raise ArgumentError, "receiver endpoint must be separate from private staging"
106
146
  end
107
147
  protected_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
108
- @data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| project.fetch(key) } }
148
+ @data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| project.fetch(key) } }
109
149
  if staging.any? { |root| protected_roots.any? { |protected| paths_overlap?(root, protected) } }
110
150
  raise ArgumentError, "receiver staging overlaps authority state"
111
151
  end
152
+ campaign_roots = @data.fetch("projects").values.flat_map { |project| project.values_at("campaign_repository", "campaign_store_root") }
153
+ other_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
154
+ @data.fetch("projects").values.flat_map { |project|
155
+ project.values_at("journal_repository", "evidence_checkout_root", "assignment_root", "candidate_root") +
156
+ project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } }
157
+ if campaign_roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
158
+ campaign_roots.any? { |root| other_roots.any? { |other| paths_overlap?(root, other) } }
159
+ raise ArgumentError, "campaign roots overlap another protected or worker root"
160
+ end
112
161
  repositories = @data.fetch("projects").values.map { |project| project.fetch("journal_repository") }
113
162
  raise ArgumentError, "canonical project repository has duplicate mappings" unless repositories.uniq.size == repositories.size
114
163
  @data.fetch("projects").each_key do |project_id|
@@ -141,24 +190,13 @@ module Ace
141
190
  unless Process.uid == service.fetch("uid")
142
191
  raise Ace::Runtime::RuntimeUnavailableError, "inbox resolver must run as its installed authority"
143
192
  end
144
- PrivateDirectory.verify!(context.fetch("deliveries_dir"))
145
- verify_inbox_acl!(context.fetch("deliveries_dir"), private_leaf: true)
146
- key_path = context.fetch("receipt_public_key")
147
- client_path = context.fetch("pi_queue_client")
148
- [key_path, client_path].each do |path|
149
- Ace::Runtime::Molecules::ProtectedSocket.root_path!(path)
150
- verify_inbox_acl!(path)
151
- unless File.file?(path) && !File.symlink?(path) && File.readable?(path)
152
- raise Ace::Runtime::RuntimeUnavailableError, "installed inbox artifact is unavailable"
153
- end
154
- end
155
- unless File.executable?(client_path) && Digest::SHA256.file(client_path).hexdigest == context.fetch("pi_queue_client_sha256")
156
- raise Ace::Runtime::RuntimeUnavailableError, "installed inbox identity client differs"
157
- end
158
- raise Ace::Runtime::RuntimeUnavailableError, "installed inbox public key is oversized" if File.size(key_path) > 16_384
159
- key = OpenSSL::PKey.read(File.binread(key_path))
160
- unless key.is_a?(OpenSSL::PKey::RSA) && !key.private?
161
- raise Ace::Runtime::RuntimeUnavailableError, "installed inbox key must be public-only RSA"
193
+ credentials = context.fetch("owner_credentials")
194
+ path = context.fetch("control_socket_path")
195
+ wire = Ace::Runtime::Molecules::ProtectedSocket
196
+ wire.root_path!(File.dirname(path), directory: true, owner: credentials.fetch("uid"))
197
+ identity = wire.socket_identity(path)
198
+ unless identity.last == credentials.fetch("uid")
199
+ raise Ace::Runtime::RuntimeUnavailableError, "installed context owner endpoint differs"
162
200
  end
163
201
  context
164
202
  rescue AttemptErrors::ReceiptRejected, OpenSSL::PKey::PKeyError, SystemCallError
@@ -196,14 +234,22 @@ module Ace
196
234
 
197
235
  def validate_inbox_contexts!
198
236
  roots = []
237
+ service_scopes = data.fetch("launch_mappings").values.map { |map| map.fetch("execution_scope") }
238
+ service_owner_uids = []
199
239
  data.fetch("projects").each do |project_id, fixed_project|
200
240
  contexts = fixed_project.fetch("inbox_contexts", {})
201
241
  unless contexts.is_a?(Hash) && contexts.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
202
242
  raise ArgumentError, "invalid installed inbox context map"
203
243
  end
204
244
  contexts.each_value do |context|
205
- strict!(context, %w[deliveries_dir receipt_public_key native_mapping_id pi_queue_client pi_queue_client_sha256 supervisor_uids])
206
- %w[deliveries_dir receipt_public_key pi_queue_client].each { |key| path!(context.fetch(key)) }
245
+ fields = %w[control_socket_path deliveries_dir owner_credentials native_mapping_id pi_queue_client pi_queue_client_sha256 supervisor_uids]
246
+ fields << "service" if context.key?("service")
247
+ strict!(context, fields)
248
+ credentials = context.fetch("owner_credentials")
249
+ strict!(credentials, %w[gid groups uid])
250
+ principal!(credentials, "uid", "gid", "groups")
251
+ raise ArgumentError, "context owner group count exceeds bounds" if credentials.fetch("groups").size > 64
252
+ %w[control_socket_path deliveries_dir pi_queue_client].each { |key| path!(context.fetch(key)) }
207
253
  unless context["pi_queue_client_sha256"].is_a?(String) && context["pi_queue_client_sha256"].match?(/\A[0-9a-f]{64}\z/)
208
254
  raise ArgumentError, "invalid installed Pi identity client digest"
209
255
  end
@@ -220,19 +266,48 @@ module Ace
220
266
  unless native.fetch("project_id") == project_id && authority(native.fetch("authority_id")).fetch("composition") == "services"
221
267
  raise ArgumentError, "inbox context requires its services project mapping"
222
268
  end
269
+ if context.key?("service")
270
+ Ace::Runtime::Molecules::ExecutionUnitInstallation.validate_inbox_context_service!(context.fetch("service"))
271
+ scope = context.fetch("service").fetch("execution_scope")
272
+ if service_scopes.any? { |other|
273
+ %w[slot_id slice_unit service_unit].any? { |field| scope.fetch(field) == other.fetch(field) } ||
274
+ scope.values_at("root_directory", "runtime_directory").any? { |path|
275
+ other.values_at("root_directory", "runtime_directory").any? { |existing| paths_overlap?(path, existing) }
276
+ }
277
+ }
278
+ raise ArgumentError, "context service shares a protected execution slot or backing root"
279
+ end
280
+ prohibited_uids = data.fetch("authorities").values.map { |service| service.fetch("uid") } +
281
+ data.fetch("launch_mappings").values.flat_map { |map| map.values_at("launcher_uid", "worker_uid") } +
282
+ data.fetch("projects").values.flat_map { |project|
283
+ %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].flat_map { |field| project.fetch(field) } +
284
+ project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("executor_uid") }
285
+ }
286
+ prohibited_uids.concat(data.fetch("projects").values.flat_map { |project|
287
+ project.fetch("inbox_contexts", {}).values.reject { |other| other.equal?(context) }.map { |other| other.fetch("owner_credentials").fetch("uid") }
288
+ })
289
+ uid = credentials.fetch("uid")
290
+ if prohibited_uids.include?(uid) || service_owner_uids.include?(uid)
291
+ raise ArgumentError, "context service owner shares another protected principal"
292
+ end
293
+ service_owner_uids << uid
294
+ service_scopes << scope
295
+ end
223
296
  roots << context.fetch("deliveries_dir")
224
297
  end
225
298
  end
226
299
  forbidden = data.fetch("projects").values.flat_map do |fixed_project|
227
300
  fixed_project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } +
228
301
  fixed_project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("staging_root") } +
229
- %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed_project.fetch(key) }
302
+ %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed_project.fetch(key) }
230
303
  end
231
304
  forbidden.concat(data.fetch("authorities").values.map { |service| service.fetch("state_root") })
232
305
  if roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
233
306
  roots.any? { |root| forbidden.any? { |other| paths_overlap?(root, other) } }
234
307
  raise ArgumentError, "installed inbox private roots overlap protected state"
235
308
  end
309
+ rescue Ace::Runtime::RuntimeUnavailableError
310
+ raise ArgumentError, "invalid context service installation selection"
236
311
  end
237
312
  private :validate_inbox_contexts!
238
313
 
@@ -318,12 +393,43 @@ module Ace
318
393
  data.fetch("launch_mappings").fetch(id)
319
394
  end
320
395
 
396
+ # Canonical mapping identity shared by scope evidence and the trusted
397
+ # provisioning owner. Resolves only this descriptor's exact mapping ID.
398
+ def mapping_digest(id)
399
+ Digest::SHA256.hexdigest(JSON.generate(canonical_mapping_value(mapping(id)))).freeze
400
+ end
401
+
402
+ def canonical_mapping_value(value)
403
+ case value
404
+ when Hash then value.sort.to_h.transform_values { |item| canonical_mapping_value(item) }
405
+ when Array then value.map { |item| canonical_mapping_value(item) }
406
+ else value
407
+ end
408
+ end
409
+ private :canonical_mapping_value
410
+
321
411
  def validate_mapping!(id, mapping)
322
412
  required = %w[project_id authority_id launcher_uid launcher_gid launcher_groups worker_uid worker_gid
323
- worker_groups worker_actor worker_cwd worker_argv worker_env bootstrap bootstrap_sha256 native execution_scope]
324
- unless mapping.is_a?(Hash) && mapping.keys.sort == required.sort
413
+ worker_groups worker_actor worker_cwd worker_entry worker_env bootstrap bootstrap_sha256 native execution_scope task_context_entry]
414
+ workspace = %w[workspace_repository_id workspace_cleanup_config]
415
+ unless mapping.is_a?(Hash) && [required.sort, (required + workspace).sort].include?(mapping.keys.sort)
325
416
  raise ArgumentError, "launch mapping fields differ: #{id}"
326
417
  end
418
+ if mapping.key?("workspace_repository_id")
419
+ repository = mapping.fetch("workspace_repository_id")
420
+ unless repository.is_a?(String) && TOKEN.match?(repository)
421
+ raise ArgumentError, "workspace repository identity is invalid"
422
+ end
423
+ reference = mapping.fetch("workspace_cleanup_config")
424
+ strict!(reference, %w[path sha256 bytes])
425
+ path!(reference.fetch("path"))
426
+ unless reference.fetch("path").bytesize <= 4096 && reference.fetch("path").encoding == Encoding::UTF_8 && reference.fetch("path").valid_encoding? &&
427
+ digest?(reference.fetch("sha256")) && reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, 65_536)
428
+ raise ArgumentError, "workspace cleanup configuration reference is invalid"
429
+ end
430
+ end
431
+ TaskContextEntry.validate!(mapping.fetch("task_context_entry"))
432
+ Ace::Runtime::Molecules::ProtectedWorkerEntry.validate!(mapping.fetch("worker_entry"))
327
433
  service = authority(mapping.fetch("authority_id"))
328
434
  fixed_project = project(mapping.fetch("project_id"))
329
435
  %w[launcher worker].each do |role|
@@ -367,12 +473,10 @@ module Ace
367
473
  !paths_overlap?(scope.fetch("root_directory"), scope.fetch("runtime_directory"))
368
474
  raise ArgumentError, "execution scope backing roots overlap or runtime root is invalid"
369
475
  end
370
- argv = mapping.fetch("worker_argv")
371
476
  env = mapping.fetch("worker_env")
372
- unless argv.is_a?(Array) && !argv.empty? && argv.size <= 64 && argv.all? { |v| v.is_a?(String) && !v.include?("\0") } &&
373
- argv.first.start_with?("/") && env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
477
+ unless env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
374
478
  env.keys.none? { |key| key.match?(/\A(?:LD_|DYLD_|RUBY|BUNDLE|PYTHON)/) }
375
- raise ArgumentError, "invalid fixed worker argv or environment"
479
+ raise ArgumentError, "invalid fixed worker environment"
376
480
  end
377
481
  unless mapping["worker_actor"].is_a?(String) && !mapping["worker_actor"].empty? &&
378
482
  mapping["bootstrap_sha256"].match?(/\A[0-9a-f]{64}\z/)
@@ -402,6 +506,51 @@ module Ace
402
506
  end
403
507
  end
404
508
 
509
+ # Original ownership cannot be recycled or hidden by a staged descriptor.
510
+ def maintenance_inventory(candidate)
511
+ unless candidate.is_a?(Deployment) && artifact_reference && candidate.artifact_reference && frozen? && candidate.frozen?
512
+ raise AttemptErrors::EvidenceUnavailable, "maintenance requires authenticated immutable original and candidate descriptors"
513
+ end
514
+ originals = data.fetch("launch_mappings")
515
+ proposed = candidate.data.fetch("launch_mappings")
516
+ originals.each do |id, map|
517
+ next unless proposed.key?(id)
518
+ unless maintenance_association(map) == candidate.maintenance_association(proposed.fetch(id))
519
+ raise ArgumentError, "existing maintenance mapping was reassociated: #{id}"
520
+ end
521
+ end
522
+ entries = originals.map { |id, map| [id, self, map] } +
523
+ proposed.reject { |id, _| originals.key?(id) }.map { |id, map| [id, candidate, map] }
524
+ all = originals.map { |id, map| [id, self, map] } + proposed.map { |id, map| [id, candidate, map] }
525
+ all.combination(2).each do |left, right|
526
+ l_id, l_owner, l_map = left
527
+ r_id, r_owner, r_map = right
528
+ l_scope, r_scope = l_map.fetch("execution_scope"), r_map.fetch("execution_scope")
529
+ shared = %w[slot_id slice_unit service_unit network_namespace_path].any? { |key| l_scope.fetch(key) == r_scope.fetch(key) }
530
+ shared ||= l_scope.values_at("root_directory", "runtime_directory").product(
531
+ r_scope.values_at("root_directory", "runtime_directory")).any? { |a, b| paths_overlap?(a, b) }
532
+ if shared && (l_id != r_id || l_owner.maintenance_association(l_map) != r_owner.maintenance_association(r_map))
533
+ raise ArgumentError, "physical execution slot has conflicting maintenance ownership"
534
+ end
535
+ end
536
+ entries.sort_by(&:first).map { |entry| entry.freeze }.freeze
537
+ end
538
+
539
+ def maintenance_association(map)
540
+ fixed = project(map.fetch("project_id"))
541
+ [map.fetch("project_id"), fixed.values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root"),
542
+ authority(map.fetch("authority_id")).fetch("state_root"),
543
+ map.fetch("execution_scope").values_at("slot_id", "slice_unit", "service_unit", "network_namespace_path")]
544
+ end
545
+
546
+ def freeze_data!(value)
547
+ case value
548
+ when Hash then value.each { |key, item| key.freeze; freeze_data!(item) }
549
+ when Array then value.each { |item| freeze_data!(item) }
550
+ end
551
+ value.freeze
552
+ end
553
+
405
554
  def strict!(value, keys)
406
555
  raise ArgumentError, "deployment fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
407
556
  end
@@ -438,11 +587,24 @@ module Ace
438
587
  manager ||= Ace::Runtime::Molecules::SystemdScopeManager.new(
439
588
  slice_unit: scope.fetch("slice_unit"), service_unit: scope.fetch("service_unit"))
440
589
  manifest = Ace::Runtime::Molecules::ExecutionUnitInstallation.new(scope: scope, native: map.fetch("native"),
441
- bootstrap: map.fetch("bootstrap"), worker_executable: map.fetch("worker_argv").first,
590
+ bootstrap: map.fetch("bootstrap"), worker_entry: map.fetch("worker_entry"),
442
591
  worker_uid: map.fetch("worker_uid"), worker_gid: map.fetch("worker_gid")).verify!(manager: manager)
443
592
  artifacts = manifest.fetch("artifacts")
593
+ configuration = artifacts.find { |artifact| artifact.fetch("role") == "readiness_configuration" }
594
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |reader|
595
+ bytes, = reader.read_path!(configuration.fetch("host_path"), limit: 65_536)
596
+ unless Digest::SHA256.hexdigest(bytes) == configuration.fetch("sha256")
597
+ raise Ace::Runtime::RuntimeUnavailableError, "readiness configuration content differs"
598
+ end
599
+ Ace::Runtime::Molecules::ReadinessConfiguration.decode(bytes, slot: scope.fetch("slot_id"))
600
+ .verify_selection!(mapping_id: id, mapping: map, authority: service, installation: manifest)
601
+ reader.verify_unchanged!
602
+ end
444
603
  bootstrap = artifacts.find { |artifact| artifact.fetch("role") == "bootstrap" }
445
- executables = artifacts.select { |artifact| %w[bootstrap worker_executable].include?(artifact.fetch("role")) }
604
+ executables = artifacts.select do |artifact|
605
+ artifact.fetch("role") == "bootstrap" || (artifact.fetch("role") == "runtime_dependency" &&
606
+ artifact.fetch("view_path") == map.fetch("worker_entry").fetch("interpreter").fetch("path"))
607
+ end
446
608
  unless bootstrap.fetch("sha256") == map.fetch("bootstrap_sha256") && elf_architecture?(bootstrap.fetch("host_path")) &&
447
609
  executables.all? { |artifact| File.executable?(artifact.fetch("host_path")) }
448
610
  raise Ace::Runtime::RuntimeUnavailableError, "installed bootstrap or executable is unsafe"
@@ -450,7 +612,7 @@ module Ace
450
612
  wire.root_path!(File.dirname(service.fetch("socket_path")), directory: true, owner: service.fetch("uid"))
451
613
  if authority_state
452
614
  fixed = project(map.fetch("project_id"))
453
- ([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed.fetch(key) }).each do |path|
615
+ ([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed.fetch(key) }).each do |path|
454
616
  wire.root_path!(path, directory: true, owner: service.fetch("uid"))
455
617
  unless (File.stat(path).mode & 0o077).zero?
456
618
  raise Ace::Runtime::RuntimeUnavailableError, "authority state root must remain private"
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deployment"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # Immutable installer configuration, indexed by canonical content identity.
9
+ # This object is never a transport selector or an execution ledger.
10
+ class DeploymentHistory
11
+ PATH = "/etc/ace/assignment-deployment-history.json"
12
+ attr_reader :original, :candidate, :artifact_reference
13
+
14
+ def self.load = load_protected(nil)
15
+
16
+ def self.load_artifact(reference)
17
+ validate_reference!(reference)
18
+ load_protected(reference)
19
+ end
20
+
21
+ def self.load_protected(reference)
22
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
23
+ bytes = if reference
24
+ artifacts.read!(reference)
25
+ else
26
+ held, selected = artifacts.read_path!(PATH, limit: 65_536)
27
+ reference = selected
28
+ held
29
+ end
30
+ raw = bytes.dup.force_encoding(Encoding::UTF_8)
31
+ raise ArgumentError, "history manifest is not UTF-8" unless raw.valid_encoding?
32
+ value = JSON.parse(raw, create_additions: false, max_nesting: 32,
33
+ allow_duplicate_key: false, allow_comments: false)
34
+ unless value.is_a?(Hash) && value.keys.sort ==
35
+ %w[candidate_descriptor descriptors original_descriptor public_keys schema] &&
36
+ value["schema"] == "ace.assign.deployment-history/v1"
37
+ raise ArgumentError, "invalid deployment history manifest"
38
+ end
39
+ descriptors = value.fetch("descriptors")
40
+ keys = value.fetch("public_keys")
41
+ unless descriptors.is_a?(Array) && descriptors.size.between?(1, 128) &&
42
+ keys.is_a?(Array) && keys.size <= 128 && descriptors.size + keys.size <= 255
43
+ raise ArgumentError, "deployment history inventory exceeds bounds"
44
+ end
45
+ index = {}
46
+ descriptors.each do |selected|
47
+ validate_reference!(selected)
48
+ digest = selected.fetch("sha256")
49
+ raise ArgumentError, "duplicate historical descriptor selector" if index.key?(digest)
50
+ index[digest] = Deployment.send(:from_verified_bytes, artifacts.read!(selected), immutable(selected))
51
+ end
52
+ selections = %w[original_descriptor candidate_descriptor].map do |name|
53
+ selected = value.fetch(name)
54
+ validate_reference!(selected)
55
+ descriptor = index.fetch(selected.fetch("sha256")) do
56
+ raise ArgumentError, "transaction descriptor is not retained"
57
+ end
58
+ unless descriptor.artifact_reference == selected
59
+ raise ArgumentError, "transaction descriptor reference conflicts"
60
+ end
61
+ descriptor
62
+ end
63
+ public_keys = {}
64
+ keys.each do |entry|
65
+ unless entry.is_a?(Hash) && entry.keys.sort == %w[public_key_sha256 ref] &&
66
+ digest?(entry["public_key_sha256"])
67
+ raise ArgumentError, "invalid historical public key entry"
68
+ end
69
+ validate_reference!(entry.fetch("ref"))
70
+ digest = entry.fetch("public_key_sha256")
71
+ raise ArgumentError, "duplicate historical public key selector" if public_keys.key?(digest)
72
+ key = OpenSSL::PKey.read(artifacts.read!(entry.fetch("ref")))
73
+ unless key.is_a?(OpenSSL::PKey::RSA) && !key.private? &&
74
+ Digest::SHA256.hexdigest(key.public_key.to_der) == digest
75
+ raise ArgumentError, "historical public key identity differs"
76
+ end
77
+ public_keys[digest] = key.freeze
78
+ end
79
+ selections.first.maintenance_inventory(selections.last)
80
+ history = new(selections, index, public_keys, immutable(reference))
81
+ artifacts.verify_unchanged!
82
+ history.freeze
83
+ end
84
+ end
85
+
86
+ def initialize(selections, descriptors, keys, reference)
87
+ @original, @candidate = selections
88
+ @descriptors, @keys = descriptors.freeze, keys.freeze
89
+ @artifact_reference = reference
90
+ end
91
+ private_class_method :new
92
+
93
+ def descriptor!(sha256:)
94
+ @descriptors.fetch(sha256) do
95
+ raise AttemptErrors::EvidenceUnavailable, "original protected descriptor is not retained"
96
+ end
97
+ end
98
+
99
+ def public_key!(sha256:)
100
+ @keys.fetch(sha256) do
101
+ raise AttemptErrors::EvidenceUnavailable, "original protected public key is not retained"
102
+ end
103
+ end
104
+
105
+ def descriptors = @descriptors.values.freeze
106
+
107
+ # Publication moves verified bytes to the fixed current descriptor path.
108
+ # Retention paths stay immutable; canonical content, not pathname, joins
109
+ # the installed selection to that exact independently verified artifact.
110
+ def selects?(deployment, selection: nil)
111
+ return false unless deployment.is_a?(Deployment) && deployment.frozen? && deployment.artifact_reference
112
+ choices = case selection
113
+ when :original then [original]
114
+ when :candidate then [candidate]
115
+ when nil then [original, candidate]
116
+ else raise ArgumentError, "invalid history transaction selection"
117
+ end
118
+ choices.any? do |selected|
119
+ selected.artifact_reference.values_at("sha256", "bytes") == deployment.artifact_reference.values_at("sha256", "bytes")
120
+ end
121
+ end
122
+
123
+ def self.digest?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
124
+
125
+ def self.validate_reference!(value)
126
+ unless value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256] &&
127
+ value["path"].is_a?(String) && value["path"].start_with?("/") &&
128
+ !value["path"].include?("\0") && File.expand_path(value["path"]) == value["path"] &&
129
+ digest?(value["sha256"]) && value["bytes"].is_a?(Integer) && value["bytes"].between?(1, 65_536)
130
+ raise ArgumentError, "invalid protected history artifact reference"
131
+ end
132
+ end
133
+
134
+ def self.immutable(value)
135
+ case value
136
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
137
+ when Array then value.map { |item| immutable(item) }.freeze
138
+ when String then value.dup.freeze
139
+ else value.freeze
140
+ end
141
+ end
142
+ private_class_method :digest?, :validate_reference!, :immutable, :load_protected
143
+ end
144
+ end
145
+ end
146
+ end