ace-assign 0.63.1 → 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/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- 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 +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- 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 +37 -0
- 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 +398 -0
- 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 +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- 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 +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- 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 +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- 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 +59 -0
- 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/private_directory.rb +33 -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/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- 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 +286 -0
- 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 +187 -0
- 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 +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- 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/terminate.rb +29 -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 +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- 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 +38 -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 +56 -4
- 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 +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- 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 +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -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 +68 -32
- 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 +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- 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 +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class Endcap
|
|
7
|
+
SERVICE_IDENTITY = %w[request_id assignment_id attempt_id project_id operation input_digest target
|
|
8
|
+
authorization service_id caller_uid candidate_head candidate_generation executor_uid transport policy_digest
|
|
9
|
+
worker_process_binding launch_ticket reservation_generation mapping_id].freeze
|
|
10
|
+
|
|
11
|
+
# Source-owned terminal admission. Caller already owns lifecycle/journal
|
|
12
|
+
# exclusion; the immutable commit is the current CAS input, never a
|
|
13
|
+
# caller-selected alternate evidence ref.
|
|
14
|
+
def service_settlement_complete!(journal:, events:, params:, map:, commit:)
|
|
15
|
+
service_settlement_evidence!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def service_settlement_evidence!(journal:, events:, params:, map:, commit:)
|
|
20
|
+
protected_journal!(journal)
|
|
21
|
+
journal.verify_commit!(commit)
|
|
22
|
+
retained = journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
23
|
+
.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
24
|
+
unless retained == events && Models::EvidenceEvent.chain_valid?(events)
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "service settlement requires exact canonical commit"
|
|
26
|
+
end
|
|
27
|
+
references = events.select { |event| %w[service_claim service_transition].include?(event["type"]) }
|
|
28
|
+
.map { |event| event.fetch("payload").fetch("request_id") }.uniq.sort
|
|
29
|
+
records = journal.service_request_records(commit: commit).select do |record|
|
|
30
|
+
record["assignment_id"] == params.fetch("assignment_id") && record["attempt_id"] == params.fetch("attempt_id")
|
|
31
|
+
end
|
|
32
|
+
unless records.map { |record| record.fetch("request_id") }.uniq.sort == references
|
|
33
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service request set is incomplete"
|
|
34
|
+
end
|
|
35
|
+
pending = false
|
|
36
|
+
projected = records.sort_by { |record| record.fetch("request_id") }.map do |record|
|
|
37
|
+
unless record["project_id"] == map.fetch("project_id") && record["mapping_id"] == params.fetch("mapping_id")
|
|
38
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service request is not settled for this scope"
|
|
39
|
+
end
|
|
40
|
+
# This existing reader verifies the accepted record digest and all
|
|
41
|
+
# imported terminal/no-effect evidence against this exact commit.
|
|
42
|
+
verified = journal.service_request(record.fetch("request_id"), commit: commit)
|
|
43
|
+
unless verified == record && %w[accepted uncertain rejected succeeded failed failed-settled].include?(verified.fetch("state"))
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service record differs"
|
|
45
|
+
end
|
|
46
|
+
if %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| verified.key?(key) }
|
|
47
|
+
ServiceEvidence.new(journal: journal).challenge!(verified, pending: {commit: commit}, current: false)
|
|
48
|
+
end
|
|
49
|
+
unless %w[succeeded failed-settled].include?(verified.fetch("state"))
|
|
50
|
+
pending = true
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
transition = events.reverse.find { |event| %w[service_claim service_transition].include?(event["type"]) &&
|
|
54
|
+
event.dig("payload", "request_id") == record.fetch("request_id") }
|
|
55
|
+
receipt = verified.fetch("receipt")
|
|
56
|
+
unless transition && transition["type"] == "service_transition" &&
|
|
57
|
+
transition.dig("payload", "state") == verified.fetch("state") &&
|
|
58
|
+
transition.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(verified) &&
|
|
59
|
+
transition.dig("payload", "receipt_digest") == Atoms::EvidenceDigest.digest(receipt)
|
|
60
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted service terminal transition differs"
|
|
61
|
+
end
|
|
62
|
+
{"request_id" => verified.fetch("request_id"), "state" => verified.fetch("state"),
|
|
63
|
+
"event_digest" => transition.fetch("digest"), "record_digest" => transition.dig("payload", "record_digest"),
|
|
64
|
+
"receipt_digest" => transition.dig("payload", "receipt_digest"),
|
|
65
|
+
"evidence_refs" => receipt.fetch("evidence").sort_by { |ref| ref.values_at("ref", "sha256") }}
|
|
66
|
+
end
|
|
67
|
+
projected.compact!
|
|
68
|
+
unless projected.empty?
|
|
69
|
+
journal.event_commits!(assignment_id: params.fetch("assignment_id"),
|
|
70
|
+
event_digests: projected.map { |row| row.fetch("event_digest") }, commit: commit)
|
|
71
|
+
end
|
|
72
|
+
if pending
|
|
73
|
+
raise AttemptErrors::ServiceSettlementPending, "authenticated canonical service requests remain unsettled"
|
|
74
|
+
end
|
|
75
|
+
settlement_immutable({"commit" => commit, "services" => projected})
|
|
76
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
|
|
77
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service settlement is unverifiable"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Called only by the installed journal composition, including lower
|
|
81
|
+
# service writers. It never reacquires lifecycle/authority locks.
|
|
82
|
+
def authorize_service_update!(journal:, existing:, replacement:, pending:)
|
|
83
|
+
protected_journal!(journal)
|
|
84
|
+
if existing && existing.key?("executor_process_binding") &&
|
|
85
|
+
Atoms::EvidenceDigest.digest(existing.fetch("executor_process_binding")) != Atoms::EvidenceDigest.digest(replacement["executor_process_binding"])
|
|
86
|
+
raise AttemptErrors::InvalidState, "original cleanup executor cannot change"
|
|
87
|
+
end
|
|
88
|
+
if existing && existing.key?("operation_owner_binding") &&
|
|
89
|
+
Atoms::EvidenceDigest.digest(existing.fetch("operation_owner_binding")) != Atoms::EvidenceDigest.digest(replacement["operation_owner_binding"])
|
|
90
|
+
raise AttemptErrors::InvalidState, "original cleanup dispatch owner cannot change"
|
|
91
|
+
end
|
|
92
|
+
if existing && %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| existing[key] != replacement[key] }
|
|
93
|
+
unless pending && pending[:operation] == "claim_service_settlement" &&
|
|
94
|
+
existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
|
|
95
|
+
existing["dispatch_phase"] == replacement["dispatch_phase"]
|
|
96
|
+
raise AttemptErrors::InvalidState, "no-effect challenge requires its fixed source owner"
|
|
97
|
+
end
|
|
98
|
+
ServiceEvidence.new(journal: journal).challenge!(replacement, pending: pending)
|
|
99
|
+
end
|
|
100
|
+
initial = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
|
|
101
|
+
if initial && (replacement.key?("operation_owner_binding") || replacement.key?("executor_process_binding"))
|
|
102
|
+
raise AttemptErrors::InvalidState, "cleanup owner identity requires fresh dispatch admission"
|
|
103
|
+
end
|
|
104
|
+
begin_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
|
|
105
|
+
if existing && existing["operation"] == "publish" && pending && %w[publication_challenge publication_continue].include?(pending[:operation])
|
|
106
|
+
return authorize_publication_update!(journal: journal, existing: existing, replacement: replacement, pending: pending)
|
|
107
|
+
end
|
|
108
|
+
if existing && existing["dispatch_phase"] != replacement["dispatch_phase"] && !begin_dispatch
|
|
109
|
+
raise AttemptErrors::InvalidState, "invalid protected dispatch phase change"
|
|
110
|
+
end
|
|
111
|
+
return true unless initial || begin_dispatch
|
|
112
|
+
unless replacement["state"] == "uncertain" && (initial ? replacement["dispatch_phase"] == "issued" : true)
|
|
113
|
+
raise AttemptErrors::InvalidState, "fresh protected effect requires an uncertain dispatch ticket"
|
|
114
|
+
end
|
|
115
|
+
bytes = pending && pending.fetch(:service_inputs, {})[replacement.fetch("request_id")]
|
|
116
|
+
raise AttemptErrors::UnauthorizedIdentity, "fresh service effect requires its exact original input" unless bytes.is_a?(String)
|
|
117
|
+
map = @deployment.mapping(replacement.fetch("mapping_id"))
|
|
118
|
+
unless replacement["project_id"] == map["project_id"] && replacement["caller_uid"] == map["worker_uid"]
|
|
119
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service mapping differs"
|
|
120
|
+
end
|
|
121
|
+
params = replacement.slice("mapping_id", "assignment_id", "attempt_id", "candidate_generation").merge("head" => replacement.fetch("candidate_head"))
|
|
122
|
+
events = pending.fetch(:current_events)
|
|
123
|
+
@launch.scope_open_for_effect!(events: events, params: params, map: map)
|
|
124
|
+
origin = active_origin(events, params)
|
|
125
|
+
unless replacement["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
126
|
+
replacement["launch_ticket"] == origin.fetch("launch_ticket") && replacement["reservation_generation"] == origin.fetch("reservation_generation")
|
|
127
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service origin differs"
|
|
128
|
+
end
|
|
129
|
+
receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
|
|
130
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
|
|
131
|
+
current = exact_candidate!(candidate(events), params)
|
|
132
|
+
approved_review!(journal, events, params, map, current, service_operation: replacement.fetch("operation")) unless %w[create update].include?(replacement.fetch("operation"))
|
|
133
|
+
service_policy!.prepare!(replacement, input_bytes: bytes)
|
|
134
|
+
if begin_dispatch && %w[prune-preserved-workspace publish].include?(replacement.fetch("operation"))
|
|
135
|
+
executor = replacement.fetch("executor_process_binding")
|
|
136
|
+
boot = replacement.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
|
|
137
|
+
Molecules::ExecutionScopeLineage.validate_process_identity!(executor, boot_id: boot)
|
|
138
|
+
credentials = @deployment.project(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
|
|
139
|
+
unless executor.slice("uid", "gid", "groups") == credentials.slice("gid", "groups").merge("uid" => receiver.fetch("executor_uid"))
|
|
140
|
+
raise AttemptErrors::UnauthorizedIdentity, "original cleanup executor credentials differ"
|
|
141
|
+
end
|
|
142
|
+
@kernel.live!(executor)
|
|
143
|
+
unless replacement["operation"] != "prune-preserved-workspace" || Atoms::EvidenceDigest.digest(service_policy!.dispatch_owner_binding!(replacement)) ==
|
|
144
|
+
Atoms::EvidenceDigest.digest(replacement.fetch("operation_owner_binding"))
|
|
145
|
+
raise AttemptErrors::UnauthorizedIdentity, "fixed cleanup owner changed before dispatch acceptance"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
true
|
|
149
|
+
rescue KeyError
|
|
150
|
+
raise AttemptErrors::UnauthorizedIdentity, "protected service admission context is incomplete"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def settlement_immutable(value)
|
|
156
|
+
case value
|
|
157
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.dup.freeze] = settlement_immutable(item) }.freeze
|
|
158
|
+
when Array then value.map { |item| settlement_immutable(item) }.freeze
|
|
159
|
+
when String then value.dup.freeze
|
|
160
|
+
else value.freeze
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def authorize_service_transfer!(request, params, map, peer, role)
|
|
165
|
+
service_policy!
|
|
166
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
|
|
167
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
168
|
+
protected_journal!(journal)
|
|
169
|
+
if request.fetch("operation") == "request_service"
|
|
170
|
+
existing = journal.service_request(params.fetch("request_id"))
|
|
171
|
+
if existing
|
|
172
|
+
service_executor!(peer, role, existing)
|
|
173
|
+
service_replay_binding!(existing, params, map)
|
|
174
|
+
else
|
|
175
|
+
events = attempt_events(journal, params)
|
|
176
|
+
origin = active_origin(events, params)
|
|
177
|
+
worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
178
|
+
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
182
|
+
raise AttemptErrors::NotFound, "service request is unavailable" unless record
|
|
183
|
+
service_executor!(peer, role, record)
|
|
184
|
+
service_ticket!(record, params, map, peer)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Receiver identity and forwarded worker identity are independent checks.
|
|
191
|
+
# This immutable observation grants no service claim or dispatch permit.
|
|
192
|
+
def workspace_prune_preview_context(_request, params, map, peer, role, transfer)
|
|
193
|
+
raise ArgumentError, "preview context forbids upload" unless transfer.nil?
|
|
194
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
195
|
+
{data: workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: role), replayed: false}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Only source composition supplies this fixed journal/snapshot owner.
|
|
200
|
+
# Both ordinary admission and the root read-only view use the same
|
|
201
|
+
# original registration, process and candidate proof implementation.
|
|
202
|
+
public
|
|
203
|
+
|
|
204
|
+
def workspace_prune_preview_context_at!(journal:, params:, map:, peer:, role:)
|
|
205
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"))
|
|
206
|
+
protected_journal!(journal)
|
|
207
|
+
commit = journal.ref_value
|
|
208
|
+
events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: map)
|
|
209
|
+
origin = active_origin(events, params)
|
|
210
|
+
worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
211
|
+
receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
212
|
+
service_policy!.workspace_prune_receiver!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"),
|
|
213
|
+
service_id: params.fetch("service_id"), executor_uid: receiver.fetch("executor_uid"))
|
|
214
|
+
selected = candidate(events)
|
|
215
|
+
unless selected && selected["head"].is_a?(String) && selected["head"].match?(CandidateTransfer::SHA) &&
|
|
216
|
+
selected["candidate_generation"].is_a?(Integer) && selected["candidate_generation"].positive?
|
|
217
|
+
raise AttemptErrors::EvidenceUnavailable, "preview candidate is unavailable"
|
|
218
|
+
end
|
|
219
|
+
data = {
|
|
220
|
+
"journal_commit" => commit,
|
|
221
|
+
"maintenance" => params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id")),
|
|
222
|
+
"head" => selected.fetch("head"), "candidate_generation" => selected.fetch("candidate_generation"),
|
|
223
|
+
"worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
|
|
224
|
+
"caller_process_binding" => params.fetch("worker_process_binding")
|
|
225
|
+
}
|
|
226
|
+
raise AttemptErrors::Conflict, "preview canonical selection changed" unless journal.ref_value == commit
|
|
227
|
+
raise AttemptErrors::EvidenceUnavailable, "preview context exceeds bound" if JSON.generate(data).bytesize > 16_384
|
|
228
|
+
Atoms::EvidenceDigest.digest(data) # Reject unsupported transport values before copying.
|
|
229
|
+
JSON.parse(JSON.generate(data))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def service_status(_request, params, map, peer, role)
|
|
235
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
236
|
+
protected_journal!(journal)
|
|
237
|
+
commit = journal.ref_value
|
|
238
|
+
read = role == :executor && journal.service_settlement_read(params.fetch("request_id"), commit: commit)
|
|
239
|
+
record = read ? read.fetch(:record) : journal.service_request(params.fetch("request_id"), commit: commit)
|
|
240
|
+
unless record && record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
|
|
241
|
+
record["project_id"] == map["project_id"] && record["candidate_head"] == params["head"] &&
|
|
242
|
+
record["candidate_generation"] == params["candidate_generation"]
|
|
243
|
+
raise AttemptErrors::UnauthorizedIdentity, "service status binding differs"
|
|
244
|
+
end
|
|
245
|
+
if role != :executor && %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| record.key?(key) }
|
|
246
|
+
ServiceEvidence.new(journal: journal).challenge!(record, pending: {commit: commit}, current: false)
|
|
247
|
+
end
|
|
248
|
+
selected = read ? read.fetch(:read_view).inventory.fetch("events").fetch(params.fetch("assignment_id")) :
|
|
249
|
+
journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
250
|
+
events = selected.select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
|
|
251
|
+
@kernel.live!(peer)
|
|
252
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: peer.fetch("uid"))
|
|
253
|
+
if role == :executor
|
|
254
|
+
record = service_settlement_record!(journal, params, map, peer, role, record: record)
|
|
255
|
+
else
|
|
256
|
+
authorized = case role
|
|
257
|
+
when :worker
|
|
258
|
+
peer["uid"] == record["caller_uid"] && @kernel.descendant?(peer, record.fetch("worker_process_binding"))
|
|
259
|
+
when :reviewer
|
|
260
|
+
review = assigned_review(events)
|
|
261
|
+
review && review["reviewer_uid"] == peer["uid"] && review["head"] == params["head"] &&
|
|
262
|
+
review["candidate_generation"] == params["candidate_generation"] && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
|
|
263
|
+
when :supervisor
|
|
264
|
+
@deployment.project(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
|
|
265
|
+
else
|
|
266
|
+
false
|
|
267
|
+
end
|
|
268
|
+
raise AttemptErrors::UnauthorizedIdentity, "service status purpose differs" unless authorized
|
|
269
|
+
end
|
|
270
|
+
data = service_projection(record).merge("generation" => journal.authority_generation(events),
|
|
271
|
+
"journal_commit" => commit)
|
|
272
|
+
data["input_digest"] = record.fetch("input_digest") if %w[create update ready merge].include?(record["operation"])
|
|
273
|
+
if %w[create update ready merge].include?(record["operation"]) && record["state"] == "succeeded"
|
|
274
|
+
event = ServiceDeliveryEvidence.new(journal: journal).verified(record: record, events: events, commit: commit)
|
|
275
|
+
data.merge!(record.slice("input_digest", "authorization", "executor_uid", "completion_digest", "receipt"))
|
|
276
|
+
data["delivery_event"] = event
|
|
277
|
+
end
|
|
278
|
+
if role == :executor
|
|
279
|
+
data["settlement_context"] = read.fetch(:settlement_context) ||
|
|
280
|
+
ServiceEvidence.new(journal: journal).settlement_context(record, commit: commit, read_view: read.fetch(:read_view))
|
|
281
|
+
end
|
|
282
|
+
{data: data, replayed: false}
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def service_authorization(request, params, map, peer, role, transfer)
|
|
287
|
+
authorize_service_transfer!(request, params, map, peer, role)
|
|
288
|
+
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
289
|
+
bytes = transfer.bytes.dup.freeze
|
|
290
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
291
|
+
protected_journal!(journal)
|
|
292
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
293
|
+
service_executor!(peer, role, record)
|
|
294
|
+
service_ticket!(record, params, map, peer)
|
|
295
|
+
unless record.fetch("input_digest") == params.fetch("input_digest")
|
|
296
|
+
raise AttemptErrors::Conflict, "authorization input binding differs"
|
|
297
|
+
end
|
|
298
|
+
events = attempt_events(journal, params)
|
|
299
|
+
@launch.scope_open_for_effect!(events: events, params: params, map: map)
|
|
300
|
+
origin = active_origin(events, params)
|
|
301
|
+
unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
302
|
+
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
303
|
+
raise AttemptErrors::UnauthorizedIdentity, "authorization launch origin differs"
|
|
304
|
+
end
|
|
305
|
+
current = exact_candidate!(candidate(events), params)
|
|
306
|
+
unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
|
|
307
|
+
raise AttemptErrors::Conflict, "authorization candidate changed"
|
|
308
|
+
end
|
|
309
|
+
service_receiver!(peer, role, map, record.fetch("service_id"))
|
|
310
|
+
approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
|
|
311
|
+
prepared = service_policy!.prepare!(record, input_bytes: bytes)
|
|
312
|
+
data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
|
|
313
|
+
"policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
|
|
314
|
+
{data: data, replayed: false}
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def dispatch_service(request, params, map, peer, role, transfer)
|
|
319
|
+
return service_authorization(request, params, map, peer, role, transfer) if request.fetch("operation") == "service_authorization"
|
|
320
|
+
authorize_service_transfer!(request, params, map, peer, role)
|
|
321
|
+
admitted = if request.fetch("operation") == "complete_service"
|
|
322
|
+
ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"), artifact_field: "evidence", reference_key: "ref")
|
|
323
|
+
else
|
|
324
|
+
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
325
|
+
transfer.bytes.dup.freeze
|
|
326
|
+
end
|
|
327
|
+
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
328
|
+
protected_journal!(journal)
|
|
329
|
+
unless request.fetch("operation") == "complete_service"
|
|
330
|
+
input_record = request.fetch("operation") == "request_service" ? params : journal.service_request(params.fetch("request_id"))
|
|
331
|
+
# Replay bypasses the mutation callback, never input validation.
|
|
332
|
+
# Only body binding is checked here; current effect policy stays
|
|
333
|
+
# inside each fresh CAS callback.
|
|
334
|
+
service_policy!.input_binding(admitted, expected_digest: input_record.fetch("input_digest"),
|
|
335
|
+
expected_target: input_record.fetch("target"), operation: input_record.fetch("operation"))
|
|
336
|
+
end
|
|
337
|
+
# Admission also runs outside mutate: an exact mutation replay
|
|
338
|
+
# skips its callback and cannot bypass the authenticated ticket.
|
|
339
|
+
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
|
|
340
|
+
retained = request.fetch("operation") == "request_service" && journal.service_request(params.fetch("request_id"))
|
|
341
|
+
if retained
|
|
342
|
+
service_executor!(peer, role, retained)
|
|
343
|
+
service_replay_binding!(retained, params, map)
|
|
344
|
+
end
|
|
345
|
+
if !retained && request.fetch("operation") != "request_service"
|
|
346
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
347
|
+
service_executor!(peer, role, record)
|
|
348
|
+
service_ticket!(record, params, map, peer)
|
|
349
|
+
completion_binding!(record, params, admitted) if request.fetch("operation") == "complete_service"
|
|
350
|
+
elsif !retained
|
|
351
|
+
origin = active_origin(attempt_events(journal, params), params)
|
|
352
|
+
worker_or_launcher!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
353
|
+
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
354
|
+
end
|
|
355
|
+
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
356
|
+
mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
|
|
357
|
+
parameters_digest: Atoms::EvidenceDigest.digest(params),
|
|
358
|
+
expected_generation: request.fetch("operation") == "complete_service" ? nil : params.fetch("expected_generation"),
|
|
359
|
+
generation_mode: request.fetch("operation") == "complete_service" ? :recorded_completion : :expected, with_replay: true) do |events, _commit, _generation|
|
|
360
|
+
if request.fetch("operation") == "request_service"
|
|
361
|
+
existing = journal.service_request(params.fetch("request_id"))
|
|
362
|
+
if existing
|
|
363
|
+
service_executor!(peer, role, existing)
|
|
364
|
+
service_replay_binding!(existing, params, map)
|
|
365
|
+
{data: service_projection(existing).merge("claim" => "retained")}
|
|
366
|
+
else
|
|
367
|
+
service_claim_plan(journal, events, params, map, peer, role, admitted)
|
|
368
|
+
end
|
|
369
|
+
elsif request.fetch("operation") == "begin_dispatch"
|
|
370
|
+
service_begin_plan(journal, events, params, map, peer, role, admitted)
|
|
371
|
+
else
|
|
372
|
+
service_completion_plan(journal, events, params, map, peer, role, admitted)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
if request.fetch("operation") == "begin_dispatch" && result.fetch(:replayed)
|
|
376
|
+
result = result.merge(data: result.fetch(:data).merge("invocation" => "already_started"))
|
|
377
|
+
end
|
|
378
|
+
if request.fetch("operation") == "request_service"
|
|
379
|
+
# Acceptance metadata belongs to this exact mutation, while
|
|
380
|
+
# retained service state may have advanced since acceptance.
|
|
381
|
+
current = journal.service_request(params.fetch("request_id"))
|
|
382
|
+
data = service_projection(current).merge(result.fetch(:data).slice("generation", "journal_commit"))
|
|
383
|
+
data["claim"] = retained || result.fetch(:replayed) || result.dig(:data, "claim") == "retained" ? "retained" : "created"
|
|
384
|
+
result = result.merge(data: data)
|
|
385
|
+
end
|
|
386
|
+
if %w[request_service begin_dispatch].include?(request.fetch("operation")) &&
|
|
387
|
+
%w[prune-preserved-workspace publish].include?(result.fetch(:data).fetch("operation"))
|
|
388
|
+
selector = request.fetch("operation") == "request_service" ? "request_event_digest" : "dispatch_event_digest"
|
|
389
|
+
digest = cleanup_service_mutation_event!(journal, request, params, result.fetch(:data).fetch("journal_commit"))
|
|
390
|
+
result = result.merge(data: result.fetch(:data).merge(selector => digest))
|
|
391
|
+
end
|
|
392
|
+
result
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Selectors refer to the actual accepted mutation, never a caller's
|
|
397
|
+
# pathname or the latest service record. They are derived after CAS,
|
|
398
|
+
# avoiding a self-reference in the persisted mutation reply.
|
|
399
|
+
def cleanup_service_mutation_event!(journal, request, params, commit)
|
|
400
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
401
|
+
.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
402
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
403
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup service mutation chain is unavailable"
|
|
404
|
+
end
|
|
405
|
+
selected = events.select do |event|
|
|
406
|
+
event["type"] == "authority_mutation" && event.dig("payload", "mutation_id") == request.fetch("mutation_id")
|
|
407
|
+
end
|
|
408
|
+
event = selected.one? && selected.first
|
|
409
|
+
unless event && event.dig("payload", "operation") == request.fetch("operation") &&
|
|
410
|
+
event.dig("payload", "parameters_digest") == Atoms::EvidenceDigest.digest(params) &&
|
|
411
|
+
event.dig("payload", "assignment_id") == params.fetch("assignment_id") &&
|
|
412
|
+
event.dig("payload", "attempt_id") == params.fetch("attempt_id") &&
|
|
413
|
+
event.dig("payload", "data", "request_id") == params.fetch("request_id") &&
|
|
414
|
+
journal.event_commit!(assignment_id: params.fetch("assignment_id"), event_digest: event.fetch("digest"), commit: commit) == commit
|
|
415
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup service mutation introduction is unavailable"
|
|
416
|
+
end
|
|
417
|
+
event.fetch("digest")
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def service_policy!
|
|
421
|
+
@service_policy || raise(ArgumentError, "full-service authority requires its installed policy owner")
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def service_executor!(peer, role, record)
|
|
425
|
+
unless record && role == :executor && peer["uid"] == record.fetch("executor_uid")
|
|
426
|
+
raise AttemptErrors::UnauthorizedIdentity, "completion requires the recorded executor"
|
|
427
|
+
end
|
|
428
|
+
if record["operation"] == "publish" && record["dispatch_phase"] != "issued" && peer != record["executor_process_binding"]
|
|
429
|
+
raise AttemptErrors::UnauthorizedIdentity, "publication requires its original receiver birth"
|
|
430
|
+
end
|
|
431
|
+
@kernel.live!(peer)
|
|
432
|
+
true
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def service_receiver!(peer, role, map, service_id)
|
|
436
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
437
|
+
receiver = project.fetch("service_receivers").fetch(service_id)
|
|
438
|
+
unless role == :executor && peer["uid"] == receiver.fetch("executor_uid") &&
|
|
439
|
+
project.fetch("service_executor_uids").include?(peer["uid"]) &&
|
|
440
|
+
!project.fetch("worker_uids").include?(peer["uid"])
|
|
441
|
+
raise AttemptErrors::UnauthorizedIdentity, "service requires its fixed independent receiver"
|
|
442
|
+
end
|
|
443
|
+
@kernel.live!(peer)
|
|
444
|
+
receiver
|
|
445
|
+
rescue KeyError
|
|
446
|
+
raise AttemptErrors::UnauthorizedIdentity, "fixed service receiver is unavailable"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def service_claim_plan(journal, events, params, map, peer, role, input_bytes)
|
|
450
|
+
origin = active_origin(events, params)
|
|
451
|
+
worker = params.fetch("worker_process_binding")
|
|
452
|
+
worker_or_launcher!(worker, :worker, map, origin)
|
|
453
|
+
current = exact_candidate!(candidate(events), params)
|
|
454
|
+
approved_review!(journal, events, params, map, current, service_operation: params.fetch("operation")) unless %w[create update].include?(params.fetch("operation"))
|
|
455
|
+
receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
456
|
+
binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
|
|
457
|
+
"authorization", "service_id").merge("project_id" => map.fetch("project_id"),
|
|
458
|
+
"caller_uid" => map.fetch("worker_uid"), "mapping_id" => params.fetch("mapping_id"), "candidate_head" => current.fetch("head"),
|
|
459
|
+
"candidate_generation" => current.fetch("candidate_generation"),
|
|
460
|
+
"worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
|
|
461
|
+
"launch_ticket" => origin.fetch("launch_ticket"), "reservation_generation" => origin.fetch("reservation_generation"))
|
|
462
|
+
prepared = service_policy!.prepare!(binding, input_bytes: input_bytes)
|
|
463
|
+
binding = prepared.fetch(:binding).merge("policy_digest" => prepared.fetch(:policy_digest))
|
|
464
|
+
unless binding["executor_uid"] == receiver.fetch("executor_uid")
|
|
465
|
+
raise AttemptErrors::UnauthorizedIdentity, "policy and fixed receiver disagree"
|
|
466
|
+
end
|
|
467
|
+
existing = journal.service_request(binding.fetch("request_id"))
|
|
468
|
+
if existing
|
|
469
|
+
unless existing.slice(*SERVICE_IDENTITY) == binding.slice(*SERVICE_IDENTITY)
|
|
470
|
+
raise AttemptErrors::Conflict, "service request immutable identity differs"
|
|
471
|
+
end
|
|
472
|
+
return {data: service_projection(existing).merge("claim" => "retained")}
|
|
473
|
+
end
|
|
474
|
+
ticket = SecureRandom.hex(16)
|
|
475
|
+
record = binding.merge("dispatch_ticket_id" => ticket,
|
|
476
|
+
"claim_binding" => Atoms::EvidenceDigest.digest(binding.merge("dispatch_ticket_id" => ticket)),
|
|
477
|
+
"claim_generation" => 1, "dispatch_phase" => "issued", "state" => "uncertain",
|
|
478
|
+
"claimed_at" => Time.now.utc.iso8601(9))
|
|
479
|
+
{data: service_projection(record).merge("claim" => "created"), service_inputs: {record.fetch("request_id") => input_bytes}, service_updates: [{request_id: record.fetch("request_id"),
|
|
480
|
+
expected: nil, replacement: record, event_type: "service_claim"}]}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def service_begin_plan(journal, events, params, map, peer, role, input_bytes)
|
|
484
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
485
|
+
raise AttemptErrors::NotFound, "service request is unavailable" unless record
|
|
486
|
+
service_receiver!(peer, role, map, record.fetch("service_id"))
|
|
487
|
+
service_ticket!(record, params, map, peer)
|
|
488
|
+
# A repeated begin never grants a second invocation, including after
|
|
489
|
+
# lease expiry or terminality. It reports only retained ticket truth.
|
|
490
|
+
return {data: service_projection(record).merge("invocation" => "already_started")} unless record["dispatch_phase"] == "issued"
|
|
491
|
+
origin = active_origin(events, params)
|
|
492
|
+
unless record["worker_process_binding"] == origin.fetch("process_binding").fetch("process_identity") &&
|
|
493
|
+
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
494
|
+
raise AttemptErrors::UnauthorizedIdentity, "dispatch launch origin differs"
|
|
495
|
+
end
|
|
496
|
+
current = exact_candidate!(candidate(events), params)
|
|
497
|
+
unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
|
|
498
|
+
raise AttemptErrors::Conflict, "dispatch candidate changed"
|
|
499
|
+
end
|
|
500
|
+
approved_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
|
|
501
|
+
service_policy!.prepare!(record, input_bytes: input_bytes)
|
|
502
|
+
replacement = record.merge("dispatch_phase" => "dispatch_started")
|
|
503
|
+
if record.fetch("operation") == "prune-preserved-workspace"
|
|
504
|
+
replacement["operation_owner_binding"] = service_policy!.dispatch_owner_binding!(record)
|
|
505
|
+
end
|
|
506
|
+
if %w[prune-preserved-workspace publish].include?(record.fetch("operation"))
|
|
507
|
+
replacement["executor_process_binding"] = JSON.parse(JSON.generate(peer))
|
|
508
|
+
end
|
|
509
|
+
{data: service_projection(replacement).merge("invocation" => "permitted"),
|
|
510
|
+
service_inputs: {record.fetch("request_id") => input_bytes},
|
|
511
|
+
service_updates: [{request_id: record.fetch("request_id"), expected: record,
|
|
512
|
+
replacement: replacement, event_type: "service_transition"}]}
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def service_ticket!(record, params, map, peer)
|
|
516
|
+
unless record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
|
|
517
|
+
record["project_id"] == map["project_id"] && record["executor_uid"] == peer["uid"] &&
|
|
518
|
+
record["claim_binding"] == params["claim_binding"]
|
|
519
|
+
raise AttemptErrors::UnauthorizedIdentity, "dispatch ticket binding differs"
|
|
520
|
+
end
|
|
521
|
+
true
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def service_replay_binding!(record, params, map)
|
|
525
|
+
fields = %w[request_id assignment_id attempt_id operation input_digest target authorization service_id mapping_id]
|
|
526
|
+
unless fields.all? { |field| record[field] == params[field] } && record["project_id"] == map["project_id"] &&
|
|
527
|
+
record["caller_uid"] == map["worker_uid"] && params.dig("worker_process_binding", "uid") == map["worker_uid"]
|
|
528
|
+
raise AttemptErrors::Conflict, "service replay immutable identity differs"
|
|
529
|
+
end
|
|
530
|
+
@kernel.live!(params.fetch("worker_process_binding"))
|
|
531
|
+
unless @kernel.descendant?(params.fetch("worker_process_binding"), record.fetch("worker_process_binding"))
|
|
532
|
+
raise AttemptErrors::UnauthorizedIdentity, "service replay requires recorded native worker ownership"
|
|
533
|
+
end
|
|
534
|
+
true
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def service_completion_plan(journal, events, params, map, peer, role, admitted)
|
|
538
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
539
|
+
service_executor!(peer, role, record)
|
|
540
|
+
service_ticket!(record, params, map, peer)
|
|
541
|
+
digest = completion_binding!(record, params, admitted)
|
|
542
|
+
if record["completion_digest"]
|
|
543
|
+
return {data: service_projection(record)}
|
|
544
|
+
end
|
|
545
|
+
receipt = admitted.fetch(:receipt)
|
|
546
|
+
if record["operation"] == "publish"
|
|
547
|
+
unless admitted.fetch(:artifacts).size == 1
|
|
548
|
+
raise AttemptErrors::ReceiptRejected, "publication requires its one exact result"
|
|
549
|
+
end
|
|
550
|
+
ServiceEvidence.new(journal: journal).publication_result!(admitted.fetch(:artifacts).first, record, state: receipt.fetch("outcome"))
|
|
551
|
+
end
|
|
552
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
553
|
+
canonical = Molecules::CanonicalEvidence.new(journal: journal)
|
|
554
|
+
plan = canonical.import_plan(**owner.context(record), artifacts: admitted.fetch(:artifacts),
|
|
555
|
+
admitted_after_event_digest: events.last.fetch("digest"))
|
|
556
|
+
normalized = JSON.parse(JSON.generate(receipt)).merge("evidence" => plan.fetch(:references))
|
|
557
|
+
replacement = record.merge("state" => receipt.fetch("outcome"), "receipt" => normalized, "completion_digest" => digest)
|
|
558
|
+
replacement["failed_at"] = Time.now.utc.iso8601(9) if receipt["outcome"] == "failed"
|
|
559
|
+
if %w[create update ready merge].include?(replacement["operation"]) && replacement["state"] == "succeeded"
|
|
560
|
+
event = ServiceDeliveryEvidence.new(journal: journal).completion_event(record: replacement,
|
|
561
|
+
receipt: normalized, completion_digest: digest, artifacts: admitted.fetch(:artifacts))
|
|
562
|
+
plan = plan.merge(events: plan.fetch(:events) + [event])
|
|
563
|
+
end
|
|
564
|
+
plan.merge(data: service_projection(replacement), service_updates: [{request_id: record.fetch("request_id"),
|
|
565
|
+
expected: record, replacement: replacement, event_type: "service_transition"}])
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def completion_binding!(record, params, admitted)
|
|
569
|
+
unless record["candidate_head"] == params["head"] && record["candidate_generation"] == params["candidate_generation"]
|
|
570
|
+
raise AttemptErrors::Conflict, "completion candidate binding differs"
|
|
571
|
+
end
|
|
572
|
+
digest = Atoms::EvidenceDigest.digest("receipt_sha256" => admitted.fetch(:receipt_sha256),
|
|
573
|
+
"artifacts" => admitted.fetch(:artifacts).map { |bytes| Digest::SHA256.hexdigest(bytes) })
|
|
574
|
+
if record["completion_digest"] && record["completion_digest"] != digest
|
|
575
|
+
raise AttemptErrors::Conflict, "service completion content differs"
|
|
576
|
+
end
|
|
577
|
+
receipt = admitted.fetch(:receipt)
|
|
578
|
+
unless receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
|
|
579
|
+
Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.all? { |field| receipt[field] == record[field] } &&
|
|
580
|
+
%w[succeeded failed].include?(receipt["outcome"]) &&
|
|
581
|
+
(receipt["outcome"] != "succeeded" || record["dispatch_phase"] == "dispatch_started" || (record["operation"] == "publish" && record["dispatch_phase"] == "issuing"))
|
|
582
|
+
raise AttemptErrors::ReceiptRejected, "completion must attest the exact dispatched request"
|
|
583
|
+
end
|
|
584
|
+
digest
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def service_projection(record)
|
|
588
|
+
record.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation", "service_id", "target",
|
|
589
|
+
"candidate_head", "candidate_generation", "state", "dispatch_ticket_id", "claim_binding", "claim_generation", "dispatch_phase", "policy_digest", "operation_owner_binding", "executor_process_binding", *ServicePublicationEvidence::PUBLICATION_FIELDS).tap do |projection|
|
|
590
|
+
if record["operation"] == "publish" && %w[succeeded failed].include?(record["state"])
|
|
591
|
+
projection.merge!(record.slice("receipt", "completion_digest", "authorization", "input_digest"))
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|