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
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../molecules/execution_scope_lineage"
3
+ require "json"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # Source capabilities only. A serialized descriptor is not admission.
9
+ module MaintenanceWorkspaceRetirement
10
+ SCHEMA = "ace.maintenance-workspace-retirement/v1"
11
+ TARGET_FIELDS = %w[project_id mapping_id assignment_id attempt_id resource journal_commit binding_event_digest release_event_digest descriptor_sha256].freeze
12
+ INVOCATION_FIELDS = %w[request_id input_digest operation_owner_binding_digest dispatch_event_digest].freeze
13
+ REFERENCES = {
14
+ "captured" => %w[intent captured preservation],
15
+ "removed" => %w[intent captured preservation removal fence],
16
+ "completed" => %w[intent captured preservation removal fence receipt]
17
+ }.freeze
18
+
19
+ def self.immutable(value)
20
+ case value
21
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
22
+ when Array then value.map { |item| immutable(item) }.freeze
23
+ when String then value.dup.freeze
24
+ else value.freeze
25
+ end
26
+ end
27
+
28
+ def self.unavailable!(message)
29
+ raise AttemptErrors::EvidenceUnavailable, message
30
+ end
31
+
32
+ class Evidence
33
+ attr_reader :descriptor, :owner
34
+
35
+ # Only the installed adapter mints this after validating its complete
36
+ # collection. Consumers additionally bind the exact adapter instance,
37
+ # original canonical projection and held operation before using it.
38
+ def self.mint!(owner:, descriptor:)
39
+ new(owner: owner, descriptor: descriptor)
40
+ end
41
+ private_class_method :new
42
+
43
+ def initialize(owner:, descriptor:)
44
+ @owner = owner
45
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence descriptor is not an object") unless descriptor.is_a?(Hash)
46
+ @descriptor = MaintenanceWorkspaceRetirement.immutable(descriptor)
47
+ @thread = Thread.current
48
+ @consumption = nil
49
+ @expired = false
50
+ validate_descriptor!
51
+ rescue KeyError, TypeError, ArgumentError
52
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence descriptor differs")
53
+ end
54
+
55
+ def verify!(owner:, target:, projection:, phase:, deadline:)
56
+ unless !@expired && @thread.equal?(Thread.current) && @owner.equal?(owner) && descriptor.fetch("target") == target &&
57
+ descriptor.fetch("phase") == phase && descriptor.fetch("resource") == projection.fetch("workspace_resource")
58
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence original owner or selection differs")
59
+ end
60
+ identities = projection.fetch("resource_identities")
61
+ root = projection.fetch("worker_cwd")
62
+ expected = projection.fetch("parent_resource_declarations").filter_map do |declaration|
63
+ path = declaration.fetch("host_path")
64
+ next unless declaration.fetch("stage") == "parent" && (path == root || path.start_with?(root + "/"))
65
+ matches = identities.select { |row| row.values_at("host_path", "view_path") == declaration.values_at("host_path", "view_path") }
66
+ MaintenanceWorkspaceRetirement.unavailable!("retirement declared row has no unique original identity") unless matches.one?
67
+ matches.first
68
+ end.sort_by { |row| row.values_at("host_path", "view_path") }
69
+ unless descriptor.fetch("covered_resources").map { |row| row.fetch("original") } == expected
70
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered resources are not exhaustive")
71
+ end
72
+ @owner.verify_retirement_evidence!(evidence: self, projection: projection, deadline: deadline)
73
+ true
74
+ end
75
+
76
+ def expire! = @expired = true
77
+
78
+ def covered_resource(path)
79
+ descriptor.fetch("covered_resources").find { |entry| entry.fetch("original").fetch("host_path") == path }
80
+ end
81
+
82
+ def verify_consumption!
83
+ unless !@expired && @consumption && @thread.equal?(Thread.current)
84
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence is outside its held observation")
85
+ end
86
+ true
87
+ end
88
+
89
+ def with_consumption(verification)
90
+ MaintenanceWorkspaceRetirement.unavailable!("retirement observation cannot be nested") if @consumption
91
+ installed = true
92
+ @consumption = verification
93
+ verify_consumption!
94
+ verification.call
95
+ result = yield
96
+ verify_consumption!
97
+ verification.call
98
+ result
99
+ ensure
100
+ @consumption = nil if installed
101
+ end
102
+
103
+ private
104
+
105
+ def validate_descriptor!
106
+ fields = %w[schema phase target resource covered_resources invocation refs]
107
+ target = descriptor.fetch("target")
108
+ invocation = descriptor.fetch("invocation")
109
+ phase = descriptor.fetch("phase")
110
+ unless descriptor.keys.sort == fields.sort && descriptor.fetch("schema") == SCHEMA && REFERENCES.key?(phase) &&
111
+ target.is_a?(Hash) && target.keys.sort == TARGET_FIELDS.sort &&
112
+ invocation.is_a?(Hash) && invocation.keys.sort == INVOCATION_FIELDS.sort
113
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence is not a closed supported descriptor")
114
+ end
115
+ resource!(descriptor.fetch("resource"))
116
+ unless target.fetch("resource") == "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}" &&
117
+ %w[project_id mapping_id assignment_id attempt_id].all? { |key| token?(target[key]) } &&
118
+ target.fetch("journal_commit").is_a?(String) && target.fetch("journal_commit").match?(/\A[0-9a-f]{40}\z/) &&
119
+ %w[binding_event_digest release_event_digest descriptor_sha256].all? { |key| digest?(target[key]) } &&
120
+ token?(invocation.fetch("request_id")) && (INVOCATION_FIELDS - ["request_id"]).all? { |key| digest?(invocation[key]) }
121
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence selectors differ")
122
+ end
123
+ covered = descriptor.fetch("covered_resources")
124
+ unless covered.is_a?(Array) && covered.size.between?(1, 64)
125
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered inventory differs")
126
+ end
127
+ covered.each do |entry|
128
+ unless entry.is_a?(Hash) && entry.keys.sort == %w[captured_host_path original] && path?(entry["captured_host_path"])
129
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered row differs")
130
+ end
131
+ resource!(entry.fetch("original"))
132
+ end
133
+ unless covered.map { |entry| entry.fetch("original") }.uniq.size == covered.size &&
134
+ covered.map { |entry| entry.fetch("captured_host_path") }.uniq.size == covered.size
135
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered rows repeat")
136
+ end
137
+ roots = covered.select { |entry| entry.fetch("original") == descriptor.fetch("resource") }
138
+ unless roots.one? && covered.all? { |entry|
139
+ original = entry.fetch("original").fetch("host_path")
140
+ root = descriptor.fetch("resource").fetch("host_path")
141
+ (original == root || original.start_with?(root + "/")) &&
142
+ entry.fetch("captured_host_path") == roots.first.fetch("captured_host_path") + original.delete_prefix(root) }
143
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence captured paths do not match exact relative resources")
144
+ end
145
+ refs = descriptor.fetch("refs")
146
+ unless refs.is_a?(Hash) && refs.keys.sort == REFERENCES.fetch(phase).sort
147
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence reference set differs")
148
+ end
149
+ refs.each_value do |ref|
150
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) && digest?(ref["sha256"]) &&
151
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 64 * 1024 * 1024)
152
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence reference differs")
153
+ end
154
+ end
155
+ end
156
+
157
+ def token?(value) = value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/)
158
+ def digest?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
159
+ def path?(value)
160
+ value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 4096) &&
161
+ value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
162
+ end
163
+ def resource!(value)
164
+ unless value.is_a?(Hash) && value.keys.sort == Molecules::ExecutionScopeLineage::RESOURCE_FIELDS.sort &&
165
+ %w[host_path view_path].all? { |key| path?(value[key]) } &&
166
+ %w[mount_id device inode].all? { |key| value[key].is_a?(Integer) && value[key].positive? } &&
167
+ %w[uid gid].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 } &&
168
+ %w[ext4 xfs btrfs tmpfs].include?(value["filesystem_type"])
169
+ MaintenanceWorkspaceRetirement.unavailable!("retirement evidence resource identity differs")
170
+ end
171
+ end
172
+ end
173
+ class Session
174
+ attr_reader :projection
175
+ def self.open!(**arguments) = new(**arguments)
176
+ private_class_method :new
177
+
178
+ def initialize(lifecycle:, mapping_id:, journal:, commit:, target:, projection:, evidence_owner:, deadline:, contexts:)
179
+ @lifecycle, @mapping_id, @journal, @commit = lifecycle, mapping_id, journal, commit
180
+ @target, @projection = MaintenanceWorkspaceRetirement.immutable(target), projection
181
+ @owner, @deadline, @contexts = evidence_owner, deadline, contexts
182
+ @thread, @active, @phase = Thread.current, true, nil
183
+ @evidences = []
184
+ @writer = @owner.retirement_workspace_writer!
185
+ unless @writer.is_a?(Molecules::LifecycleExclusion::WorkspaceWriter) && @writer.projection == projection.fetch("workspace_exclusion")
186
+ MaintenanceWorkspaceRetirement.unavailable!("retirement session requires the original maintained workspace writer")
187
+ end
188
+ verify_held!
189
+ end
190
+
191
+ def verify_captured!(evidence:) = verify_phase!("captured", evidence)
192
+ def verify_removed!(evidence:) = verify_phase!("removed", evidence)
193
+ def close!
194
+ @active = false
195
+ @evidences.each(&:expire!)
196
+ end
197
+
198
+ private
199
+
200
+ def verify_held!
201
+ unless @active && @thread.equal?(Thread.current) &&
202
+ Thread.current[:ace_assign_maintenance_contexts]&.[](@lifecycle.object_id).equal?(@contexts)
203
+ MaintenanceWorkspaceRetirement.unavailable!("retirement session no longer owns complete maintenance contexts")
204
+ end
205
+ @lifecycle.send(:maintenance_deadline!, @deadline)
206
+ @lifecycle.send(:require_maintenance_context!, @mapping_id, @journal, @commit)
207
+ @writer.verify_unchanged!
208
+ end
209
+
210
+ def verify_phase!(phase, evidence)
211
+ unless evidence.is_a?(Evidence) && (phase == "captured" && @phase.nil? || phase == "removed" && @phase == "captured")
212
+ MaintenanceWorkspaceRetirement.unavailable!("retirement session phase or evidence differs")
213
+ end
214
+ @evidences << evidence
215
+ verify = -> {
216
+ verify_held!
217
+ evidence.verify!(owner: @owner, target: @target, projection: @projection, phase: phase, deadline: @deadline)
218
+ }
219
+ evidence.with_consumption(verify) do
220
+ @lifecycle.send(:verify_retirement_contexts!, evidence: evidence, deadline: @deadline)
221
+ end
222
+ @phase = phase
223
+ true
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Original descriptor selection shared by canonical Inbox consumers.
7
+ # The held historical manifest/key owner supplies the exact artifact.
8
+ class OriginalInboxSelection
9
+ def initialize(history:, authority_id:)
10
+ @history, @authority_id = history, authority_id
11
+ end
12
+
13
+ def load!(events:, params:, map:, journal:, commit:)
14
+ provisioning = events.select { |event| event["type"] == "scope_provisioning" }
15
+ reservations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
16
+ unless provisioning.one? && reservations.one?
17
+ raise AttemptErrors::EvidenceUnavailable, "context completion original selection is ambiguous"
18
+ end
19
+ identity = provisioning.first.fetch("payload")
20
+ reserved = reservations.first.fetch("payload").fetch("data")
21
+ original = @history.descriptor!(sha256: identity.fetch("descriptor_sha256"))
22
+ selected = original.mapping(params.fetch("mapping_id"))
23
+ unless identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id] &&
24
+ reserved.values_at("project_id", "assignment_id", "attempt_id", "mapping_id") ==
25
+ [map.fetch("project_id"), params.fetch("assignment_id"), params.fetch("attempt_id"), params.fetch("mapping_id")] &&
26
+ identity.fetch("reservation_generation") == reserved.fetch("reservation_generation") &&
27
+ identity.fetch("deployment_digest") == original.mapping_digest(params.fetch("mapping_id")) &&
28
+ selected.fetch("project_id") == map.fetch("project_id") && selected.fetch("authority_id") == @authority_id &&
29
+ identity.fetch("slot_id") == selected.fetch("execution_scope").fetch("slot_id") &&
30
+ original.project(selected.fetch("project_id")).values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
31
+ [journal.repo_root, journal.ref, journal.checkout_root]
32
+ raise AttemptErrors::EvidenceUnavailable, "context completion original association differs"
33
+ end
34
+ journal.event_commit!(assignment_id: params.fetch("assignment_id"), event_digest: provisioning.first.fetch("digest"), commit: commit)
35
+ [original, selected]
36
+ end
37
+
38
+ end
39
+ end
40
+ end
41
+ end
@@ -7,12 +7,15 @@ module Ace
7
7
  # Linux POSIX access ACL evaluation for a fixed unprivileged principal.
8
8
  # No credential switching; unsupported or unreadable policy refuses admission.
9
9
  class PosixAcl
10
- def entries(path)
10
+ def entries(path, attribute: "system.posix_acl_access")
11
+ unless %w[system.posix_acl_access system.posix_acl_default].include?(attribute)
12
+ raise ArgumentError, "unsupported POSIX ACL attribute"
13
+ end
11
14
  raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection requires Linux" unless RUBY_PLATFORM.include?("linux")
12
15
  function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["lgetxattr"],
13
16
  [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
14
17
  buffer = Fiddle::Pointer.malloc(8192, Fiddle::RUBY_FREE)
15
- count = function.call(path, "system.posix_acl_access", buffer, 8192)
18
+ count = function.call(path, attribute, buffer, 8192)
16
19
  return nil if count == -1 && Fiddle.last_error == Errno::ENODATA::Errno
17
20
  raise Ace::Runtime::RuntimeUnavailableError, "receiver access ACL is unreadable" if count < 4 || (count - 4) % 8 != 0
18
21
  bytes = buffer[0, count]
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require_relative "client"
5
+ require_relative "prepared_work"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # One authenticated original fetch and one immutable verified work graph.
11
+ # Caller identifiers select; the maintained Client/Endcap authenticates.
12
+ class PreparedInput
13
+ attr_reader :descriptor, :work, :working_directory
14
+
15
+ def self.fetch(client:, assignment_id:, attempt_id:)
16
+ reply = client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
17
+ "kind" => "prepared_work", "purpose_id" => "original_prepared_work", "artifact_id" => "prepared_bundle"},
18
+ download: true, purpose: :candidate, timeout: 30)
19
+ new(reply: reply, candidate_loader: -> {
20
+ client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
21
+ "kind" => "campaign_candidate", "purpose_id" => "original_campaign_candidate", "artifact_id" => "candidate_bundle"},
22
+ download: true, purpose: :candidate, timeout: 30)
23
+ })
24
+ end
25
+
26
+ def initialize(reply:, candidate_reply: nil, candidate_loader: nil)
27
+ @descriptor = reply.data.fetch("descriptor")
28
+ Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
29
+ unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
30
+ "/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
31
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: original lifecycle mapping differs"
32
+ end
33
+ unless reply.parts.is_a?(Array) && reply.parts.one?
34
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: one original bundle required"
35
+ end
36
+ root = PrivateDirectory.verify!(descriptor.fetch("original_worker_scratch_root"))
37
+ @work = PreparedWork.admit(bytes: reply.parts.first, head: descriptor.fetch("prepared_head"),
38
+ tree: descriptor.fetch("prepared_tree"), sha256: descriptor.fetch("sha256"), size: descriptor.fetch("bytes"), root: root)
39
+ expected = work.reference(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree"))
40
+ definition = work.definition_bytes(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree"))
41
+ unless expected == descriptor.slice(*expected.keys) &&
42
+ descriptor.values_at("assignment_id", "project_id") == work.manifest.values_at("assignment_id", "project_id") &&
43
+ Digest::SHA256.hexdigest(definition) == descriptor.fetch("definition_digest")
44
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_mismatch: original graph association"
45
+ end
46
+ execution = work.definition["campaign_execution"]
47
+ if execution
48
+ candidate_reply ||= candidate_loader&.call
49
+ candidate = candidate_reply&.data&.fetch("descriptor")
50
+ unless candidate.is_a?(Hash) && candidate.keys.sort == %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort &&
51
+ candidate.slice("mapping_id", "assignment_id", "attempt_id", "project_id", "original_worker_identity", "original_worker_scratch_root", "original_binding_digest") ==
52
+ descriptor.slice("mapping_id", "assignment_id", "attempt_id", "project_id", "original_worker_identity", "original_worker_scratch_root", "original_binding_digest") &&
53
+ candidate.fetch("campaign_execution") == execution && candidate.fetch("head") == execution.fetch("head") &&
54
+ candidate.fetch("candidate_generation") == execution.fetch("parent_candidate_generation") &&
55
+ candidate_reply.parts.is_a?(Array) && candidate_reply.parts.one?
56
+ raise AttemptErrors::EvidenceUnavailable, "campaign original candidate input differs"
57
+ end
58
+ materialized = CandidateTransfer.new(root: root).materialize_repository(bytes: candidate_reply.parts.first,
59
+ sha256: candidate.fetch("sha256"), size: candidate.fetch("bytes"), head: candidate.fetch("head"),
60
+ tree: candidate.fetch("tree"), root: root)
61
+ @working_directory = materialized.fetch("directory").freeze
62
+ elsif candidate_reply
63
+ raise AttemptErrors::EvidenceUnavailable, "ordinary prepared input forbids a campaign candidate"
64
+ end
65
+ @descriptor = JSON.parse(JSON.generate(descriptor))
66
+ pending = [@descriptor]
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
+ freeze
74
+ end
75
+
76
+ def render(text)
77
+ values = descriptor.slice("mapping_id", "assignment_id", "scope", "attempt_id")
78
+ rendered = +""
79
+ cursor = 0
80
+ # Only an opening delimiter introduces a token. Closing braces in
81
+ # literal JSON are preserved; inserted selector values are not parsed.
82
+ while (opening = text.index("{{", cursor))
83
+ rendered << text[cursor...opening]
84
+ closing = text.index("}}", opening + 2)
85
+ unless closing
86
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unresolved instruction token"
87
+ end
88
+ token = text[(opening + 2)...closing]
89
+ if token.include?("{") || token.include?("}")
90
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unresolved instruction token"
91
+ end
92
+ field = token.delete_prefix("admitted.")
93
+ unless token == "admitted." + field && values.key?(field)
94
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unknown instruction token"
95
+ end
96
+ rendered << Shellwords.escape(values.fetch(field))
97
+ cursor = closing + 2
98
+ end
99
+ rendered << text[cursor..]
100
+ rendered.freeze
101
+ end
102
+
103
+ def drive_prompt
104
+ target = Shellwords.escape(descriptor.fetch("assignment_id") + "@" + descriptor.fetch("scope"))
105
+ selectors = "--assignment #{target} --mapping #{Shellwords.escape(descriptor.fetch('mapping_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))}"
106
+ instructions = ["Drive the authenticated captured assignment subtree. Use ace-assign status #{selectors}, step #{selectors}, start #{selectors}, finish #{selectors} --message REPORT, and fail #{selectors} --message REASON. Do not fork-run, add, retry, renumber, or expand another graph."]
107
+ if (execution = work.definition["campaign_execution"])
108
+ instructions << "This is a registered campaign #{execution.fetch('phase')} child. The provider working directory is the private authenticated parent candidate repository at #{Shellwords.escape(execution.fetch('head'))}, with original base #{Shellwords.escape(execution.fetch('base'))}. Do not edit or advance that candidate. Execute the captured #{Shellwords.escape(execution.fetch('operation'))} phase and retain its actual #{Shellwords.escape(execution.fetch('check_name'))} check and report artifacts. Submit an ordinary campaign-free result (campaign null), operation #{Shellwords.escape(execution.fetch('operation'))}, exact original head/base, and actual check outcome. The existing independent reviewer must validate those same artifact bytes; an approval child must retain the exact canonical independent review actor/verdict. No session metadata or completed queue step counts as a settled R1 campaign round."
109
+ end
110
+ original = "--mapping #{Shellwords.escape(descriptor.fetch('mapping_id'))} --assignment #{Shellwords.escape(descriptor.fetch('assignment_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))}"
111
+ instructions << "Before the original worker exits, explicitly submit its tested candidate and real execution result through the protected authority. Queue completion is not candidate/result acceptance. Read ace-assign authority status #{original}; persist its authority_generation with the complete next command arguments and ordered byte inputs. For each NEW operation use a new explicit mutation and a newly selected generation; for exact retries preserve the original generation, mutation and bytes."
112
+ instructions << "Submit one prepared Git bundle: ace-assign submit-candidate #{original} --head HEAD --candidate-generation CURRENT_CANDIDATE --expected-generation G --mutation CANDIDATE_MUTATION --bundle FILE. Select CURRENT_CANDIDATE from status result_candidate_generation, using 0 when null. Persist the returned candidate_generation and head as REVIEWED_CANDIDATE and HEAD; never derive the next counter by arithmetic or from queue completion."
113
+ instructions << "Have an independent installed reviewer run ace-overseer review --project #{Shellwords.escape(descriptor.fetch('project_id'))} --agent #{Shellwords.escape(descriptor.fetch('mapping_id'))} --assignment #{Shellwords.escape(descriptor.fetch('assignment_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))} --head HEAD --candidate-generation REVIEWED_CANDIDATE --expected-generation G --mutation REVIEW_REQUEST --accept-mutation REVIEW_ACCEPT. Do not impersonate that principal. The review consumer owns actual candidate export, independent checks and canonical acceptance. After review, read authority status again before the first result submission."
114
+ instructions << "Produce the real receipt with Ace::Assign::Models::ExecutionReceipt.new/to_h: original assignment/attempt/project and scope #{Shellwords.escape(descriptor.fetch('scope'))}, exact tested candidate head, installed worker actor/role worker/runtime herdr, actual operation/verdict/executed checks and ordered artifact path/SHA256 pairs. No invented successful check or queue-to-receipt conversion. Submit JSON bytes and matching ordered artifact files with ace-assign submit-result #{original} --head HEAD --candidate-generation REVIEWED_CANDIDATE --expected-generation G --mutation RESULT_MUTATION --receipt FILE --artifact FILE ... . Failed empty artifacts use no --artifact; succeeded requires evidence. Supervisor finish is separate and revalidates result, independent review and no-writer/settlement proof."
115
+ instructions << "For an authorized merge step captured INSIDE this subtree, use the protected delivery rule in wfi://assign/drive. Never execute an outer preset step or expand this graph. Retain the exact approved candidate, structured input bytes, authorization and request ID before the first request; select authority_generation for that new request only."
116
+ service = "--project #{Shellwords.escape(descriptor.fetch('project_id'))} #{original} --scope #{Shellwords.escape(descriptor.fetch('scope'))} --candidate-head HEAD --candidate-generation REVIEWED_CANDIDATE"
117
+ instructions << "Request the uniquely installed authorized merge receiver: ace-lab service request #{service} --service SERVICE --operation merge --authorization AUTHORIZATION --request-id REQUEST --expected-generation G --input INPUT_FILE. Credentials alone are not authorization. Persist the returned original input_digest and target with all request arguments. Lost/uncertain replies never permit another effect or refreshed-generation retry."
118
+ instructions << "Inspect the same request: ace-lab service status #{service} --request REQUEST --input-digest INPUT_DIGEST --target TARGET. Only canonical succeeded evidence permits ace-assign delivery #{original} --scope #{Shellwords.escape(descriptor.fetch('scope'))} --operation merge --service-request REQUEST --candidate-head HEAD --candidate-generation REVIEWED_CANDIDATE --input-digest INPUT_DIGEST --target TARGET. This consumption verifies the imported receipt and never executes merge. Refusal, unavailable or uncertain leaves the active delivery step unfinished; report its exact blocker. Finish the queue step only after succeeded canonical delivery."
119
+ work.manifest.fetch("context").each do |entry|
120
+ instructions << render(work.files.fetch(entry.fetch("text").fetch("filename")))
121
+ end
122
+ work.manifest.fetch("steps").each do |entry|
123
+ instructions << "Step #{entry.fetch('number')}:\n" + render(work.parse_queue_step!(work.files.fetch("steps/" + entry.fetch("filename"))).fetch(:body))
124
+ end
125
+ instructions.join("\n\n").freeze
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+ require "yaml"
6
+ require_relative "prepared_work"
7
+ require_relative "private_directory"
8
+ require_relative "../molecules/lifecycle_exclusion"
9
+ require_relative "../organisms/assignment_executor"
10
+
11
+ module Ace
12
+ module Assign
13
+ module Authority
14
+ # Existing assignment/progress files, rooted only in the authenticated
15
+ # original worker scratch. This cache never supplies authority or a graph.
16
+ class PreparedQueue
17
+ attr_reader :cache_base, :directory, :work, :descriptor
18
+
19
+ def initialize(work:, descriptor:)
20
+ @work, @descriptor = work, descriptor
21
+ @scratch = PrivateDirectory.verify!(descriptor.fetch("original_worker_scratch_root"))
22
+ %w[mapping_id attempt_id assignment_id].each do |field|
23
+ value = descriptor.fetch(field)
24
+ unavailable!("queue identity") unless value.is_a?(String) && value.match?(PreparedWork::TOKEN)
25
+ end
26
+ @cache_base = File.join(@scratch, "prepared-queues", descriptor.fetch("mapping_id"), descriptor.fetch("attempt_id"))
27
+ @directory = File.join(cache_base, descriptor.fetch("assignment_id"))
28
+ definition = JSON.parse(work.definition_bytes(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree")))
29
+ unavailable!("captured assignment metadata") unless %w[name source_config created_at].all? do |field|
30
+ definition[field].is_a?(String) && !definition[field].empty?
31
+ end
32
+ validate_timestamp!(definition.fetch("created_at"))
33
+ validate_timestamp!(definition["updated_at"]) if definition.key?("updated_at")
34
+ @metadata = Models::Assignment.from_h(definition, cache_dir: directory).to_h
35
+ lock_root = File.join(@scratch, "prepared-queue-exclusion")
36
+ private_directory!(lock_root, create: true)
37
+ @exclusion = Molecules::LifecycleExclusion.new(root: lock_root)
38
+ @key = @exclusion.assignment_key(descriptor.values_at("mapping_id", "attempt_id", "assignment_id").join(":"))
39
+ end
40
+
41
+ # Called ONLY after the fixed adapter proves actual original self. Later
42
+ # CLI consumers use with_executor and cannot recreate a missing queue.
43
+ def activate_original!
44
+ @exclusion.with_exclusive(@key) do
45
+ publish_initial! unless File.exist?(directory) || File.symlink?(directory)
46
+ executor = verified_executor
47
+ result = executor.status
48
+ state = result.fetch(:state)
49
+ unavailable!("selected queue has failed progress") unless state.failed.empty?
50
+ return nil if state.subtree_complete?(descriptor.fetch("scope"))
51
+ unless state.steps.all? { |step| step.status == :pending }
52
+ unavailable!("original adapter has already activated queue progress")
53
+ end
54
+ root = state.find_by_number(descriptor.fetch("scope"))
55
+ unavailable!("original selected fork root") unless root && root.fork?
56
+ executor.step_writer.mark_active(root.file_path)
57
+ # Same held-byte consumer validates the resulting progress before
58
+ # returning to the adapter that may now start its provider.
59
+ executor.status
60
+ root
61
+ end
62
+ end
63
+
64
+ def with_executor
65
+ @exclusion.with_exclusive(@key) do
66
+ unavailable!("published queue is unavailable; no reconstruction") unless File.directory?(directory)
67
+ yield verified_executor
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def verified_executor
74
+ private_directory!(directory)
75
+ private_directory!(File.join(directory, "steps"))
76
+ private_directory!(File.join(directory, "reports"))
77
+ metadata = work.parse_queue_metadata!(held_read(File.join(directory, "assignment.yaml"), PreparedWork::MAX_MANIFEST))
78
+ unavailable!("queue assignment metadata differs") unless metadata.is_a?(Hash) &&
79
+ metadata.reject { |key, _| key == "updated_at" } == @metadata.reject { |key, _| key == "updated_at" }
80
+ validate_timestamp!(metadata["updated_at"]) if metadata.key?("updated_at")
81
+ unavailable!("queue job differs") unless held_read(File.join(directory, "job.yaml"), PreparedWork::MAX_TEXT) == work.files.fetch("job.yaml")
82
+ owner = self
83
+ scanner = Molecules::QueueScanner.new
84
+ parser = scanner.method(:scan_captured)
85
+ scanner.define_singleton_method(:scan) do |steps_dir, assignment:|
86
+ raise AttemptErrors::EvidenceUnavailable, "prepared queue scanner path differs" unless steps_dir == File.join(owner.directory, "steps")
87
+ steps, reports = owner.send(:captured_queue)
88
+ parser.call(steps: steps, assignment: assignment, reports: reports)
89
+ end
90
+ Organisms::AssignmentExecutor.new(cache_base: cache_base,
91
+ selected_assignment: Models::Assignment.from_h(metadata, cache_dir: directory), queue_scanner: scanner)
92
+ end
93
+
94
+ def captured_queue
95
+ expected = work.manifest.fetch("steps").map { |entry| entry.fetch("filename") }
96
+ steps_dir = File.join(directory, "steps")
97
+ unavailable!("queue step inventory differs") unless Dir.children(steps_dir).sort == expected.sort
98
+ steps = expected.to_h do |filename|
99
+ bytes = held_read(File.join(steps_dir, filename), PreparedWork::MAX_TEXT)
100
+ parsed = work.parse_queue_step!(bytes)
101
+ accepted = work.parse_queue_step!(work.files.fetch("steps/" + filename))
102
+ projection = ->(value) { value.fetch(:frontmatter).reject { |key, _| PreparedWork::PROGRESS.include?(key) } }
103
+ unless projection.call(parsed) == projection.call(accepted) && parsed.fetch(:body) == accepted.fetch(:body) &&
104
+ %w[pending active done failed].include?(parsed.fetch(:frontmatter).fetch("status", "pending"))
105
+ unavailable!("queue work-bearing input differs")
106
+ end
107
+ [filename, bytes]
108
+ end
109
+ reports_dir = File.join(directory, "reports")
110
+ allowed = work.manifest.fetch("steps").map do |entry|
111
+ name = work.parse_queue_step!(work.files.fetch("steps/" + entry.fetch("filename"))).fetch(:frontmatter).fetch("name")
112
+ Atoms::StepFileParser.generate_report_filename(entry.fetch("number"), name)
113
+ end
114
+ reports = Dir.children(reports_dir).to_h do |filename|
115
+ unavailable!("queue report inventory differs") unless allowed.include?(filename)
116
+ bytes = held_read(File.join(reports_dir, filename), PreparedWork::MAX_TEXT)
117
+ work.parse_queue_step!(bytes)
118
+ [filename, bytes]
119
+ end
120
+ [steps.freeze, reports.freeze]
121
+ end
122
+
123
+ def publish_initial!
124
+ private_directory!(File.join(@scratch, "prepared-queues"), create: true)
125
+ private_directory!(File.dirname(cache_base), create: true)
126
+ private_directory!(cache_base, create: true)
127
+ stage = File.join(cache_base, ".prepared-#{SecureRandom.hex(12)}")
128
+ Dir.mkdir(stage, 0700)
129
+ %w[steps reports].each { |name| Dir.mkdir(File.join(stage, name), 0700) }
130
+ durable_write(File.join(stage, "assignment.yaml"), @metadata.to_yaml)
131
+ durable_write(File.join(stage, "job.yaml"), work.files.fetch("job.yaml"))
132
+ work.manifest.fetch("steps").each do |entry|
133
+ name = entry.fetch("filename")
134
+ durable_write(File.join(stage, "steps", name), work.files.fetch("steps/" + name))
135
+ end
136
+ %w[steps reports].each { |name| File.open(File.join(stage, name), File::RDONLY) { |file| file.fsync } }
137
+ File.open(stage, File::RDONLY) { |file| file.fsync }
138
+ unavailable!("queue publication collision") if File.exist?(directory) || File.symlink?(directory)
139
+ File.rename(stage, directory)
140
+ File.open(cache_base, File::RDONLY) { |file| file.fsync }
141
+ ensure
142
+ FileUtils.remove_entry(stage) if stage && File.directory?(stage) && !File.symlink?(stage)
143
+ end
144
+
145
+ def private_directory!(path, create: false)
146
+ Dir.mkdir(path, 0700) if create && !File.exist?(path) && !File.symlink?(path)
147
+ PrivateDirectory.verify!(path)
148
+ end
149
+
150
+ def durable_write(path, bytes)
151
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
152
+ file.write(bytes); file.flush; file.fsync
153
+ end
154
+ end
155
+
156
+ def held_read(path, limit)
157
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
158
+ before = file.stat
159
+ unavailable!("queue held file kind/size") unless before.file? && before.uid == Process.uid &&
160
+ (before.mode & 0077).zero? && before.size.between?(1, limit)
161
+ bytes = file.read(limit + 1).force_encoding(Encoding::UTF_8)
162
+ after = file.stat
163
+ unavailable!("queue held file changed") unless bytes.valid_encoding? && !bytes.include?("\0") &&
164
+ bytes.bytesize == before.size && [before.dev, before.ino, before.size, before.mtime, before.ctime] ==
165
+ [after.dev, after.ino, after.size, after.mtime, after.ctime]
166
+ bytes.freeze
167
+ end
168
+ rescue SystemCallError
169
+ unavailable!("queue held file unavailable")
170
+ end
171
+
172
+ def unavailable!(reason)
173
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: #{reason}"
174
+ end
175
+
176
+ def validate_timestamp!(value)
177
+ unavailable!("captured assignment timestamp") unless value.is_a?(String)
178
+ Time.iso8601(value)
179
+ rescue ArgumentError
180
+ unavailable!("captured assignment timestamp")
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end