ace-assign 0.64.0 → 0.66.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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +19 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +88 -9
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
def transfer_binding(request) = TRANSFER_OPERATIONS[request.fetch("operation")]
|
|
8
|
+
|
|
9
|
+
def authorize_transfer!(request:, peer:, role:)
|
|
10
|
+
if request["operation"] == "register_assignment"
|
|
11
|
+
params = request.fetch("params")
|
|
12
|
+
strict!(params, MUTATIONS.fetch("register_assignment"))
|
|
13
|
+
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
14
|
+
TaskContextEntry.validate!(map.fetch("task_context_entry"))
|
|
15
|
+
unless role == :launcher && peer.values_at("uid", "gid", "groups") == map.values_at("launcher_uid", "launcher_gid", "launcher_groups")
|
|
16
|
+
raise AttemptErrors::UnauthorizedIdentity, "prepared registration requires mapped launcher"
|
|
17
|
+
end
|
|
18
|
+
@kernel.live!(peer)
|
|
19
|
+
return true
|
|
20
|
+
end
|
|
21
|
+
raise ArgumentError, "unsupported launch transfer" unless request["operation"] == "prompt_attempt"
|
|
22
|
+
steering_principal!(request.fetch("params"), peer, role)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prompt_attempt!(request:, peer:, role:, transfer:)
|
|
26
|
+
params = request.fetch("params")
|
|
27
|
+
strict!(params, %w[mapping_id assignment_id attempt_id expected_generation transfer])
|
|
28
|
+
token!(request.fetch("mutation_id"))
|
|
29
|
+
generation!(params.fetch("expected_generation"))
|
|
30
|
+
map = steering_principal!(params, peer, role)
|
|
31
|
+
raise AttemptErrors::MalformedTransfer, "Prompt transfer unavailable" unless transfer
|
|
32
|
+
text = transfer.bytes
|
|
33
|
+
unless text.is_a?(String) && text.force_encoding(Encoding::UTF_8).valid_encoding? && text.bytesize.between?(1, 16_384) && !text.match?(/\A[[:space:]]*\z/)
|
|
34
|
+
raise AttemptErrors::MalformedTransfer, "Prompt text is malformed"
|
|
35
|
+
end
|
|
36
|
+
journal = journal_for(map)
|
|
37
|
+
binding = intent = channel = reservation = nil
|
|
38
|
+
prepared = with_exclusion(params, map, journal) do
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
commit = journal.ref_value
|
|
41
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
42
|
+
state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
43
|
+
original = original_prompt_record!(events, state, params: params)
|
|
44
|
+
binding = {"action" => "prompt_attempt", "project_id" => map.fetch("project_id"),
|
|
45
|
+
"mapping_id" => params.fetch("mapping_id"), "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
46
|
+
"mutation_id" => request.fetch("mutation_id"), "expected_generation" => params.fetch("expected_generation"),
|
|
47
|
+
"caller" => peer.slice("uid", "gid", "groups").merge("role" => role.to_s),
|
|
48
|
+
"original_binding_digest" => original.fetch("binding_digest"), "text_bytes" => text.bytesize, "text_sha256" => Digest::SHA256.hexdigest(text)}
|
|
49
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(binding)))
|
|
50
|
+
replay = journal.mutation_result(request.fetch("mutation_id"), commit: commit)
|
|
51
|
+
if replay
|
|
52
|
+
unless replay["operation"] == "prompt_attempt" && replay["parameters_digest"] == digest && replay["assignment_id"] == params["assignment_id"] && replay["attempt_id"] == params["attempt_id"]
|
|
53
|
+
raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
|
|
54
|
+
end
|
|
55
|
+
next({data: replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit")), replayed: true})
|
|
56
|
+
end
|
|
57
|
+
unless journal.prompt_intent(request.fetch("mutation_id"), commit: commit)
|
|
58
|
+
channel = @control_channels[steering_key(params, map)]
|
|
59
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launcher channel is unavailable" unless channel
|
|
60
|
+
reservation = channel.reserve_dispatch!
|
|
61
|
+
end
|
|
62
|
+
issue = journal.issue_prompt(binding: binding, issued_by: peer) do |current, _old, _generation|
|
|
63
|
+
unless channel && reservation && channel.dispatch_reserved?(reservation)
|
|
64
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launcher channel is unavailable"
|
|
65
|
+
end
|
|
66
|
+
fresh = origin(current, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
67
|
+
authenticate = original_prompt_record!(current, fresh, params: params)
|
|
68
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record changed" unless authenticate == original
|
|
69
|
+
raise AttemptErrors::Conflict, "Attempt is not available for prompt input" unless fresh["phase"] == "issued" && !terminal_events?(current)
|
|
70
|
+
scope_open_for_effect!(events: current, params: params, map: map)
|
|
71
|
+
@kernel.live!(fresh.fetch("process_binding").fetch("process_identity"))
|
|
72
|
+
end
|
|
73
|
+
intent = journal.prompt_intent(request.fetch("mutation_id"), commit: issue.dig(:data, "journal_commit"))
|
|
74
|
+
{issue: issue, original: original}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
return prepared if prepared.key?(:data)
|
|
78
|
+
evidence = {"outcome" => "uncertain", "origin" => prepared.fetch(:original).fetch("origin")}
|
|
79
|
+
if channel && !channel.closed? && !prepared.fetch(:issue).fetch(:replayed)
|
|
80
|
+
frame = {"version" => 1, "type" => "prompt_dispatch", "mutation_id" => request.fetch("mutation_id"),
|
|
81
|
+
"attempt_id" => params.fetch("attempt_id"), "intent_event_id" => intent.fetch("digest"),
|
|
82
|
+
"journal_commit" => prepared.dig(:issue, :data, "journal_commit"), "original_binding_digest" => binding.fetch("original_binding_digest"),
|
|
83
|
+
"text_descriptor" => TransferCodec.new.descriptor([text], purpose: :prompt_text), "transfer_id" => SecureRandom.hex(16)}
|
|
84
|
+
begin
|
|
85
|
+
outcome = channel.dispatch_prompt(frame: frame, bytes: text, deadline: wire.deadline(30), reservation: reservation)
|
|
86
|
+
evidence = outcome.fetch("guarded_evidence")
|
|
87
|
+
rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable, IOError, SystemCallError
|
|
88
|
+
# Accepted intent never yields another send permit after uncertainty.
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
finish_prompt_outcome!(params, map, intent, evidence)
|
|
92
|
+
ensure
|
|
93
|
+
channel&.release_dispatch!(reservation) if reservation
|
|
94
|
+
text = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def serve_launch_control!(request:, peer:, socket:, codec:, deadline:)
|
|
98
|
+
params = request.fetch("params")
|
|
99
|
+
strict!(params, %w[mapping_id assignment_id attempt_id])
|
|
100
|
+
raise ArgumentError, "Private control requires null mutation ID" unless request.fetch("mutation_id").nil?
|
|
101
|
+
map = steering_principal!(params, peer, :launcher)
|
|
102
|
+
journal = journal_for(map)
|
|
103
|
+
channel = ready = nil
|
|
104
|
+
with_containment_exclusion(params, map, journal) do
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
commit = journal.ref_value
|
|
107
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
108
|
+
state = origin(events, **params.transform_keys(&:to_sym))
|
|
109
|
+
raise AttemptErrors::UnauthorizedIdentity, "Private channel belongs to another launcher incarnation" unless @kernel.same?(state.fetch("launcher_identity"), peer)
|
|
110
|
+
raise AttemptErrors::Conflict, "Original launcher is not issued" unless state["phase"] == "issued"
|
|
111
|
+
if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
112
|
+
raise AttemptErrors::EvidenceUnavailable, "Released scope cannot retain launcher control"
|
|
113
|
+
end
|
|
114
|
+
original = original_prompt_record!(events, state, params: params)
|
|
115
|
+
key = steering_key(params, map)
|
|
116
|
+
prior = @control_channels[key]
|
|
117
|
+
raise AttemptErrors::Conflict, "Original launcher channel already exists" if prior && !prior.closed?
|
|
118
|
+
channel = LaunchControlChannel.new(socket: socket, codec: codec,
|
|
119
|
+
outcome: ->(result) { ingest_original_prompt_outcome!(params, map, result) },
|
|
120
|
+
input_inhibition: ->(result) { record_original_input_inhibition!(params.merge(result.slice("original_binding_digest", "seal_event_id")), map,
|
|
121
|
+
result.fetch("guarded_evidence"), peer: peer) })
|
|
122
|
+
@control_channels[key] = channel
|
|
123
|
+
ready = {"version" => 1, "type" => "launch_control_ready", "attempt_id" => params.fetch("attempt_id"),
|
|
124
|
+
"original_binding_digest" => original.fetch("binding_digest"), "journal_commit" => commit, "generation" => journal.authority_generation(events)}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
wire.write(socket, ready, deadline: deadline, limit: 16_384)
|
|
128
|
+
channel.serve
|
|
129
|
+
ensure
|
|
130
|
+
channel&.close
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# This immutable-prefix query holds no slot/assignment exclusion. The
|
|
134
|
+
# original waiter dropped all locks before dispatch/ACK handling.
|
|
135
|
+
def launch_prompt_intent!(request:, peer:, role:)
|
|
136
|
+
params = request.fetch("params")
|
|
137
|
+
strict!(params, %w[mapping_id assignment_id attempt_id mutation_id intent_event_id journal_commit])
|
|
138
|
+
raise ArgumentError, "Intent lookup is read-only" unless request.fetch("mutation_id").nil?
|
|
139
|
+
map = steering_principal!(params, peer, role)
|
|
140
|
+
raise AttemptErrors::UnauthorizedIdentity, "Intent lookup requires original launcher" unless role == :launcher
|
|
141
|
+
unless params.fetch("journal_commit").is_a?(String) && params.fetch("journal_commit").match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
|
|
142
|
+
raise ArgumentError, "Invalid immutable intent commit"
|
|
143
|
+
end
|
|
144
|
+
journal = journal_for(map)
|
|
145
|
+
journal.verify_canonical_prefix!(commit: params.fetch("journal_commit"))
|
|
146
|
+
intent = journal.prompt_intent(params.fetch("mutation_id"), commit: params.fetch("journal_commit"))
|
|
147
|
+
raise AttemptErrors::EvidenceUnavailable, "Accepted prompt intent differs" unless intent && intent["digest"] == params.fetch("intent_event_id")
|
|
148
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: params.fetch("journal_commit")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
149
|
+
state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
150
|
+
raise AttemptErrors::UnauthorizedIdentity, "Intent lookup requires exact launcher incarnation" unless @kernel.same?(state.fetch("launcher_identity"), peer)
|
|
151
|
+
original = original_prompt_record!(events, state, params: params)
|
|
152
|
+
binding = intent.fetch("payload").fetch("binding")
|
|
153
|
+
unless binding["original_binding_digest"] == original.fetch("binding_digest") && binding["mapping_id"] == params["mapping_id"] && binding["assignment_id"] == params["assignment_id"] && binding["attempt_id"] == params["attempt_id"]
|
|
154
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt intent does not join original accepted record"
|
|
155
|
+
end
|
|
156
|
+
{data: {"intent_event_id" => intent.fetch("digest"), "binding" => binding, "origin" => original.fetch("origin")}, replayed: false}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Only the still-authenticated original launcher may recover its own
|
|
160
|
+
# already-known native ACK after a private connection disappears.
|
|
161
|
+
# This grants no input permit and never changes the first public reply.
|
|
162
|
+
def launch_prompt_completion!(request:, peer:, role:)
|
|
163
|
+
params = request.fetch("params")
|
|
164
|
+
strict!(params, %w[mapping_id assignment_id attempt_id mutation_id intent_event_id original_binding_digest guarded_evidence])
|
|
165
|
+
raise ArgumentError, "Original completion requires null public mutation ID" unless request.fetch("mutation_id").nil?
|
|
166
|
+
map = steering_principal!(params, peer, role)
|
|
167
|
+
raise AttemptErrors::UnauthorizedIdentity, "Completion reporting requires original launcher" unless role == :launcher
|
|
168
|
+
journal = journal_for(map)
|
|
169
|
+
intent = journal.prompt_intent(params.fetch("mutation_id"))
|
|
170
|
+
binding = intent&.dig("payload", "binding")
|
|
171
|
+
unless intent && intent["digest"] == params["intent_event_id"] && binding.is_a?(Hash) &&
|
|
172
|
+
%w[mapping_id assignment_id attempt_id original_binding_digest].all? { |key| binding[key] == params[key] } &&
|
|
173
|
+
params.fetch("guarded_evidence").is_a?(Hash) && %w[submitted not_issued].include?(params.dig("guarded_evidence", "outcome"))
|
|
174
|
+
raise AttemptErrors::EvidenceUnavailable, "Retained native completion does not join accepted issue"
|
|
175
|
+
end
|
|
176
|
+
accepted = finish_prompt_outcome!(params.slice("mapping_id", "assignment_id", "attempt_id"), map, intent,
|
|
177
|
+
params.fetch("guarded_evidence"), peer: peer, completion_ack: true)
|
|
178
|
+
{data: accepted.merge("intent_event_id" => intent.fetch("digest")), replayed: false}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def prompt_status!(request:, peer:, role:)
|
|
182
|
+
params = request.fetch("params")
|
|
183
|
+
strict!(params, %w[mapping_id assignment_id attempt_id mutation_id])
|
|
184
|
+
%w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
|
|
185
|
+
raise ArgumentError, "Prompt status requires null public mutation ID" unless request.fetch("mutation_id").nil?
|
|
186
|
+
map = steering_principal!(params, peer, role)
|
|
187
|
+
journal = journal_for(map)
|
|
188
|
+
@mutex.synchronize do
|
|
189
|
+
commit = journal.ref_value
|
|
190
|
+
intent = journal.prompt_intent(params.fetch("mutation_id"), commit: commit)
|
|
191
|
+
raise AttemptErrors::NotFound, "Prompt mutation is missing" unless intent
|
|
192
|
+
binding = intent.fetch("payload").fetch("binding")
|
|
193
|
+
unless binding.slice("mapping_id", "assignment_id", "attempt_id") == params.slice("mapping_id", "assignment_id", "attempt_id") &&
|
|
194
|
+
binding.fetch("project_id") == map.fetch("project_id")
|
|
195
|
+
raise AttemptErrors::Conflict, "Prompt mutation belongs to another attempt"
|
|
196
|
+
end
|
|
197
|
+
unless binding.fetch("caller") == peer.slice("uid", "gid", "groups").merge("role" => role.to_s)
|
|
198
|
+
raise AttemptErrors::UnauthorizedIdentity, "Prompt mutation belongs to another principal"
|
|
199
|
+
end
|
|
200
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
201
|
+
state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
202
|
+
original = original_prompt_record!(events, state, params: params)
|
|
203
|
+
unless binding.fetch("original_binding_digest") == original.fetch("binding_digest")
|
|
204
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt original guarded record differs"
|
|
205
|
+
end
|
|
206
|
+
reply = journal.mutation_result(params.fetch("mutation_id"), commit: commit)
|
|
207
|
+
if reply && (reply["operation"] != "prompt_attempt" || reply["parameters_digest"] != intent.dig("payload", "binding_digest") ||
|
|
208
|
+
reply["assignment_id"] != params["assignment_id"] || reply["attempt_id"] != params["attempt_id"])
|
|
209
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt immutable reply differs"
|
|
210
|
+
end
|
|
211
|
+
completed = authenticated_prompt_completions!(events, intent, original, journal).last
|
|
212
|
+
{data: {"attempt_id" => params.fetch("attempt_id"), "mutation_id" => params.fetch("mutation_id"),
|
|
213
|
+
"outcome" => completed ? completed.dig("payload", "evidence", "outcome") : "uncertain",
|
|
214
|
+
"intent_event_id" => intent.fetch("digest"), "outcome_event_id" => completed&.fetch("digest"),
|
|
215
|
+
"generation" => journal.authority_generation(events), "journal_commit" => commit}, replayed: false}
|
|
216
|
+
end
|
|
217
|
+
rescue KeyError, TypeError
|
|
218
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt status evidence is malformed"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def steering_key(params, map)
|
|
224
|
+
[map.fetch("project_id"), params.fetch("mapping_id"), params.fetch("assignment_id"), params.fetch("attempt_id")]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def steering_principal!(params, peer, role)
|
|
228
|
+
@kernel.live!(peer)
|
|
229
|
+
map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
|
|
230
|
+
allowed = if role == :launcher
|
|
231
|
+
peer.values_at("uid", "gid", "groups") == map.values_at("launcher_uid", "launcher_gid", "launcher_groups")
|
|
232
|
+
elsif role == :supervisor
|
|
233
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
234
|
+
credentials = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
|
|
235
|
+
project.fetch("supervisor_uids").include?(peer.fetch("uid")) && credentials &&
|
|
236
|
+
peer.values_at("gid", "groups") == credentials.values_at("gid", "groups")
|
|
237
|
+
end
|
|
238
|
+
raise AttemptErrors::UnauthorizedIdentity, "Prompt requires installed launcher or supervisor" unless allowed
|
|
239
|
+
map
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def finish_prompt_outcome!(params, map, intent, evidence, peer: nil, completion_ack: false)
|
|
243
|
+
journal = journal_for(map)
|
|
244
|
+
with_containment_exclusion(params, map, journal) do
|
|
245
|
+
@mutex.synchronize do
|
|
246
|
+
authentication = lambda do |events, _commit, _generation|
|
|
247
|
+
state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
248
|
+
if peer
|
|
249
|
+
@kernel.live!(peer)
|
|
250
|
+
unless @kernel.same?(state.fetch("launcher_identity"), peer)
|
|
251
|
+
raise AttemptErrors::UnauthorizedIdentity, "Completion reporting requires exact original launcher incarnation"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
original_prompt_record!(events, state, params: params)
|
|
255
|
+
end
|
|
256
|
+
if peer
|
|
257
|
+
selected = journal.ref_value
|
|
258
|
+
current = journal.read_events(params.fetch("assignment_id"), commit: selected).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
259
|
+
authenticated = authentication.call(current, selected, journal.authority_generation(current))
|
|
260
|
+
journal.send(:validate_prompt_evidence!, evidence, authenticated.fetch("origin"))
|
|
261
|
+
end
|
|
262
|
+
result = journal.finalize_prompt(mutation_id: intent.dig("payload", "external_mutation_id"), intent_event_id: intent.fetch("digest"),
|
|
263
|
+
binding_digest: intent.dig("payload", "binding_digest"), evidence: evidence, &authentication)
|
|
264
|
+
accepted = result
|
|
265
|
+
if %w[submitted not_issued].include?(evidence["outcome"]) && %w[submitted not_issued].include?(result.dig(:data, "outcome")) &&
|
|
266
|
+
result.dig(:data, "guarded_evidence") != evidence
|
|
267
|
+
raise AttemptErrors::Conflict, "Native completion differs from accepted outcome"
|
|
268
|
+
end
|
|
269
|
+
if result.dig(:data, "outcome") == "uncertain" && %w[submitted not_issued].include?(evidence["outcome"])
|
|
270
|
+
accepted = journal.observe_prompt_completion(mutation_id: intent.dig("payload", "external_mutation_id"), intent_event_id: intent.fetch("digest"),
|
|
271
|
+
binding_digest: intent.dig("payload", "binding_digest"), evidence: evidence, &authentication)
|
|
272
|
+
end
|
|
273
|
+
completion_ack ? accepted.fetch(:data).slice("outcome", "journal_commit") : result
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def ingest_original_prompt_outcome!(params, map, result)
|
|
279
|
+
journal = journal_for(map)
|
|
280
|
+
intent = journal.prompt_intent(result.fetch("mutation_id"))
|
|
281
|
+
unless intent && intent["digest"] == result.fetch("intent_event_id") &&
|
|
282
|
+
intent.dig("payload", "binding", "original_binding_digest") == result.fetch("original_binding_digest")
|
|
283
|
+
raise AttemptErrors::EvidenceUnavailable, "Original driver outcome differs from accepted issue"
|
|
284
|
+
end
|
|
285
|
+
finish_prompt_outcome!(params, map, intent, result.fetch("guarded_evidence"), completion_ack: true)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# An issued actor lives outside the worker unit and must be inhibited
|
|
289
|
+
# for its full lifetime, even with no prompt rows or only known ACKs.
|
|
290
|
+
# Accepted original actor input drainage removes the writer blocker. It never
|
|
291
|
+
# changes an unknown prompt outcome. Channel absence, caller exit and
|
|
292
|
+
# an empty worker cgroup cannot remove this blocker.
|
|
293
|
+
def pending_prompt_issuers?(events, journal, commit)
|
|
294
|
+
return false if accepted_input_inhibition?(events, journal, commit)
|
|
295
|
+
return true if issued_input_actor?(events)
|
|
296
|
+
events.select { |event| event["type"] == "prompt_issued" }.any? do |issued|
|
|
297
|
+
external_id = issued.fetch("payload").fetch("external_mutation_id")
|
|
298
|
+
intent = journal.prompt_intent(external_id, commit: commit)
|
|
299
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt issuer is not canonically accepted" unless intent && intent["digest"] == issued["digest"]
|
|
300
|
+
binding = intent.fetch("payload").fetch("binding")
|
|
301
|
+
params = binding.slice("mapping_id", "assignment_id", "attempt_id")
|
|
302
|
+
state = origin(events, **params.transform_keys(&:to_sym))
|
|
303
|
+
original = original_prompt_record!(events, state, params: params)
|
|
304
|
+
unless binding.fetch("original_binding_digest") == original.fetch("binding_digest")
|
|
305
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt issuer original binding differs"
|
|
306
|
+
end
|
|
307
|
+
authenticated_prompt_completions!(events, intent, original, journal).empty?
|
|
308
|
+
end
|
|
309
|
+
rescue KeyError, TypeError, ArgumentError
|
|
310
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt issuer history is malformed"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def issued_input_actor?(events)
|
|
314
|
+
releases = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" }
|
|
315
|
+
return false if releases.empty?
|
|
316
|
+
unless Models::EvidenceEvent.chain_valid?(events) && releases.one? &&
|
|
317
|
+
releases.first.dig("payload", "data").is_a?(Hash) && releases.first.dig("payload", "data", "phase") == "issued"
|
|
318
|
+
raise AttemptErrors::EvidenceUnavailable, "Original outside-unit actor issuance differs"
|
|
319
|
+
end
|
|
320
|
+
true
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def authenticated_prompt_completions!(events, intent, original, journal)
|
|
324
|
+
external_id = intent.fetch("payload").fetch("external_mutation_id")
|
|
325
|
+
events.select { |event| %w[prompt_outcome prompt_completion_observed].include?(event["type"]) &&
|
|
326
|
+
event.dig("payload", "intent_event_id") == intent.fetch("digest") }.select do |event|
|
|
327
|
+
payload = event.fetch("payload")
|
|
328
|
+
evidence = payload.fetch("evidence")
|
|
329
|
+
journal.send(:validate_prompt_evidence!, evidence, original.fetch("origin"))
|
|
330
|
+
expected_operation = event["type"] == "prompt_outcome" ? "prompt_attempt" : "prompt_observation"
|
|
331
|
+
expected_id = expected_operation == "prompt_attempt" ? external_id : "prompt-observe.#{intent.fetch('digest')}"
|
|
332
|
+
acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event["digest"] &&
|
|
333
|
+
entry.dig("payload", "operation") == expected_operation && entry.dig("payload", "mutation_id") == expected_id }
|
|
334
|
+
unless payload["external_mutation_id"] == external_id && payload["binding_digest"] == intent.dig("payload", "binding_digest") &&
|
|
335
|
+
acceptance && acceptance.dig("payload", "data", "outcome") == evidence.fetch("outcome") &&
|
|
336
|
+
acceptance.dig("payload", "data", "guarded_evidence") == evidence
|
|
337
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt native completion acceptance differs"
|
|
338
|
+
end
|
|
339
|
+
%w[submitted not_issued].include?(evidence.fetch("outcome"))
|
|
340
|
+
end
|
|
341
|
+
rescue KeyError, TypeError, ArgumentError
|
|
342
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt completion history is malformed"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Authentication binds the whole accepted original record, including
|
|
346
|
+
# its native guard. A plain process-binding digest cannot substitute.
|
|
347
|
+
def original_prompt_record!(events, state, params:)
|
|
348
|
+
Molecules::OriginalLaunchBinding.verify!(events: events, state: state, params: params)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../organisms/attempt_coordinator"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
class LaunchLifecycle
|
|
9
|
+
# The stop mutation owns its seal and immutable reply. Input inhibition
|
|
10
|
+
# waits outside exclusions; manager stop runs after CAS outside @mutex,
|
|
11
|
+
# retaining the existing containment slot guard.
|
|
12
|
+
def stop_attempt!(request:, peer:, role:)
|
|
13
|
+
params = request.fetch("params")
|
|
14
|
+
strict!(params, %w[mapping_id assignment_id attempt_id expected_generation])
|
|
15
|
+
%w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
|
|
16
|
+
token!(request.fetch("mutation_id"))
|
|
17
|
+
generation!(params.fetch("expected_generation"))
|
|
18
|
+
map = steering_principal!(params, peer, role)
|
|
19
|
+
journal = journal_for(map)
|
|
20
|
+
principal = peer.slice("uid", "gid", "groups").merge("role" => role.to_s)
|
|
21
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.merge("caller" => principal))))
|
|
22
|
+
closure = nil
|
|
23
|
+
pending_action = pending_commit = nil
|
|
24
|
+
prepared = with_containment_exclusion(params, map, journal) do
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
commit = journal.ref_value
|
|
27
|
+
events = stop_events!(journal, params, commit)
|
|
28
|
+
replay = journal.mutation_result(request.fetch("mutation_id"), commit: commit)
|
|
29
|
+
if replay
|
|
30
|
+
unless replay.values_at("operation", "parameters_digest", "assignment_id", "attempt_id") ==
|
|
31
|
+
["stop_attempt", digest, params.fetch("assignment_id"), params.fetch("attempt_id")]
|
|
32
|
+
raise AttemptErrors::Conflict, "Mutation ID is already bound to different stop input"
|
|
33
|
+
end
|
|
34
|
+
if events.any? { |event| event["type"] == "attempt_stopped" }
|
|
35
|
+
terminal_scope_stopped!(events, journal, commit, deployment: @deployment)
|
|
36
|
+
elsif %w[succeeded failed].include?(journal.canonical_attempt_state(events))
|
|
37
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
|
|
38
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
39
|
+
terminal_scope_receipt!(events, lineage, journal, commit)
|
|
40
|
+
else
|
|
41
|
+
stop_owner!(params, map, events)
|
|
42
|
+
end
|
|
43
|
+
next({data: replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit")), replayed: true})
|
|
44
|
+
end
|
|
45
|
+
lineage = stop_owner!(params, map, events)
|
|
46
|
+
if lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
|
|
47
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
48
|
+
raise AttemptErrors::EvidenceUnavailable, "Settlement owner is unavailable" unless @result_owner
|
|
49
|
+
begin
|
|
50
|
+
@result_owner.service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
51
|
+
rescue AttemptErrors::EvidenceUnavailable => error
|
|
52
|
+
raise unless authenticated_stop_pending_action(error) == "settle_services"
|
|
53
|
+
pending_action, pending_commit = "settle_services", commit
|
|
54
|
+
end
|
|
55
|
+
next nil
|
|
56
|
+
end
|
|
57
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
58
|
+
mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
|
|
59
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |current, old, _generation|
|
|
60
|
+
lineage = stop_owner!(params, map, current)
|
|
61
|
+
closure = scope_closure_plan!(lineage: lineage, params: params, map: map, events: current, journal: journal, commit: old)
|
|
62
|
+
plan = closure.fetch(:plan)
|
|
63
|
+
plan.merge(data: stop_pending_reply(params, plan.fetch(:data)["proof_id"], "reconcile_scope"))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
if prepared
|
|
68
|
+
unless prepared.fetch(:replayed)
|
|
69
|
+
request_original_input_inhibition!(params, map, journal) if closure&.fetch(:inhibit_required)
|
|
70
|
+
if closure&.fetch(:stop_required)
|
|
71
|
+
with_containment_exclusion(params, map, journal) do
|
|
72
|
+
@mutex.synchronize { stop_owner!(params, map, stop_events!(journal, params, journal.ref_value)) }
|
|
73
|
+
scope_observer_for(params.fetch("mapping_id")).stop_sealed_service!
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
return prepared
|
|
78
|
+
end
|
|
79
|
+
if pending_action
|
|
80
|
+
return record_pending_stop!(request, map, journal, digest, pending_action, pending_commit)
|
|
81
|
+
end
|
|
82
|
+
final_selection = journal.ref_value
|
|
83
|
+
begin
|
|
84
|
+
finalize_stopped_attempt!(request, peer, role, map, journal, digest)
|
|
85
|
+
rescue AttemptErrors::EvidenceUnavailable => error
|
|
86
|
+
action = authenticated_stop_pending_action(error)
|
|
87
|
+
raise unless action
|
|
88
|
+
record_pending_stop!(request, map, journal, digest, action, final_selection)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def stop_events!(journal, params, commit)
|
|
95
|
+
journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stop_owner!(params, map, events)
|
|
99
|
+
state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
100
|
+
original_prompt_record!(events, state, params: params) if issued_input_actor?(events)
|
|
101
|
+
canonical_state = Molecules::CanonicalAttemptState.derive(events)
|
|
102
|
+
unless %w[running uncertain].include?(canonical_state)
|
|
103
|
+
raise AttemptErrors::Conflict, "Attempt is not eligible for proof-authenticated stop"
|
|
104
|
+
end
|
|
105
|
+
Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
|
|
106
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stop_pending_reply(params, proof_id, action)
|
|
110
|
+
{"attempt_id" => params.fetch("attempt_id"), "state" => "uncertain", "proof_id" => proof_id, "required_action" => action}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Only positively authenticated owner-pending subclasses have public
|
|
114
|
+
# recovery actions. Missing/corrupt evidence and auth failures propagate.
|
|
115
|
+
def authenticated_stop_pending_action(error)
|
|
116
|
+
if error.is_a?(AttemptErrors::ServiceSettlementPending)
|
|
117
|
+
return "settle_services"
|
|
118
|
+
end
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def record_pending_stop!(request, map, journal, digest, action, selected_commit)
|
|
123
|
+
params = request.fetch("params")
|
|
124
|
+
with_containment_exclusion(params, map, journal) do
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
127
|
+
mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
|
|
128
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
|
|
129
|
+
unless commit == selected_commit
|
|
130
|
+
raise AttemptErrors::Conflict, "Authenticated pending settlement prefix advanced"
|
|
131
|
+
end
|
|
132
|
+
lineage = stop_owner!(params, map, events)
|
|
133
|
+
unless lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
|
|
134
|
+
raise AttemptErrors::EvidenceUnavailable, "Original scope closure proof is unavailable"
|
|
135
|
+
end
|
|
136
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
137
|
+
{events: [], blobs: {}, data: stop_pending_reply(params, lineage.proof_id, action)}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def finalize_stopped_attempt!(request, peer, role, map, journal, digest)
|
|
144
|
+
params = request.fetch("params")
|
|
145
|
+
with_exclusion(params, map, journal) do
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
148
|
+
mutation_id: request.fetch("mutation_id"), operation: "stop_attempt", parameters_digest: digest,
|
|
149
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
|
|
150
|
+
steering_principal!(params, peer, role)
|
|
151
|
+
lineage = stop_owner!(params, map, events)
|
|
152
|
+
unless lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
|
|
153
|
+
raise AttemptErrors::EvidenceUnavailable, "Original scope closure proof is unavailable"
|
|
154
|
+
end
|
|
155
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
156
|
+
raise AttemptErrors::EvidenceUnavailable, "Settlement owner is unavailable" unless @result_owner
|
|
157
|
+
services = @result_owner.service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
158
|
+
|
|
159
|
+
provisioning = events.select { |event| event["type"] == "scope_provisioning" }
|
|
160
|
+
raise AttemptErrors::EvidenceUnavailable, "Original deployment provenance is ambiguous" unless provisioning.one?
|
|
161
|
+
selection = params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id"),
|
|
162
|
+
"descriptor_sha256" => provisioning.first.fetch("payload").fetch("descriptor_sha256"),
|
|
163
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"),
|
|
164
|
+
"closed_proof_event_id" => lineage.proof_id)
|
|
165
|
+
plan = Organisms::AttemptCoordinator.stopped_transition_plan(events: events, selection: selection,
|
|
166
|
+
service_evidence: services, commit: commit)
|
|
167
|
+
plan.merge(data: {"attempt_id" => params.fetch("attempt_id"), "state" => "stopped", "proof_id" => lineage.proof_id, "required_action" => nil})
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Historical terminal authentication uses the original immutable
|
|
174
|
+
# descriptor and the actual introduction prefix, not today's pointer.
|
|
175
|
+
def terminal_scope_stopped!(events, journal, commit, deployment:)
|
|
176
|
+
terminal = events.select { |event| event["type"] == "attempt_stopped" }
|
|
177
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped terminal is missing or ambiguous" unless terminal.one?
|
|
178
|
+
event = terminal.first
|
|
179
|
+
acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
|
|
180
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped terminal acceptance is missing" unless acceptance
|
|
181
|
+
assignment_id = event.fetch("payload").fetch("assignment_id")
|
|
182
|
+
prefix_commit = terminal_event_commit!(journal, assignment_id: assignment_id, event_digest: acceptance.fetch("digest"), commit: commit)
|
|
183
|
+
verify_stopped_terminal_at_prefix!(events, journal, prefix_commit, deployment: deployment)
|
|
184
|
+
rescue KeyError, TypeError, NoMethodError
|
|
185
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped original terminal evidence is malformed"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Source composition supplies only an introduction authenticated by the
|
|
189
|
+
# canonical Git owner. This immutable verifier grants no live exclusion.
|
|
190
|
+
def verify_stopped_terminal_at_prefix!(events, journal, prefix_commit, deployment:)
|
|
191
|
+
terminals = events.select { |entry| entry["type"] == "attempt_stopped" }
|
|
192
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped terminal is missing or ambiguous" unless terminals.one?
|
|
193
|
+
event = terminals.first
|
|
194
|
+
accepted = events.select { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
|
|
195
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped terminal acceptance is missing or ambiguous" unless accepted.one?
|
|
196
|
+
acceptance = accepted.first
|
|
197
|
+
assignment_id = event.fetch("payload").fetch("assignment_id")
|
|
198
|
+
prefix = journal.read_events(assignment_id, commit: prefix_commit).select { |entry| entry["attempt_id"] == event.fetch("attempt_id") }
|
|
199
|
+
unless prefix == events.take(events.index(acceptance) + 1) && Molecules::CanonicalAttemptState.derive(prefix) == "stopped"
|
|
200
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped terminal introduction prefix differs"
|
|
201
|
+
end
|
|
202
|
+
payload = event.fetch("payload")
|
|
203
|
+
descriptor = if deployment.artifact_reference&.fetch("sha256") == payload.fetch("descriptor_sha256")
|
|
204
|
+
deployment
|
|
205
|
+
else
|
|
206
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped descriptor history is unavailable" unless @deployment_history
|
|
207
|
+
@deployment_history.descriptor!(sha256: payload.fetch("descriptor_sha256"))
|
|
208
|
+
end
|
|
209
|
+
map = descriptor.mapping(payload.fetch("mapping_id"))
|
|
210
|
+
unless map.fetch("project_id") == payload.fetch("project_id")
|
|
211
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped project differs"
|
|
212
|
+
end
|
|
213
|
+
provisioning = prefix.select { |entry| entry["type"] == "scope_provisioning" }
|
|
214
|
+
reservation = prefix.select { |entry| entry["type"] == "authority_mutation" && entry.dig("payload", "operation") == "reserve_attempt" }
|
|
215
|
+
unless provisioning.one? && reservation.one?
|
|
216
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped reservation is ambiguous"
|
|
217
|
+
end
|
|
218
|
+
identity = provisioning.first.fetch("payload")
|
|
219
|
+
reservation_data = reservation.first.dig("payload", "data")
|
|
220
|
+
unless identity.is_a?(Hash) && identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id] &&
|
|
221
|
+
reservation_data.is_a?(Hash) && identity["reservation_generation"] == reservation_data["reservation_generation"] &&
|
|
222
|
+
reservation_data.values_at("mapping_id", "project_id") == payload.values_at("mapping_id", "project_id") &&
|
|
223
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(map))) == identity.fetch("deployment_digest") &&
|
|
224
|
+
map.fetch("execution_scope").fetch("slot_id") == identity.fetch("slot_id") &&
|
|
225
|
+
descriptor.project(map.fetch("project_id")).values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
|
|
226
|
+
[journal.repo_root, journal.ref, journal.checkout_root]
|
|
227
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped descriptor canonical association differs"
|
|
228
|
+
end
|
|
229
|
+
params = payload.slice("mapping_id", "assignment_id", "attempt_id")
|
|
230
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: prefix, **payload.slice("project_id", "assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
|
|
231
|
+
unless lineage.binding.fetch("deployment_digest") == identity.fetch("deployment_digest")
|
|
232
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped parent mapping differs"
|
|
233
|
+
end
|
|
234
|
+
verify_historical_boot_baseline!(lineage.binding)
|
|
235
|
+
lineage.require_positive!(scope_generation: lineage.binding.fetch("scope_generation"),
|
|
236
|
+
scope_binding_event_id: payload.fetch("scope_binding_event_id"), seal_event_id: payload.fetch("seal_event_id"), proof_id: payload.fetch("closed_proof_event_id"))
|
|
237
|
+
if issued_input_actor?(prefix) && !accepted_input_inhibition?(prefix, journal, prefix_commit)
|
|
238
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped original input lifetime is not inhibited"
|
|
239
|
+
end
|
|
240
|
+
raise AttemptErrors::EvidenceUnavailable, "Original stopped settlement owner is unavailable" unless @result_owner
|
|
241
|
+
services = @result_owner.service_settlement_evidence!(journal: journal, events: prefix, params: params, map: map, commit: prefix_commit)
|
|
242
|
+
|
|
243
|
+
expected = Molecules::CanonicalAttemptState.stopped_payload!(events: prefix,
|
|
244
|
+
selection: payload.slice(*Molecules::CanonicalAttemptState::SELECTION_FIELDS), service_evidence: services,
|
|
245
|
+
commit: prefix_commit)
|
|
246
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped exhaustive settlement differs" unless payload == expected
|
|
247
|
+
event
|
|
248
|
+
rescue KeyError, TypeError, NoMethodError
|
|
249
|
+
raise AttemptErrors::EvidenceUnavailable, "Stopped original terminal evidence is malformed"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|