ace-assign 0.70.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  4. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  5. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  6. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  7. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  8. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  9. data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
  10. data/lib/ace/assign/authority/command_execution.rb +180 -0
  11. data/lib/ace/assign/authority/endcap.rb +4 -2
  12. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  13. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  14. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  15. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  16. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  17. data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
  18. data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
  19. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  20. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  21. data/lib/ace/assign/authority/ordinary_results.rb +11 -1
  22. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  23. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  24. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  25. data/lib/ace/assign/authority/prepared_work.rb +7 -2
  26. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  27. data/lib/ace/assign/authority/retained_input.rb +53 -0
  28. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  29. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  30. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  31. data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
  32. data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
  33. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
  36. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +19 -4
@@ -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"
@@ -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
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ module FixedPublicationLifecycle
7
+ def publication_executor_identity?(project:, uid:)
8
+ service_project!(project).fetch("service_executor_uids").include?(uid)
9
+ end
10
+
11
+ def with_publication_hitl!(binding:, project:, peer:)
12
+ params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
13
+ map = service_mapping!(params.fetch("mapping_id"))
14
+ raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
15
+ with_service_scope(params: params, map: map) do |journal, _registration|
16
+ service_journal!(journal)
17
+ record = publication_record!(journal, params, map, peer, :executor)
18
+ unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
19
+ raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
20
+ end
21
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
22
+ selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
23
+ "challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
24
+ yield selection
25
+ end
26
+ end
27
+
28
+ def authorize_publication_update!(journal:, existing:, replacement:, pending:)
29
+ operation = pending.fetch(:operation)
30
+ allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
31
+ unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
32
+ raise AttemptErrors::InvalidState, "publication transition changed original binding"
33
+ end
34
+ owner = ServiceEvidence.new(journal: journal)
35
+ if operation == "publication_challenge"
36
+ unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
37
+ replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
38
+ raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
39
+ end
40
+ need = owner.publication_challenge!(replacement, pending: pending)
41
+ unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
42
+ raise AttemptErrors::InvalidState, "publication need predecessor differs"
43
+ end
44
+ elsif operation == "publication_continue"
45
+ unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
46
+ (allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
47
+ raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
48
+ end
49
+ owner.publication_challenge!(replacement, pending: pending)
50
+ events = pending.fetch(:current_events) + pending.fetch(:pending_events)
51
+ event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
52
+ unless event && event["type"] == "service_publication_issuing" &&
53
+ %w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
54
+ raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
55
+ end
56
+ else
57
+ raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
58
+ end
59
+ true
60
+ end
61
+
62
+ def publication_record!(journal, params, map, peer, role, events: nil)
63
+ record = journal.service_request(params.fetch("request_id"))
64
+ unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
65
+ %w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
66
+ %w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
67
+ record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
68
+ raise AttemptErrors::Conflict, "publication original tuple or phase differs"
69
+ end
70
+ service_executor!(peer, role, record)
71
+ service_ticket!(record, params, map, peer)
72
+ events ||= attempt_events(journal, params)
73
+ current = exact_candidate!(candidate(events), params)
74
+ origin = service_origin!(events, params)
75
+ service_worker!(record.fetch("worker_process_binding"), :worker, map, origin)
76
+ service_review!(journal, events, params, map, current)
77
+ service_scope_open!(events: events, params: params, map: map)
78
+ service_policy!.authorize!(record)
79
+ ServiceEvidence.new(journal: journal).context(record)
80
+ record
81
+ end
82
+
83
+ def authorize_publication_transfer!(request, params, map, peer, role)
84
+ with_service_scope(params: params, map: map) do |journal, _registration|
85
+ service_journal!(journal)
86
+ publication_record!(journal, params, map, peer, role)
87
+ end
88
+ true
89
+ end
90
+
91
+ def dispatch_publication(request, params, map, peer, role, transfer)
92
+ authorize_publication_transfer!(request, params, map, peer, role)
93
+ challenge = request.fetch("operation") == "publication_challenge"
94
+ bytes = nil
95
+ if challenge
96
+ unless transfer && transfer.count == 1
97
+ raise ArgumentError, "publication need requires one bounded artifact"
98
+ end
99
+ bytes = transfer.bytes.dup.freeze
100
+ unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
101
+ raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
102
+ end
103
+ elsif transfer
104
+ raise ArgumentError, "publication continuation forbids upload"
105
+ end
106
+ with_service_scope(params: params, map: map) do |journal, _registration|
107
+ publication_record!(journal, params, map, peer, role)
108
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
109
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
110
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
111
+ with_replay: true) do |events, _commit, generation|
112
+ record = publication_record!(journal, params, map, peer, role, events: events)
113
+ if challenge
114
+ publication_challenge_plan(journal, events, record, params, bytes, generation)
115
+ else
116
+ publication_continue_plan(journal, events, record, params, generation)
117
+ end
118
+ end
119
+ if !challenge && result.fetch(:replayed)
120
+ result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
121
+ end
122
+ result
123
+ end
124
+ end
125
+
126
+ def publication_challenge_plan(journal, events, record, params, bytes, generation)
127
+ owner = ServiceEvidence.new(journal: journal)
128
+ digest = Digest::SHA256.hexdigest(bytes)
129
+ if record["dispatch_phase"] == "otp_pending"
130
+ owner.publication_challenge!(record)
131
+ raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
132
+ return {data: service_projection(record).merge("challenge" => "retained")}
133
+ end
134
+ unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
135
+ raise AttemptErrors::InvalidState, "publication has no rejected push"
136
+ end
137
+ previous = record["publication_challenge_digest"]
138
+ owner.publication_challenge!(record) if previous
139
+ owner.publication_need!(bytes, record, previous: previous)
140
+ payload = record.slice("request_id", "input_digest", "claim_binding").merge(
141
+ "challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
142
+ time = Time.now.utc
143
+ event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
144
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
145
+ plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
146
+ artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
147
+ replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
148
+ "publication_challenge_ref" => plan.fetch(:references).fetch(0),
149
+ "publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
150
+ plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
151
+ data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
152
+ expected: record, replacement: replacement, event_type: "service_transition"}])
153
+ end
154
+
155
+ def publication_continue_plan(journal, events, record, params, generation)
156
+ ServiceEvidence.new(journal: journal).publication_challenge!(record)
157
+ unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
158
+ raise AttemptErrors::Conflict, "publication continuation challenge differs"
159
+ end
160
+ if record["dispatch_phase"] == "issuing"
161
+ return {data: service_projection(record).merge("invocation" => "already_issued")}
162
+ end
163
+ raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
164
+ payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
165
+ time = Time.now.utc
166
+ event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
167
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
168
+ replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
169
+ {events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
170
+ data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
171
+ expected: record, replacement: replacement, event_type: "service_transition"}]}
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end