ace-assign 0.69.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +39 -10
@@ -6,7 +6,11 @@ require "fileutils"
6
6
  require "tmpdir"
7
7
  require "tempfile"
8
8
  require "ace/herdr/molecules/bounded_process"
9
+ require "ace/review/atoms/campaign_contract"
9
10
  require_relative "private_directory"
11
+ require_relative "../atoms/candidate_limits"
12
+ require_relative "candidate_head_comparison"
13
+ require_relative "candidate_blob_batch"
10
14
 
11
15
  module Ace
12
16
  module Assign
@@ -14,8 +18,17 @@ module Ace
14
18
  # Untrusted bundles enter a clean bare quarantine. Only complete verified
15
19
  # objects are retained; no uploader configuration or worktree is read.
16
20
  class CandidateTransfer
17
- MAX_BYTES = 64 * 1024 * 1024
18
- DEADLINE = 30
21
+ include CandidateHeadComparison
22
+ include CandidateBlobBatch
23
+ MAX_BYTES = Atoms::CandidateLimits::BYTES
24
+ MAX_REVIEW_BYTES = Ace::Review::Atoms::CampaignContract::MAX_HEAD_CONTEXT_BYTES
25
+ # Measured on the real 82,809,675-byte candidate (9,203 entries,
26
+ # 71,583,200 expanded bytes): two full admits alone need ~14s of
27
+ # bounded Git work and the complete comparison ~23s on a loaded host
28
+ # (evidence in .ace-tasks/8xf.t.ei9-wave-3 evidence). 30s failed
29
+ # closed under legitimate load; 60s keeps a finite secondary guard
30
+ # with measured headroom. Inputs stay byte/count-bounded separately.
31
+ DEADLINE = 60
19
32
  SHA = /\A[0-9a-f]{40}\z/
20
33
 
21
34
  class CampaignReadView
@@ -115,14 +128,14 @@ module Ace
115
128
  reject!("Prepared export filesystem is unavailable")
116
129
  end
117
130
 
118
- def admit(bytes:, sha256:, size:, head:, &consumer)
131
+ def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
119
132
  unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
120
133
  size == bytes.bytesize && sha256.is_a?(String) &&
121
134
  Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
122
135
  reject!("Candidate bundle binding or size is invalid")
123
136
  end
124
137
  verify_root!
125
- deadline = monotonic + DEADLINE
138
+ deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
126
139
  Dir.mktmpdir("quarantine-", @root) do |directory|
127
140
  File.chmod(0700, directory)
128
141
  bundle = File.join(directory, "candidate.bundle")
@@ -165,20 +178,29 @@ module Ace
165
178
  File.chmod(0700, destination)
166
179
  begin
167
180
  entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
168
- total = 0
169
- links = []
170
- entries.each do |entry|
181
+ reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
182
+ parsed = entries.map do |entry|
171
183
  metadata, path = entry.split("\t", 2)
172
184
  mode, kind, oid = metadata.to_s.split(" ")
173
- parts = path.to_s.split("/", -1)
174
- unless path && !path.start_with?("/") &&
175
- parts.none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) } &&
176
- kind == "blob" && %w[100644 100755 120000].include?(mode) && oid.match?(SHA)
185
+ # Total path bytes are bounded before any component or
186
+ # filesystem prefix work consumes them.
187
+ reject!("Candidate path exceeds bounded length") unless path && !path.start_with?("/") &&
188
+ path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
189
+ parts = path.split("/", -1)
190
+ unless parts.none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
191
+ %w[. .. .git].include?(part.downcase) } &&
192
+ kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(SHA)
177
193
  reject!("Candidate contains unsafe filesystem entry")
178
194
  end
179
- content = git(repository, deadline, "cat-file", "blob", oid, limit: MAX_BYTES).b
180
- total += content.bytesize
181
- reject!("Candidate expanded content is oversized") if total > MAX_BYTES
195
+ [path, mode, oid]
196
+ end
197
+ by_oid = parsed.group_by(&:last)
198
+ sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
199
+ reject!("Candidate expanded content is oversized") if parsed.sum { |_path, _mode, oid| sizes.fetch(oid) } > MAX_BYTES
200
+ links = []
201
+ ordered = parsed.each
202
+ each_candidate_blob(repository, deadline, parsed.map { |_path, _mode, oid| [oid, sizes.fetch(oid)] }) do |oid, content|
203
+ path, mode, _oid = ordered.next
182
204
  target = File.join(destination, path)
183
205
  FileUtils.mkdir_p(File.dirname(target), mode: 0700)
184
206
  if mode == "120000"
@@ -242,7 +264,7 @@ module Ace
242
264
  result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
243
265
  entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
244
266
  separator = first ? "" : ","
245
- reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
267
+ reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
246
268
  subject << separator << entry
247
269
  first = false
248
270
  end
@@ -256,19 +278,26 @@ module Ace
256
278
  def prepared_files(repository:, deadline:, head:)
257
279
  entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
258
280
  reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
259
- total = 0
260
- files = entries.to_h do |entry|
281
+ parsed = entries.map do |entry|
261
282
  metadata, path = entry.split("\t", 2)
262
283
  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) }
284
+ reject!("Prepared tree path exceeds bounded length") unless path && !path.start_with?("/") &&
285
+ path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
286
+ unless mode == "100644" && kind == "blob" && oid&.match?(SHA) &&
287
+ path.split("/", -1).none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
288
+ %w[. .. .git].include?(part.downcase) }
265
289
  reject!("Prepared tree contains unsafe entry")
266
290
  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]
291
+ [path, oid]
292
+ end
293
+ by_oid = parsed.group_by(&:last)
294
+ sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
295
+ reject!("Prepared text is oversized") unless sizes.values.all? { |size| size.between?(1, 1024 * 1024) }
296
+ reject!("Prepared expanded content is oversized") if parsed.sum { |_path, oid| sizes.fetch(oid) } > Atoms::CandidateLimits::PREPARED_BYTES
297
+ files = {}
298
+ ordered = parsed.each
299
+ each_candidate_blob(repository, deadline, parsed.map { |_path, oid| [oid, sizes.fetch(oid)] }) do |_oid, content|
300
+ files[ordered.next.first] = content
272
301
  end
273
302
  reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
274
303
  files
@@ -289,18 +318,19 @@ module Ace
289
318
  PrivateDirectory.verify!(@root)
290
319
  end
291
320
 
292
- def git(repository, deadline, *arguments, limit: 65_536)
321
+ def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
293
322
  remaining = deadline - monotonic
294
323
  raise Timeout::Error if remaining <= 0
295
324
  # Git variables may redirect object/config storage even in a clean
296
325
  # repository. Strip every inherited Git setting and global config.
297
326
  environment = ENV.keys.to_h { |key| [key, nil] }
298
327
  environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
328
+ "GIT_ATTR_NOSYSTEM" => "1",
299
329
  "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
300
330
  "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
301
331
  result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
302
- "-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
303
- stdin_data: "", timeout_s: remaining, output_limit: limit)
332
+ "-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "protocol.file.allow=always", *arguments],
333
+ stdin_data: stdin_data, timeout_s: remaining, output_limit: limit)
304
334
  reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
305
335
  result.stdout
306
336
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "../molecules/evidence_journal"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Project commands share the installed journal, without manufacturing a
11
+ # managed task or projecting command exit as completed product delivery.
12
+ class CommandExecution
13
+ OPERATIONS = %w[command_admit command_status command_claim command_bind command_complete].freeze
14
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
15
+ SHA = /\A[0-9a-f]{64}\z/
16
+
17
+ def initialize(project:, journal:, admission:)
18
+ @project, @journal, @admission = project, journal, admission
19
+ end
20
+
21
+ attr_reader :journal
22
+
23
+ def dispatch(request:, peer:, role:, deadline:)
24
+ operation = request.fetch("operation")
25
+ params = request.fetch("params")
26
+ raise ArgumentError, "invalid command project" unless request.fetch("project_id") == @project
27
+ id = params.fetch("execution_id")
28
+ raise ArgumentError, "invalid command reference" unless id.is_a?(String) && ID.match?(id)
29
+ if operation == "command_status"
30
+ strict!(params, %w[execution_id])
31
+ raise ArgumentError, "status mutation must be null" unless request.fetch("mutation_id").nil?
32
+ commit = @journal.ref_value
33
+ data = projection(id, commit)
34
+ @admission.command_reader!(peer: peer, definition: data.fetch("definition"), deadline: deadline)
35
+ return {data: data.merge("journal_commit" => commit), replayed: false}
36
+ end
37
+ strict!(params, case operation
38
+ when "command_admit" then %w[execution_id selection]
39
+ when "command_claim" then %w[execution_id caller]
40
+ when "command_bind" then %w[execution_id child]
41
+ when "command_complete" then %w[execution_id result]
42
+ else raise ArgumentError, "unknown command operation"
43
+ end)
44
+ authenticated_peer = peer
45
+ launcher = nil
46
+ plan = if operation == "command_admit"
47
+ @admission.command_request!(selection: params.fetch("selection"), peer: peer, deadline: deadline)
48
+ else
49
+ launcher = @admission.command_launcher!(peer: authenticated_peer, deadline: deadline)
50
+ nil
51
+ end
52
+ authenticate = lambda do
53
+ if operation == "command_admit"
54
+ unless @admission.command_request!(selection: params.fetch("selection"), peer: peer, deadline: deadline) == plan
55
+ raise AttemptErrors::UnauthorizedIdentity, "command admission changed"
56
+ end
57
+ else
58
+ unless @admission.command_launcher!(peer: authenticated_peer, deadline: deadline) == launcher
59
+ raise AttemptErrors::UnauthorizedIdentity, "installed command launcher changed"
60
+ end
61
+ end
62
+ end
63
+ peer = launcher if launcher
64
+ expected = {"command_admit" => 0, "command_claim" => 1, "command_bind" => 2, "command_complete" => 3}.fetch(operation)
65
+ digest = Atoms::EvidenceDigest.digest({"project" => @project, "params" => params, "peer" => peer, "plan" => plan})
66
+ @journal.mutate(assignment_id: namespace(id), attempt_id: namespace(id),
67
+ mutation_id: request.fetch("mutation_id"), operation: operation, parameters_digest: digest,
68
+ expected_generation: expected, with_replay: true, deadline: deadline,
69
+ replay_authorizer: authenticate) do |_events, commit, _generation|
70
+ authenticate.call
71
+ prior = projection(id, commit, missing: true)
72
+ data = transition(operation, id, params, plan, prior, peer, deadline)
73
+ {events: [], data: data}
74
+ end
75
+ end
76
+
77
+ # Read only the accepted launcher-owned four-stage execution at the
78
+ # caller's held CAS prefix. Arbitrary allowed commands are not tests.
79
+ def test_evidence_at!(execution_id:, commit:, head:, tree:, deadline:)
80
+ unless execution_id.is_a?(String) && ID.match?(execution_id) && commit && @journal.ref_value == commit
81
+ raise AttemptErrors::EvidenceUnavailable, "test execution prefix or selector differs"
82
+ end
83
+ data = projection(execution_id, commit)
84
+ definition = data.fetch("definition")
85
+ events = @journal.read_events(namespace(execution_id), commit: commit)
86
+ accepted = events.select { |event| event["type"] == "authority_mutation" }
87
+ stages = accepted.map { |event| event.fetch("payload").fetch("data") }
88
+ unless accepted.size == 4 && data["phase"] == "exited" && data["result"] == {"exit_status" => 0, "signal" => nil} &&
89
+ definition["purpose"] == "test" && definition["source"] == {"head" => head, "tree" => tree} &&
90
+ [head, tree].all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{40}\z/) } &&
91
+ stages.all? { |stage| stage.fetch("definition") == definition } &&
92
+ stages.map { |stage| stage.fetch("phase") } == %w[admitted claimed running exited] &&
93
+ stages.drop(1).all? { |stage| stage.fetch("launcher") == data.fetch("launcher") } &&
94
+ stages[2].fetch("child") == data.fetch("child") && data.dig("launcher", "uid") == 0 &&
95
+ data.dig("child", "witnessed_by") == data.fetch("launcher") &&
96
+ data.dig("child", "kernel_identity") == data.dig("child", "root_witness")&.slice("pid", "uid", "started_at", "host") &&
97
+ data.dig("child", "root_witness", "cwd") == definition.dig("executor", "cwd") &&
98
+ data.dig("child", "kernel_identity", "uid") == definition.dig("executor", "uid")
99
+ raise AttemptErrors::ReceiptRejected, "candidate requires an exact successful executed test command"
100
+ end
101
+ @admission.command_test_evidence!(definition: definition, deadline: deadline)
102
+ command = JSON.generate(definition.fetch("argv"))
103
+ raise AttemptErrors::ReceiptRejected, "executed test command reference is oversized" if command.bytesize > 2048
104
+ {"schema" => "ace.test.execution-reference/v1", "reference" => execution_id,
105
+ "candidate_head" => head, "command" => command, "result" => "passed",
106
+ "recorded_at" => accepted.last.fetch("recorded_at"), "receipt_sha256" => Atoms::EvidenceDigest.digest(data)}
107
+ rescue KeyError, TypeError
108
+ raise AttemptErrors::EvidenceUnavailable, "canonical test execution is incomplete"
109
+ end
110
+
111
+ private
112
+
113
+ def transition(operation, id, params, plan, prior, peer, deadline)
114
+ base = {"schema" => "project-command/v1", "execution_id" => id, "project_id" => @project}
115
+ case operation
116
+ when "command_admit"
117
+ raise AttemptErrors::Conflict, "command already admitted" if prior
118
+ # Plan contains references and nonsecret context; its closed schema
119
+ # is owned by the installed Lab admission, never requester input.
120
+ base.merge("phase" => "admitted", "definition" => plan)
121
+ when "command_claim"
122
+ phase!(prior, "admitted")
123
+ @admission.command_claim!(definition: prior.fetch("definition"), caller: params.fetch("caller"),
124
+ launcher: peer, deadline: deadline)
125
+ prior.merge("phase" => "claimed", "launcher" => peer)
126
+ when "command_bind"
127
+ phase!(prior, "claimed")
128
+ launcher!(prior, peer)
129
+ child = @admission.command_child!(definition: prior.fetch("definition"), child: params.fetch("child"),
130
+ launcher: peer, deadline: deadline)
131
+ prior.merge("phase" => "running", "child" => child)
132
+ when "command_complete"
133
+ phase!(prior, "running")
134
+ launcher!(prior, peer)
135
+ result = params.fetch("result")
136
+ strict!(result, %w[exit_status signal])
137
+ exit_status, signal = result.values_at("exit_status", "signal")
138
+ unless (exit_status.is_a?(Integer) && exit_status.between?(0, 255) && signal.nil?) ||
139
+ (exit_status.nil? && signal.is_a?(Integer) && signal.between?(1, 127))
140
+ raise ArgumentError, "invalid command termination"
141
+ end
142
+ prior.merge("phase" => "exited", "result" => result)
143
+ end
144
+ end
145
+
146
+ def projection(id, commit, missing: false)
147
+ events = @journal.read_events(namespace(id), commit: commit)
148
+ unless Models::EvidenceEvent.chain_valid?(events)
149
+ raise AttemptErrors::EvidenceUnavailable, "command history is corrupt"
150
+ end
151
+ accepted = events.select { |event| event["type"] == "authority_mutation" }
152
+ if accepted.empty?
153
+ return nil if missing
154
+ raise AttemptErrors::NotFound, "command reference is unknown"
155
+ end
156
+ operations = accepted.map { |event| event.dig("payload", "operation") }
157
+ unless operations == OPERATIONS.reject { |name| name == "command_status" }.take(operations.size) && operations.size <= 4
158
+ raise AttemptErrors::EvidenceUnavailable, "command lifecycle is corrupt"
159
+ end
160
+ data = accepted.last.fetch("payload").fetch("data")
161
+ unless data.values_at("schema", "project_id", "execution_id") == ["project-command/v1", @project, id]
162
+ raise AttemptErrors::EvidenceUnavailable, "command association differs"
163
+ end
164
+ data
165
+ end
166
+
167
+ def namespace(id) = "command-#{id}"
168
+ def strict!(value, keys)
169
+ raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
170
+ end
171
+ def phase!(prior, phase)
172
+ raise AttemptErrors::Conflict, "command phase differs; inspect original execution" unless prior && prior["phase"] == phase
173
+ end
174
+ def launcher!(prior, peer)
175
+ raise AttemptErrors::UnauthorizedIdentity, "command launcher changed" unless prior.fetch("launcher") == peer
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -294,7 +294,7 @@ module Ace
294
294
  data.fetch("head") == params.fetch("head")
295
295
  raise AttemptErrors::EvidenceUnavailable, "candidate replay provenance differs"
296
296
  end
297
- bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes: 64 * 1024 * 1024)
297
+ bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes: CandidateTransfer::MAX_BYTES)
298
298
  unless bytes.bytesize == data.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
299
299
  raise AttemptErrors::EvidenceUnavailable, "candidate replay bundle differs"
300
300
  end
@@ -434,7 +434,9 @@ module Ace
434
434
  end
435
435
  end
436
436
 
437
- require_relative "endcap_services"
437
+ require_relative "fixed_service_lifecycle"
438
+ require_relative "fixed_publication_lifecycle"
439
+ require_relative "endcap_service_context"
438
440
  require_relative "endcap_service_settlement"
439
441
 
440
442
  require_relative "endcap_campaign_results"
@@ -179,7 +179,7 @@ module Ace
179
179
  @launch.campaign_children_settled!(journal: selected, commit: prefix, params: params, map: map)
180
180
  @launch.completion_scope!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
181
181
  service_settlement_evidence!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
182
- plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"))
182
+ plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"), authority: :protected)
183
183
  plan.merge(data: params.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
184
184
  "receipt_digest" => result.fetch("receipt_digest"), "state" => result.dig("receipt", "verdict")))
185
185
  end
@@ -37,7 +37,7 @@ module Ace
37
37
  end
38
38
 
39
39
  def with_campaign_receipt!(journal:, commit:, events:, params:, map:, receipt:, result:, historical:)
40
- unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/v1" &&
40
+ unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/v2" &&
41
41
  receipt["campaign"].is_a?(Hash) && receipt["campaign"].keys.sort == %w[id result] &&
42
42
  receipt["operation"] == "review" && receipt["verdict"] == "succeeded"
43
43
  raise AttemptErrors::ReceiptRejected, "parent campaign receipt differs"
@@ -35,172 +35,6 @@ module Ace
35
35
  end
36
36
  end
37
37
 
38
- def publication_executor_identity?(project:, uid:)
39
- @deployment.project(project).fetch("service_executor_uids").include?(uid)
40
- end
41
-
42
- def with_publication_hitl!(binding:, project:, peer:)
43
- params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
44
- map = @deployment.mapping(params.fetch("mapping_id"))
45
- raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
46
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
47
- protected_journal!(journal)
48
- record = publication_record!(journal, params, map, peer, :executor)
49
- unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
50
- raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
51
- end
52
- ServiceEvidence.new(journal: journal).publication_challenge!(record)
53
- selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
54
- "challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
55
- yield selection
56
- end
57
- end
58
-
59
- def authorize_publication_update!(journal:, existing:, replacement:, pending:)
60
- operation = pending.fetch(:operation)
61
- allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
62
- unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
63
- raise AttemptErrors::InvalidState, "publication transition changed original binding"
64
- end
65
- owner = ServiceEvidence.new(journal: journal)
66
- if operation == "publication_challenge"
67
- unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
68
- replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
69
- raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
70
- end
71
- need = owner.publication_challenge!(replacement, pending: pending)
72
- unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
73
- raise AttemptErrors::InvalidState, "publication need predecessor differs"
74
- end
75
- elsif operation == "publication_continue"
76
- unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
77
- (allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
78
- raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
79
- end
80
- owner.publication_challenge!(replacement, pending: pending)
81
- events = pending.fetch(:current_events) + pending.fetch(:pending_events)
82
- event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
83
- unless event && event["type"] == "service_publication_issuing" &&
84
- %w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
85
- raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
86
- end
87
- else
88
- raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
89
- end
90
- true
91
- end
92
-
93
- def publication_record!(journal, params, map, peer, role, events: nil)
94
- record = journal.service_request(params.fetch("request_id"))
95
- unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
96
- %w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
97
- %w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
98
- record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
99
- raise AttemptErrors::Conflict, "publication original tuple or phase differs"
100
- end
101
- service_executor!(peer, role, record)
102
- service_ticket!(record, params, map, peer)
103
- events ||= attempt_events(journal, params)
104
- current = exact_candidate!(candidate(events), params)
105
- origin = active_origin(events, params)
106
- worker_or_launcher!(record.fetch("worker_process_binding"), :worker, map, origin)
107
- approved_review!(journal, events, params, map, current)
108
- @launch.scope_open_for_effect!(events: events, params: params, map: map)
109
- service_policy!.authorize!(record)
110
- ServiceEvidence.new(journal: journal).context(record)
111
- record
112
- end
113
-
114
- def authorize_publication_transfer!(request, params, map, peer, role)
115
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
116
- protected_journal!(journal)
117
- publication_record!(journal, params, map, peer, role)
118
- end
119
- true
120
- end
121
-
122
- def dispatch_publication(request, params, map, peer, role, transfer)
123
- authorize_publication_transfer!(request, params, map, peer, role)
124
- challenge = request.fetch("operation") == "publication_challenge"
125
- bytes = nil
126
- if challenge
127
- unless transfer && transfer.count == 1
128
- raise ArgumentError, "publication need requires one bounded artifact"
129
- end
130
- bytes = transfer.bytes.dup.freeze
131
- unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
132
- raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
133
- end
134
- elsif transfer
135
- raise ArgumentError, "publication continuation forbids upload"
136
- end
137
- @launch.with_assignment(params: params, map: map) do |journal, _registration|
138
- publication_record!(journal, params, map, peer, role)
139
- result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
140
- mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
141
- parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
142
- with_replay: true) do |events, _commit, generation|
143
- record = publication_record!(journal, params, map, peer, role, events: events)
144
- if challenge
145
- publication_challenge_plan(journal, events, record, params, bytes, generation)
146
- else
147
- publication_continue_plan(journal, events, record, params, generation)
148
- end
149
- end
150
- if !challenge && result.fetch(:replayed)
151
- result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
152
- end
153
- result
154
- end
155
- end
156
-
157
- def publication_challenge_plan(journal, events, record, params, bytes, generation)
158
- owner = ServiceEvidence.new(journal: journal)
159
- digest = Digest::SHA256.hexdigest(bytes)
160
- if record["dispatch_phase"] == "otp_pending"
161
- owner.publication_challenge!(record)
162
- raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
163
- return {data: service_projection(record).merge("challenge" => "retained")}
164
- end
165
- unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
166
- raise AttemptErrors::InvalidState, "publication has no rejected push"
167
- end
168
- previous = record["publication_challenge_digest"]
169
- owner.publication_challenge!(record) if previous
170
- owner.publication_need!(bytes, record, previous: previous)
171
- payload = record.slice("request_id", "input_digest", "claim_binding").merge(
172
- "challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
173
- time = Time.now.utc
174
- event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
175
- payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
176
- plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
177
- artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
178
- replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
179
- "publication_challenge_ref" => plan.fetch(:references).fetch(0),
180
- "publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
181
- plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
182
- data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
183
- expected: record, replacement: replacement, event_type: "service_transition"}])
184
- end
185
-
186
- def publication_continue_plan(journal, events, record, params, generation)
187
- ServiceEvidence.new(journal: journal).publication_challenge!(record)
188
- unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
189
- raise AttemptErrors::Conflict, "publication continuation challenge differs"
190
- end
191
- if record["dispatch_phase"] == "issuing"
192
- return {data: service_projection(record).merge("invocation" => "already_issued")}
193
- end
194
- raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
195
- payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
196
- time = Time.now.utc
197
- event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
198
- payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
199
- replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
200
- {events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
201
- data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
202
- expected: record, replacement: replacement, event_type: "service_transition"}]}
203
- end
204
38
  end
205
39
  end
206
40
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Protected admission remains here. The shared fixed service transitions
7
+ # never manufacture a Deployment mapping or a protected launch origin.
8
+ class Endcap
9
+ include FixedServiceLifecycle
10
+ include FixedPublicationLifecycle
11
+
12
+ private
13
+
14
+ def with_service_scope(**options, &block) = @launch.with_assignment(**options, &block)
15
+ def service_journal!(journal) = protected_journal!(journal)
16
+ def service_mapping!(id) = @deployment.mapping(id)
17
+ def service_project!(id) = @deployment.project(id)
18
+ def service_scope_open!(**options) = @launch.scope_open_for_effect!(**options)
19
+ def service_preview_events!(**options) = @launch.preview_attempt_events!(**options)
20
+ def service_origin!(events, params) = active_origin(events, params)
21
+
22
+ def service_origin_fields!(origin)
23
+ {"worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
24
+ "launch_ticket" => origin.fetch("launch_ticket"),
25
+ "reservation_generation" => origin.fetch("reservation_generation")}
26
+ end
27
+
28
+ def service_origin_matches!(record, origin)
29
+ expected = service_origin_fields!(origin)
30
+ record.slice(*expected.keys) == expected
31
+ end
32
+
33
+ def service_worker!(*args, **options) = worker_or_launcher!(*args, **options)
34
+ def service_review!(*args, **options) = approved_review!(*args, **options)
35
+ end
36
+ end
37
+ end
38
+ end