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
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "json"
4
5
  require "fileutils"
5
6
  require "tmpdir"
6
- require "ace/herdr"
7
+ require "tempfile"
8
+ require "ace/herdr/molecules/bounded_process"
7
9
  require_relative "private_directory"
8
10
 
9
11
  module Ace
@@ -16,11 +18,103 @@ module Ace
16
18
  DEADLINE = 30
17
19
  SHA = /\A[0-9a-f]{40}\z/
18
20
 
21
+ class CampaignReadView
22
+ def initialize(owner:, repository:, base:, deadline:)
23
+ @owner, @repository, @base, @deadline = owner, repository, base, deadline
24
+ @thread, @active = Thread.current, true
25
+ end
26
+
27
+ def close = @active = false
28
+ def head = command("rev-parse", "HEAD").strip
29
+ def base
30
+ commit!(@base)
31
+ @base
32
+ end
33
+ def commit!(revision)
34
+ raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
35
+ command("cat-file", "-e", "#{revision}^{commit}")
36
+ true
37
+ end
38
+ def full_diff?(base, head)
39
+ commit!(base)
40
+ commit!(head)
41
+ !command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
42
+ end
43
+ def clean?
44
+ command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
45
+ .all? { |entry| entry.start_with?("?? .ace-local/") }
46
+ end
47
+
48
+ private
49
+
50
+ def command(*arguments)
51
+ unless @active && Thread.current.equal?(@thread)
52
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
53
+ end
54
+ @owner.send(:git, @repository, @deadline, *arguments)
55
+ rescue Timeout::Error
56
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
57
+ end
58
+ end
59
+
60
+ def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
61
+ raise ArgumentError, "campaign repository requires a block" unless block_given?
62
+ deadline = monotonic + DEADLINE
63
+ materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
64
+ directory = materialized.fetch("directory")
65
+ reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
66
+ unless reader.head == head
67
+ raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
68
+ end
69
+ reader.base
70
+ yield reader
71
+ ensure
72
+ reader&.close
73
+ FileUtils.remove_entry(directory) if directory && File.directory?(directory)
74
+ end
75
+
19
76
  def initialize(root:)
20
77
  @root = File.expand_path(root)
21
78
  verify_root!
22
79
  end
23
80
 
81
+ # Export the already validated prepared tree using the same isolated Git
82
+ # boundary as admission. The returned bytes are retained by the producer;
83
+ # registration must not rebuild them after a lost response.
84
+ def export_prepared(work:)
85
+ require_relative "prepared_work"
86
+ reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
87
+ verify_root!
88
+ deadline = monotonic + DEADLINE
89
+ Dir.mktmpdir("prepared-export-", @root) do |directory|
90
+ File.chmod(0700, directory)
91
+ repository = File.join(directory, "repository")
92
+ git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
93
+ work.files.each do |path, bytes|
94
+ target = File.join(repository, path)
95
+ FileUtils.mkdir_p(File.dirname(target), mode: 0700)
96
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
97
+ file.write(bytes)
98
+ end
99
+ end
100
+ git(repository, deadline, "add", "--", ".")
101
+ git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
102
+ "-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
103
+ head = git(repository, deadline, "rev-parse", "HEAD").strip
104
+ tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
105
+ bundle = File.join(directory, "prepared.bundle")
106
+ git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
107
+ bytes = read_bundle(bundle).freeze
108
+ {"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
109
+ "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
110
+ "definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
111
+ end
112
+ rescue Timeout::Error
113
+ reject!("Prepared export deadline exceeded")
114
+ rescue SystemCallError
115
+ reject!("Prepared export filesystem is unavailable")
116
+ end
117
+
24
118
  def admit(bytes:, sha256:, size:, head:, &consumer)
25
119
  unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
26
120
  size == bytes.bytesize && sha256.is_a?(String) &&
@@ -70,7 +164,7 @@ module Ace
70
164
  destination = Dir.mktmpdir("candidate-", root)
71
165
  File.chmod(0700, destination)
72
166
  begin
73
- entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).split("\0")
167
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
74
168
  total = 0
75
169
  links = []
76
170
  entries.each do |entry|
@@ -98,6 +192,7 @@ module Ace
98
192
  File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
99
193
  mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
100
194
  end
195
+ yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
101
196
  end
102
197
  links.each { |target, content| File.symlink(content, target) }
103
198
  links.each do |target, _content|
@@ -116,8 +211,80 @@ module Ace
116
211
  reject!("Candidate filesystem materialization is unverifiable")
117
212
  end
118
213
 
214
+ # Executing campaign children need local Git history as well as files.
215
+ # Create only our own empty administration; no uploader configuration,
216
+ # checkout filters or hooks enter this private repository.
217
+ def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
218
+ deadline = monotonic + DEADLINE
219
+ result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
220
+ directory = result.fetch("directory")
221
+ Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
222
+ file.binmode
223
+ file.write(bytes); file.flush
224
+ git(directory, deadline, "init", "--template=", "--initial-branch=work")
225
+ git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
226
+ git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
227
+ git(directory, deadline, "read-tree", head)
228
+ reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
229
+ end
230
+ result
231
+ rescue Exception
232
+ FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
233
+ raise
234
+ end
235
+
236
+ # Full immutable review input comes from the same admitted Git objects
237
+ # used for materialization, never a second read of the scratch checkout.
238
+ def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
239
+ subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
240
+ .delete_suffix("}") + ',"files":['
241
+ first = true
242
+ result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
243
+ entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
244
+ separator = first ? "" : ","
245
+ reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
246
+ subject << separator << entry
247
+ first = false
248
+ end
249
+ subject << "]}"
250
+ result.merge("subject" => subject.freeze)
251
+ end
252
+
253
+ # Prepared instructions have a narrower tree contract than candidates:
254
+ # no executable entries or symlinks, and bounded UTF-8 files. Read only
255
+ # verified Git objects; never run checkout filters or reopen uploader paths.
256
+ def prepared_files(repository:, deadline:, head:)
257
+ entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
258
+ reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
259
+ total = 0
260
+ files = entries.to_h do |entry|
261
+ metadata, path = entry.split("\t", 2)
262
+ mode, kind, oid = metadata.to_s.split(" ")
263
+ unless path && mode == "100644" && kind == "blob" && oid.match?(SHA) &&
264
+ !path.start_with?("/") && path.split("/", -1).none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) }
265
+ reject!("Prepared tree contains unsafe entry")
266
+ end
267
+ size = git(repository, deadline, "cat-file", "-s", oid).strip
268
+ reject!("Prepared text is oversized") unless size.match?(/\A[0-9]+\z/) && size.to_i.between?(1, 1024 * 1024)
269
+ total += size.to_i
270
+ reject!("Prepared expanded content is oversized") if total > MAX_BYTES
271
+ [path, git(repository, deadline, "cat-file", "blob", oid, limit: 1024 * 1024).b]
272
+ end
273
+ reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
274
+ files
275
+ end
276
+
119
277
  private
120
278
 
279
+ def review_bytes(bytes)
280
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
281
+ if text.valid_encoding? && !text.include?("\0")
282
+ {"encoding" => "utf-8", "bytes" => text}
283
+ else
284
+ {"encoding" => "base64", "bytes" => [bytes].pack("m0")}
285
+ end
286
+ end
287
+
121
288
  def verify_root!
122
289
  PrivateDirectory.verify!(@root)
123
290
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+ require "ace/runtime/molecules/protected_artifact_set"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Extends the existing held artifact protection solely for the bounded
10
+ # root result view. Private archive storage is never admitted here.
11
+ class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
12
+ class LinuxACL
13
+ def absent!(handle, attribute)
14
+ unless RUBY_PLATFORM.include?("linux")
15
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
16
+ end
17
+ function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
18
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
19
+ size = function.call(handle.fileno, attribute, 0, 0)
20
+ error = Fiddle.last_error
21
+ unless size == -1 && error == Errno::ENODATA::Errno
22
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
23
+ end
24
+ rescue Fiddle::DLError
25
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
26
+ end
27
+ end
28
+
29
+ def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
30
+ unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
31
+ File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
32
+ raise ArgumentError, "invalid fixed cleanup result view"
33
+ end
34
+ super(**options)
35
+ @result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
36
+ end
37
+
38
+ def verify!(path, handle, directory:)
39
+ super
40
+ return unless path == @result_path || path == @parent
41
+ parent = path == @parent
42
+ stat = handle.stat
43
+ unless directory == parent && stat.gid == @authority_gid &&
44
+ (stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
45
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
46
+ end
47
+ @acl.absent!(handle, "system.posix_acl_access")
48
+ @acl.absent!(handle, "system.posix_acl_default") if parent
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -2,7 +2,10 @@
2
2
  require "securerandom"
3
3
  require_relative "deployment"
4
4
  require_relative "transfer_codec"
5
+ require_relative "campaign_execution"
6
+ require_relative "candidate_transfer"
5
7
  require_relative "../molecules/canonical_evidence"
8
+ require_relative "../molecules/execution_scope_lineage"
6
9
 
7
10
  module Ace
8
11
  module Assign
@@ -17,22 +20,32 @@ module Ace
17
20
  end
18
21
 
19
22
  def call(operation, params, mutation_id: nil, timeout: nil, upload_parts: nil, download: false, purpose: nil)
20
- if operation == "evidence_fetch" && (!download || purpose != :artifacts || upload_parts)
21
- raise ArgumentError, "evidence fetch requires fixed artifacts download"
23
+ if operation == "register_assignment" && (!upload_parts || purpose != :candidate || download)
24
+ raise ArgumentError, "prepared registration requires fixed candidate upload"
25
+ end
26
+ if operation == "evidence_fetch" && (!download || purpose != (%w[prepared_work campaign_candidate].include?(params["kind"]) ? :candidate : :artifacts) || upload_parts)
27
+ raise ArgumentError, "evidence fetch requires source-fixed kind download"
22
28
  end
23
29
  if operation == "submit_result" && (!upload_parts || purpose != :receipt_artifacts || download)
24
30
  raise ArgumentError, "result submission requires fixed receipt upload"
25
31
  end
32
+ if operation == "campaign_export_result" && (!download || purpose != :artifacts || upload_parts || mutation_id)
33
+ raise ArgumentError, "campaign result requires fixed read-only artifact download"
34
+ end
35
+ if operation == "campaign_record_round" && (!upload_parts || purpose != :receipt_artifacts || download)
36
+ raise ArgumentError, "campaign round requires fixed bounded upload"
37
+ end
26
38
  @deployment.verify!(mapping_id, kernel: @kernel)
27
39
  wire = Ace::Runtime::Molecules::ProtectedSocket
28
40
  path = @service.fetch("socket_path")
29
41
  before = wire.socket_identity(path)
30
42
  raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless before.last == @service.fetch("uid")
31
43
  raise ArgumentError, "cannot upload and download on one request" if upload_parts && download
32
- codec = transfer_codec if upload_parts || download
44
+ prepared_download = operation == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(params["kind"])
45
+ codec = transfer_codec if upload_parts || download && !prepared_download
33
46
  descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
34
47
  parameters = upload_parts ? params.merge("transfer" => descriptor) : params
35
- cap = %w[reserve_attempt close_execution_scope].include?(operation) ? 90 : 30
48
+ cap = %w[reserve_attempt close_execution_scope stop_attempt].include?(operation) ? 90 : 30
36
49
  duration = timeout || (cap == 90 ? 90 : 5)
37
50
  unless duration.is_a?(Numeric) && duration.positive? && duration.finite?
38
51
  raise ArgumentError, "authority timeout must be finite and positive"
@@ -47,7 +60,7 @@ module Ace
47
60
  wire.write(socket, {"version" => 1, "operation" => operation,
48
61
  "mutation_id" => mutation_id, "project_id" => @map.fetch("project_id"),
49
62
  "params" => parameters.merge("mapping_id" => mapping_id)}, deadline: deadline, limit: upload_parts || download ? 16_384 : wire::LIMIT)
50
- if %w[evidence_fetch observe_execution_scope close_execution_scope].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
63
+ if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review assignment_inventory evidence_fetch observe_execution_scope close_execution_scope stop_attempt prompt_status launch_input_inhibit_selection launch_input_inhibit_completion launch_prompt_intent launch_prompt_completion claim_service_settlement workspace_prune_preview_context].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
51
64
  socket.shutdown(Socket::SHUT_WR)
52
65
  end
53
66
  if upload_parts
@@ -57,14 +70,21 @@ module Ace
57
70
  result = wire.read(socket, deadline: deadline, limit: 16_384)
58
71
  unless result.is_a?(Hash) && result["status"] == "ok" && result["data"].is_a?(Hash)
59
72
  code = result.is_a?(Hash) ? result.dig("error", "code") : nil
73
+ raise AttemptErrors::BoundedResultUnavailable, "protected authority result exceeds frame bound" if code == "bounded_result"
60
74
  raise AttemptErrors::EvidenceUnavailable, "protected authority refused (#{code || 'invalid_response'})"
61
75
  end
62
76
  transport = result.fetch("transport")
63
77
  unless transport.is_a?(Hash) && transport.keys == ["replayed"] && [true, false].include?(transport["replayed"])
64
78
  raise AttemptErrors::EvidenceUnavailable, "authority replay metadata is unavailable"
65
79
  end
66
- validate_evidence_download!(result.fetch("data"), params) if operation == "evidence_fetch"
80
+ if operation == "evidence_fetch"
81
+ if %w[prepared_work campaign_candidate].include?(params["kind"]) && transport.fetch("replayed")
82
+ raise AttemptErrors::EvidenceUnavailable, "prepared fetch cannot replay a mutation"
83
+ end
84
+ validate_evidence_download!(result.fetch("data"), params)
85
+ end
67
86
  parts = if download
87
+ codec = prepared_transfer_codec(result.fetch("data").fetch("descriptor")) if prepared_download
68
88
  codec.receive(socket, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
69
89
  Array.new(input.count) { |index| input.bytes(index: index) }
70
90
  end
@@ -74,10 +94,56 @@ module Ace
74
94
  rescue SystemCallError, IOError
75
95
  raise AttemptErrors::EvidenceUnavailable, "protected authority connection unavailable; no local fallback"
76
96
  end
97
+
98
+
99
+ # Fixed original-launcher stream. Public calls retain their existing
100
+ # complete-upload EOF contract; no caller-selected framing flag exists.
101
+ def with_launch_control(state:)
102
+ @deployment.verify!(mapping_id, kernel: @kernel)
103
+ wire = Ace::Runtime::Molecules::ProtectedSocket
104
+ path = @service.fetch("socket_path")
105
+ before = wire.socket_identity(path)
106
+ raise Ace::Runtime::RuntimeUnavailableError, "Authority endpoint owner differs" unless before.last == @service.fetch("uid")
107
+ deadline = wire.deadline(30)
108
+ wire.connect(path, deadline: deadline) do |socket|
109
+ peer = @kernel.peer(socket)
110
+ unless peer.values_at("uid", "gid", "groups") == @service.values_at("uid", "gid", "groups") && wire.socket_identity(path) == before
111
+ raise AttemptErrors::UnauthorizedIdentity, "Authority stream peer changed"
112
+ end
113
+ wire.write(socket, {"version" => 1, "operation" => "launch_control", "mutation_id" => nil,
114
+ "project_id" => @map.fetch("project_id"), "params" => state.slice("assignment_id", "attempt_id").merge("mapping_id" => mapping_id)},
115
+ deadline: deadline, limit: 16_384)
116
+ ready = wire.read(socket, deadline: deadline, limit: 16_384)
117
+ validate_launch_control_ready!(ready, state)
118
+ yield socket, transfer_codec, ready
119
+ end
120
+ rescue IOError, SystemCallError
121
+ raise AttemptErrors::EvidenceUnavailable, "Original launch control connection is unavailable"
122
+ end
123
+
77
124
  private
78
125
 
126
+ def validate_launch_control_ready!(ready, state)
127
+ unless ready.is_a?(Hash) && ready.keys.sort == %w[attempt_id generation journal_commit original_binding_digest type version] &&
128
+ ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_control_ready" &&
129
+ ready["attempt_id"] == state.fetch("attempt_id") && ready["generation"].is_a?(Integer) && ready["generation"].positive? &&
130
+ ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
131
+ ready["original_binding_digest"].is_a?(String) && ready["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
132
+ raise AttemptErrors::EvidenceUnavailable, "Original launch control admission is unavailable"
133
+ end
134
+ true
135
+ end
136
+
79
137
  def validate_evidence_download!(data, params)
80
138
  descriptor, transfer = data.values_at("descriptor", "transfer")
139
+ if params["kind"] == "prepared_work"
140
+ validate_prepared_download!(data, params)
141
+ return
142
+ end
143
+ if params["kind"] == "campaign_candidate"
144
+ validate_campaign_candidate_download!(data, params)
145
+ return
146
+ end
81
147
  unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
82
148
  Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
83
149
  descriptor["project_id"] == @map.fetch("project_id") &&
@@ -101,6 +167,90 @@ module Ace
101
167
  end
102
168
  TransferCodec.new(root: installed.fetch("scratch_root"))
103
169
  end
170
+
171
+ def validate_prepared_download!(data, params)
172
+ descriptor, transfer = data.values_at("descriptor", "transfer")
173
+ fields = %w[version kind purpose artifact project_id mapping_id assignment_id attempt_id task_id scope definition_digest selection_sha256 prepared_head prepared_tree manifest_bytes manifest_sha256 registration_generation registration_commit original_binding_digest ref bytes sha256 task_context_entry original_worker_identity original_worker_scratch_root workspace_exclusion].sort
174
+ valid = data.keys.sort == %w[descriptor generation journal_commit transfer] && descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
175
+ descriptor["version"].is_a?(Integer) && descriptor["version"] == 1 &&
176
+ params.values_at("kind", "purpose_id", "artifact_id") == %w[prepared_work original_prepared_work prepared_bundle] &&
177
+ descriptor.values_at("kind", "purpose", "artifact") == %w[prepared_work original_prepared_work prepared_bundle] &&
178
+ descriptor["project_id"] == @map.fetch("project_id") && descriptor["mapping_id"] == mapping_id &&
179
+ %w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
180
+ descriptor["task_id"].is_a?(String) && descriptor["task_id"].match?(Deployment::TOKEN) &&
181
+ descriptor["scope"].is_a?(String) && descriptor["scope"].bytesize.between?(1, 128) && descriptor["scope"].match?(/\A[0-9]+(?:\.[0-9]+)*\z/) &&
182
+ %w[definition_digest selection_sha256 manifest_sha256 original_binding_digest sha256].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
183
+ %w[prepared_head prepared_tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{40}\z/) } &&
184
+ descriptor["manifest_bytes"].is_a?(Integer) && descriptor["manifest_bytes"].between?(1, 32_768) &&
185
+ descriptor["registration_generation"].is_a?(Integer) && descriptor["registration_generation"].positive? &&
186
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, TransferCodec::LIMITS.fetch(:candidate).first) &&
187
+ descriptor["registration_commit"].is_a?(String) && descriptor["registration_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
188
+ descriptor["ref"] == "execution/prepared/#{params.fetch('assignment_id')}-#{descriptor['sha256']}.bundle" &&
189
+ data["generation"].is_a?(Integer) && data["generation"].positive? &&
190
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
191
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"].is_a?(Integer) && transfer["version"] == 1 &&
192
+ transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
193
+ raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs" unless valid
194
+ TaskContextEntry.validate!(descriptor.fetch("task_context_entry"))
195
+ Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
196
+ unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
197
+ "/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
198
+ raise AttemptErrors::EvidenceUnavailable, "original prepared lifecycle mapping differs"
199
+ end
200
+ identity = descriptor.fetch("original_worker_identity")
201
+ birth = identity.is_a?(Hash) && identity["started_at"]
202
+ boot = birth.is_a?(String) && birth.split(":", -1)[1]
203
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
204
+ root = descriptor.fetch("original_worker_scratch_root")
205
+ unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
206
+ root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
207
+ raise AttemptErrors::EvidenceUnavailable, "original prepared scratch root differs"
208
+ end
209
+ rescue ArgumentError, KeyError
210
+ raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs"
211
+ end
212
+
213
+ def validate_campaign_candidate_download!(data, params)
214
+ descriptor, transfer = data.values_at("descriptor", "transfer")
215
+ fields = %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id
216
+ project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort
217
+ unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
218
+ descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
219
+ params.values_at("purpose_id", "artifact_id") == %w[original_campaign_candidate candidate_bundle] &&
220
+ descriptor["mapping_id"] == mapping_id && descriptor["project_id"] == @map.fetch("project_id") &&
221
+ %w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
222
+ %w[head tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(CandidateTransfer::SHA) } &&
223
+ %w[sha256 original_binding_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
224
+ descriptor["candidate_generation"].is_a?(Integer) && descriptor["candidate_generation"].positive? &&
225
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, CandidateTransfer::MAX_BYTES) &&
226
+ descriptor["campaign_execution"].is_a?(Hash) && descriptor["campaign_execution"].keys.sort == CampaignExecution::FIELDS &&
227
+ data["generation"].is_a?(Integer) && data["generation"].positive? &&
228
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
229
+ transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
230
+ transfer["version"].is_a?(Integer) &&
231
+ transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
232
+ raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
233
+ end
234
+ identity = descriptor.fetch("original_worker_identity")
235
+ birth = identity.is_a?(Hash) && identity["started_at"]
236
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: birth.to_s.split(":", -1)[1])
237
+ root = descriptor.fetch("original_worker_scratch_root")
238
+ unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
239
+ root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
240
+ raise AttemptErrors::EvidenceUnavailable, "original campaign scratch root differs"
241
+ end
242
+ rescue ArgumentError, KeyError
243
+ raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
244
+ end
245
+
246
+ def prepared_transfer_codec(descriptor)
247
+ peer = @kernel.capture(Process.pid)
248
+ original = descriptor.fetch("original_worker_identity")
249
+ unless peer.values_at("uid", "gid", "groups") == original.values_at("uid", "gid", "groups")
250
+ raise AttemptErrors::UnauthorizedIdentity, "original prepared scratch principal differs"
251
+ end
252
+ TransferCodec.new(root: descriptor.fetch("original_worker_scratch_root"))
253
+ end
104
254
  end
105
255
  end
106
256
  end