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
|
@@ -11,7 +11,7 @@ module Ace
|
|
|
11
11
|
# This reader never promotes cached population into a proof event.
|
|
12
12
|
class ExecutionScopeLineage
|
|
13
13
|
BINDING_FIELDS = %w[project_id assignment_id attempt_id mapping_id slot_id reservation_generation
|
|
14
|
-
scope_generation deployment_digest boot_id slice_invocation_id cgroup_identity resource_mount_namespace_identity resource_identities network_installation_selection network_namespace_identity].freeze
|
|
14
|
+
scope_generation deployment_digest boot_id slice_invocation_id cgroup_identity resource_mount_namespace_identity resource_identities network_installation_selection network_namespace_identity boot_baseline_selection].freeze
|
|
15
15
|
PROOF_FIELDS = %w[scope_generation scope_binding_event_id seal_event_id boot_id slice_invocation_id
|
|
16
16
|
cgroup_identity populated].freeze
|
|
17
17
|
PROCESS_FIELDS = %w[pid uid gid groups started_at host parent_pid].freeze
|
|
@@ -22,6 +22,20 @@ module Ace
|
|
|
22
22
|
INVOCATION = /\A[0-9a-f]{32}\z/
|
|
23
23
|
BOOT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
24
24
|
|
|
25
|
+
def self.validate_process_identity!(value, boot_id:)
|
|
26
|
+
unless boot_id.is_a?(String) && BOOT.match?(boot_id) && value.is_a?(Hash) && value.keys.sort == PROCESS_FIELDS.sort
|
|
27
|
+
raise AttemptErrors::EvidenceUnavailable, "scope process identity fields differ"
|
|
28
|
+
end
|
|
29
|
+
groups = value["groups"]
|
|
30
|
+
unless %w[pid uid gid parent_pid].all? { |key| value[key].is_a?(Integer) && value[key].positive? } &&
|
|
31
|
+
groups.is_a?(Array) && groups.all? { |id| id.is_a?(Integer) && id.positive? } && groups == groups.sort.uniq &&
|
|
32
|
+
value["started_at"].is_a?(String) && value["started_at"].match?(/\Alinux:#{Regexp.escape(boot_id)}:[0-9]+\z/) &&
|
|
33
|
+
value["host"].is_a?(String) && !value["host"].empty?
|
|
34
|
+
raise AttemptErrors::EvidenceUnavailable, "scope process birth/credentials are malformed"
|
|
35
|
+
end
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
def self.validate_network_selection!(selection)
|
|
26
40
|
unless selection.is_a?(Hash) && selection.keys.sort == %w[installer_artifact policy_export profile report]
|
|
27
41
|
raise AttemptErrors::EvidenceUnavailable, "network installation selection is not closed"
|
|
@@ -37,6 +51,16 @@ module Ace
|
|
|
37
51
|
selection
|
|
38
52
|
end
|
|
39
53
|
|
|
54
|
+
def self.validate_boot_baseline_selection!(ref)
|
|
55
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && ref["path"].is_a?(String) &&
|
|
56
|
+
ref["path"].bytesize.between?(1, 4096) && ref["path"].start_with?("/") && !ref["path"].include?("\0") &&
|
|
57
|
+
File.expand_path(ref["path"]) == ref["path"] && ref["sha256"].is_a?(String) && DIGEST.match?(ref["sha256"]) &&
|
|
58
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 16_384)
|
|
59
|
+
raise AttemptErrors::EvidenceUnavailable, "boot baseline artifact reference differs"
|
|
60
|
+
end
|
|
61
|
+
ref
|
|
62
|
+
end
|
|
63
|
+
|
|
40
64
|
attr_reader :binding_event, :native_event, :child_event, :seal_event, :proof_event, :admission_event
|
|
41
65
|
|
|
42
66
|
def initialize(events:, project_id:, assignment_id:, attempt_id:, mapping_id:)
|
|
@@ -136,6 +160,7 @@ module Ace
|
|
|
136
160
|
namespace!(value.fetch("resource_mount_namespace_identity"))
|
|
137
161
|
namespace!(value.fetch("network_namespace_identity"))
|
|
138
162
|
self.class.validate_network_selection!(value.fetch("network_installation_selection"))
|
|
163
|
+
self.class.validate_boot_baseline_selection!(value.fetch("boot_baseline_selection"))
|
|
139
164
|
resources = value.fetch("resource_identities")
|
|
140
165
|
resources!(resources)
|
|
141
166
|
@binding_event = event
|
|
@@ -297,14 +322,7 @@ module Ace
|
|
|
297
322
|
end
|
|
298
323
|
|
|
299
324
|
def process!(value, boot_id)
|
|
300
|
-
|
|
301
|
-
groups = value["groups"]
|
|
302
|
-
unless %w[pid uid gid parent_pid].all? { |key| positive_integer?(value[key]) } &&
|
|
303
|
-
groups.is_a?(Array) && groups.all? { |id| positive_integer?(id) } && groups == groups.sort.uniq &&
|
|
304
|
-
value["started_at"].is_a?(String) && value["started_at"].match?(/\Alinux:#{Regexp.escape(boot_id)}:[0-9]+\z/) &&
|
|
305
|
-
value["host"].is_a?(String) && !value["host"].empty?
|
|
306
|
-
unavailable!("scope process birth/credentials are malformed")
|
|
307
|
-
end
|
|
325
|
+
self.class.validate_process_identity!(value, boot_id: boot_id)
|
|
308
326
|
end
|
|
309
327
|
|
|
310
328
|
def exact_fields!(value, keys)
|
|
@@ -85,14 +85,22 @@ module Ace
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
|
|
88
|
-
last_message_file: nil, session_meta_file: nil)
|
|
88
|
+
last_message_file: nil, session_meta_file: nil, prepared_input: nil)
|
|
89
89
|
ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
|
|
90
90
|
resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
|
|
91
91
|
resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
|
|
92
92
|
scoped_assignment = "#{assignment_id}@#{fork_root}"
|
|
93
|
-
|
|
93
|
+
if prepared_input && prepared_input.descriptor.values_at("assignment_id", "scope") != [assignment_id, fork_root]
|
|
94
|
+
raise Error, "Prepared provider scope differs"
|
|
95
|
+
end
|
|
96
|
+
prompt = prepared_input ? prepared_input.drive_prompt : "/as-assign-drive #{scoped_assignment}"
|
|
94
97
|
last_msg_file = last_message_file || build_last_message_file(cache_dir, fork_root)
|
|
95
98
|
scope_env = self.class.fork_scope_env(assignment_id: assignment_id, fork_root: fork_root)
|
|
99
|
+
if prepared_input
|
|
100
|
+
scope_env = scope_env.merge("ACE_ASSIGN_LAUNCH_MAPPING" => prepared_input.descriptor.fetch("mapping_id"),
|
|
101
|
+
"ACE_ASSIGN_ASSIGNMENT_ID" => assignment_id, "ACE_ASSIGN_ATTEMPT_ID" => prepared_input.descriptor.fetch("attempt_id"),
|
|
102
|
+
"ACE_ASSIGN_TASK_CONTEXT_ENTRY" => JSON.generate(prepared_input.descriptor.fetch("task_context_entry").fetch("manifest")))
|
|
103
|
+
end
|
|
96
104
|
|
|
97
105
|
result = query_interface.query(
|
|
98
106
|
resolved_provider,
|
|
@@ -102,7 +110,8 @@ module Ace
|
|
|
102
110
|
timeout: resolved_timeout,
|
|
103
111
|
fallback: false,
|
|
104
112
|
last_message_file: last_msg_file,
|
|
105
|
-
subprocess_env: scope_env
|
|
113
|
+
subprocess_env: scope_env,
|
|
114
|
+
working_dir: prepared_input&.working_directory
|
|
106
115
|
)
|
|
107
116
|
|
|
108
117
|
if last_msg_file && result[:text] && !result[:text].strip.empty?
|
|
@@ -110,7 +119,8 @@ module Ace
|
|
|
110
119
|
File.write(last_msg_file, result[:text]) if existing.empty?
|
|
111
120
|
end
|
|
112
121
|
|
|
113
|
-
write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file
|
|
122
|
+
write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file,
|
|
123
|
+
discover_session: prepared_input.nil?)
|
|
114
124
|
|
|
115
125
|
result
|
|
116
126
|
rescue Ace::LLM::Error => e
|
|
@@ -220,12 +230,12 @@ module Ace
|
|
|
220
230
|
File.write(session_meta_file, meta.to_yaml) unless meta.empty?
|
|
221
231
|
end
|
|
222
232
|
|
|
223
|
-
def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil)
|
|
233
|
+
def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil, discover_session: true)
|
|
224
234
|
return unless last_msg_file
|
|
225
235
|
|
|
226
236
|
session_id = result.dig(:metadata, :session_id)
|
|
227
237
|
|
|
228
|
-
if session_id.nil? || session_id.to_s.strip.empty?
|
|
238
|
+
if discover_session && (session_id.nil? || session_id.to_s.strip.empty?)
|
|
229
239
|
detected = detect_provider_session(result[:provider], prompt)
|
|
230
240
|
session_id = detected&.dig(:session_id)
|
|
231
241
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "execution_scope_lineage"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module Molecules
|
|
7
|
+
module JournalInputInhibition
|
|
8
|
+
INPUT_INHIBITION_FIELDS = %w[assignment_id attempt_id mapping_id original_binding_digest project_id seal_event_id].freeze
|
|
9
|
+
|
|
10
|
+
def observe_input_inhibition(selection:, evidence:, &authentication)
|
|
11
|
+
raise ArgumentError, "Original inhibition authentication is required" unless authentication
|
|
12
|
+
input = selection.merge("guarded_evidence" => evidence)
|
|
13
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(input)))
|
|
14
|
+
mutate(assignment_id: selection.fetch("assignment_id"), attempt_id: selection.fetch("attempt_id"),
|
|
15
|
+
mutation_id: "input-inhibit.#{selection.fetch('seal_event_id')}", operation: "input_inhibition_observation",
|
|
16
|
+
parameters_digest: digest, expected_generation: nil, generation_mode: :input_inhibition,
|
|
17
|
+
input_inhibition: input, with_replay: true) do |events, commit, generation|
|
|
18
|
+
original = authentication.call(events, commit, generation)
|
|
19
|
+
unless original.is_a?(Hash) && original.keys.sort == %w[binding_digest origin] && original["binding_digest"] == selection.fetch("original_binding_digest")
|
|
20
|
+
raise AttemptErrors::EvidenceUnavailable, "Original input inhibition record differs"
|
|
21
|
+
end
|
|
22
|
+
verify_input_drained_evidence!(evidence, original.fetch("origin"))
|
|
23
|
+
{events: [{type: "input_inhibited", payload: selection.merge("guarded_evidence" => evidence)}], blobs: {},
|
|
24
|
+
data: selection.slice("original_binding_digest", "seal_event_id")}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def verify_input_drained_evidence!(evidence, original)
|
|
29
|
+
unless original.is_a?(Hash) && evidence.is_a?(Hash) && evidence.keys.sort == %w[input_state origin outcome pending_input] &&
|
|
30
|
+
evidence["outcome"] == "inhibited" && evidence["input_state"] == "inhibited" &&
|
|
31
|
+
evidence["pending_input"].is_a?(Integer) && evidence["pending_input"].zero?
|
|
32
|
+
raise AttemptErrors::EvidenceUnavailable, "Native input drainage is unconfirmed"
|
|
33
|
+
end
|
|
34
|
+
received = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(evidence.fetch("origin"),
|
|
35
|
+
terminal_id: original.fetch("terminal_id"), child: original.fetch("child"))
|
|
36
|
+
raise AttemptErrors::EvidenceUnavailable, "Native input drainage origin differs" unless received == original
|
|
37
|
+
true
|
|
38
|
+
rescue Ace::Runtime::RuntimeUnavailableError, KeyError, TypeError
|
|
39
|
+
raise AttemptErrors::EvidenceUnavailable, "Native input drainage is unverifiable"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def verify_input_inhibition!(input, mutation_id:, digest:, assignment_id:, attempt_id:, commit:)
|
|
45
|
+
unless input.is_a?(Hash) && input.keys.sort == (INPUT_INHIBITION_FIELDS + ["guarded_evidence"]).sort &&
|
|
46
|
+
INPUT_INHIBITION_FIELDS.all? { |key| input[key].is_a?(String) } &&
|
|
47
|
+
%w[original_binding_digest seal_event_id].all? { |key| input.fetch(key).match?(/\A[0-9a-f]{64}\z/) } &&
|
|
48
|
+
input["assignment_id"] == assignment_id && input["attempt_id"] == attempt_id &&
|
|
49
|
+
mutation_id == "input-inhibit.#{input.fetch('seal_event_id')}" &&
|
|
50
|
+
digest == Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(input)))
|
|
51
|
+
raise ArgumentError, "Invalid fixed input inhibition selector"
|
|
52
|
+
end
|
|
53
|
+
events = read_events(assignment_id, commit: commit).select { |event| event["attempt_id"] == attempt_id }
|
|
54
|
+
authority_generation(events)
|
|
55
|
+
record = events.find { |event| event["digest"] == input.fetch("original_binding_digest") && event["type"] == "authority_mutation" &&
|
|
56
|
+
event.dig("payload", "operation") == "record_launch" }
|
|
57
|
+
data = record&.dig("payload", "data")
|
|
58
|
+
seal = events.find { |event| event["digest"] == input.fetch("seal_event_id") && event["type"] == "scope_sealed" }
|
|
59
|
+
accepted_seal = seal && events.find { |event| event["type"] == "authority_mutation" && event["previous_digest"] == seal.fetch("digest") &&
|
|
60
|
+
%w[close_execution_scope stop_attempt].include?(event.dig("payload", "operation")) }
|
|
61
|
+
lineage = ExecutionScopeLineage.new(events: events, project_id: input.fetch("project_id"), assignment_id: assignment_id,
|
|
62
|
+
attempt_id: attempt_id, mapping_id: input.fetch("mapping_id"))
|
|
63
|
+
unless data.is_a?(Hash) && %w[assignment_id attempt_id mapping_id].all? { |key| data[key] == input.fetch(key) } &&
|
|
64
|
+
seal && accepted_seal && lineage.seal_event == seal && events.index(record) < events.index(seal) &&
|
|
65
|
+
events.drop(events.index(seal) + 1).none? { |event| event["type"] == "prompt_issued" } &&
|
|
66
|
+
events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
67
|
+
raise AttemptErrors::EvidenceUnavailable, "Input inhibition does not join accepted original seal"
|
|
68
|
+
end
|
|
69
|
+
origin = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(data.fetch("guarded_origin"),
|
|
70
|
+
terminal_id: data.fetch("process_binding").fetch("terminal_id"), child: data.fetch("process_binding").fetch("process_identity"))
|
|
71
|
+
verify_input_drained_evidence!(input.fetch("guarded_evidence"), origin)
|
|
72
|
+
rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
|
|
73
|
+
raise AttemptErrors::EvidenceUnavailable, "Input inhibition canonical selection is malformed"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "digest"
|
|
5
|
+
require "ace/herdr/molecules/bounded_process"
|
|
6
|
+
require_relative "journal_prompt_mutation"
|
|
7
|
+
require_relative "journal_input_inhibition"
|
|
5
8
|
|
|
6
9
|
module Ace
|
|
7
10
|
module Assign
|
|
@@ -9,6 +12,8 @@ module Ace
|
|
|
9
12
|
# Protected authority mutations share the execution ref's lock and CAS.
|
|
10
13
|
# Replies live in chained attempt events, rather than an RPC ledger.
|
|
11
14
|
module JournalMutation
|
|
15
|
+
include JournalPromptMutation
|
|
16
|
+
include JournalInputInhibition
|
|
12
17
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
13
18
|
|
|
14
19
|
# Shared canonical projection used by admission and protected status.
|
|
@@ -29,11 +34,39 @@ module Ace
|
|
|
29
34
|
# returns events [{type:, payload:}], immutable blobs, and public data.
|
|
30
35
|
# Only the authority calls this internal journal API; wire requests
|
|
31
36
|
# cannot select blob paths, event types, or accepted response data.
|
|
32
|
-
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected)
|
|
37
|
+
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil)
|
|
33
38
|
unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
|
|
34
|
-
(generation_mode == :recorded_completion && operation
|
|
39
|
+
(generation_mode == :recorded_completion && %w[complete_service complete_no_effect].include?(operation) && expected_generation.nil?) ||
|
|
40
|
+
(generation_mode == :prompt_completion && operation == "prompt_attempt" && expected_generation.nil? && prompt_completion.is_a?(Hash)) ||
|
|
41
|
+
(generation_mode == :prompt_observation && operation == "prompt_observation" && expected_generation.nil? && prompt_observation.is_a?(Hash)) ||
|
|
42
|
+
(generation_mode == :input_inhibition && operation == "input_inhibition_observation" && expected_generation.nil? && input_inhibition.is_a?(Hash))
|
|
35
43
|
raise ArgumentError, "invalid fixed mutation generation mode"
|
|
36
44
|
end
|
|
45
|
+
if input_inhibition && generation_mode != :input_inhibition
|
|
46
|
+
raise ArgumentError, "Input inhibition requires its fixed mode"
|
|
47
|
+
end
|
|
48
|
+
if operation == "input_inhibition_observation" && !(generation_mode == :input_inhibition && input_inhibition)
|
|
49
|
+
raise ArgumentError, "Input inhibition observation requires its accepted original selector"
|
|
50
|
+
end
|
|
51
|
+
if mutation_id.is_a?(String) && mutation_id.start_with?("input-inhibit.") && !(operation == "input_inhibition_observation" && input_inhibition)
|
|
52
|
+
raise ArgumentError, "Reserved input inhibition namespace"
|
|
53
|
+
end
|
|
54
|
+
if prompt_completion && generation_mode != :prompt_completion
|
|
55
|
+
raise ArgumentError, "prompt completion selector requires its fixed mode"
|
|
56
|
+
end
|
|
57
|
+
if prompt_observation && generation_mode != :prompt_observation
|
|
58
|
+
raise ArgumentError, "prompt observation selector requires its fixed mode"
|
|
59
|
+
end
|
|
60
|
+
if operation == "prompt_issue"
|
|
61
|
+
validate_prompt_binding!(prompt_binding)
|
|
62
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(prompt_binding)))
|
|
63
|
+
unless parameters_digest == digest && mutation_id == "prompt-issue.#{digest}" &&
|
|
64
|
+
assignment_id == prompt_binding.fetch("assignment_id") && attempt_id == prompt_binding.fetch("attempt_id")
|
|
65
|
+
raise ArgumentError, "prompt phase does not match source binding"
|
|
66
|
+
end
|
|
67
|
+
elsif prompt_binding
|
|
68
|
+
raise ArgumentError, "prompt binding is only accepted by its fixed issue phase"
|
|
69
|
+
end
|
|
37
70
|
[assignment_id, attempt_id, mutation_id].each { |id| validate_mutation_id!(id) }
|
|
38
71
|
unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
39
72
|
raise ArgumentError, "invalid mutation parameter digest"
|
|
@@ -44,7 +77,19 @@ module Ace
|
|
|
44
77
|
ensure_checkout!
|
|
45
78
|
old = ref_value if old.nil?
|
|
46
79
|
sync_checkout(old)
|
|
47
|
-
|
|
80
|
+
if prompt_binding
|
|
81
|
+
verify_prompt_external_namespace!(prompt_binding.fetch("mutation_id"), parameters_digest,
|
|
82
|
+
assignment_id: assignment_id, attempt_id: attempt_id, commit: old)
|
|
83
|
+
end
|
|
84
|
+
verify_prompt_completion!(prompt_completion, mutation_id: mutation_id, digest: parameters_digest,
|
|
85
|
+
assignment_id: assignment_id, attempt_id: attempt_id, commit: old) if prompt_completion
|
|
86
|
+
verify_prompt_observation!(prompt_observation, mutation_id: mutation_id,
|
|
87
|
+
assignment_id: assignment_id, attempt_id: attempt_id, commit: old) if prompt_observation
|
|
88
|
+
verify_input_inhibition!(input_inhibition, mutation_id: mutation_id, digest: parameters_digest,
|
|
89
|
+
assignment_id: assignment_id, attempt_id: attempt_id, commit: old) if input_inhibition
|
|
90
|
+
verify_prompt_mutation_namespace!(mutation_id: mutation_id, operation: operation, parameters_digest: parameters_digest,
|
|
91
|
+
assignment_id: assignment_id, attempt_id: attempt_id, prompt_completion: prompt_completion, prompt_observation: prompt_observation, commit: old)
|
|
92
|
+
replay = mutation_result(mutation_id, commit: old)
|
|
48
93
|
if replay
|
|
49
94
|
unless replay["operation"] == operation && replay["parameters_digest"] == parameters_digest &&
|
|
50
95
|
replay["assignment_id"] == assignment_id && replay["attempt_id"] == attempt_id
|
|
@@ -53,16 +98,16 @@ module Ace
|
|
|
53
98
|
result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
|
|
54
99
|
return with_replay ? {data: result, replayed: true} : result
|
|
55
100
|
end
|
|
56
|
-
current = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
|
|
101
|
+
current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
|
|
57
102
|
generation = authority_generation(current)
|
|
58
|
-
unless
|
|
103
|
+
unless %i[recorded_completion prompt_completion prompt_observation input_inhibition].include?(generation_mode) || expected_generation == generation
|
|
59
104
|
raise AttemptErrors::Conflict, "Authority registration generation changed"
|
|
60
105
|
end
|
|
61
106
|
plan = yield(current, old, generation)
|
|
62
107
|
blobs = plan.fetch(:blobs, {})
|
|
63
108
|
events = chain_mutation_events(attempt_id, current.last&.fetch("digest"), plan.fetch(:events, []))
|
|
64
109
|
updates = prepare_service_updates(plan.fetch(:service_updates, []), assignment_id: assignment_id,
|
|
65
|
-
attempt_id: attempt_id, pending: {current_events: current, pending_events: events, blobs: blobs, commit: old,
|
|
110
|
+
attempt_id: attempt_id, pending: {current_events: current, pending_events: events, blobs: blobs, commit: old, operation: operation,
|
|
66
111
|
service_inputs: service_input_context(plan)})
|
|
67
112
|
events.concat(chain_mutation_events(attempt_id, events.last&.fetch("digest") || current.last&.fetch("digest"),
|
|
68
113
|
updates.map { |prepared| prepared.fetch(:event) }))
|
|
@@ -91,10 +136,10 @@ module Ace
|
|
|
91
136
|
end
|
|
92
137
|
end
|
|
93
138
|
|
|
94
|
-
def mutation_result(mutation_id)
|
|
139
|
+
def mutation_result(mutation_id, commit: ref_value)
|
|
95
140
|
validate_mutation_id!(mutation_id)
|
|
96
|
-
assignment_ids.each do |assignment_id|
|
|
97
|
-
events = read_events(assignment_id)
|
|
141
|
+
assignment_ids(commit: commit).each do |assignment_id|
|
|
142
|
+
events = read_events(assignment_id, commit: commit)
|
|
98
143
|
events.group_by { |event| event["attempt_id"] }.each_value do |chain|
|
|
99
144
|
unless Models::EvidenceEvent.chain_valid?(chain)
|
|
100
145
|
raise AttemptErrors::EvidenceUnavailable, "Authority mutation event chain is corrupt"
|
|
@@ -106,9 +151,9 @@ module Ace
|
|
|
106
151
|
next unless event
|
|
107
152
|
|
|
108
153
|
path = "execution/#{assignment_id}/events/#{event_filename(event)}"
|
|
109
|
-
|
|
110
|
-
raise AttemptErrors::EvidenceUnavailable, "Mutation acceptance commit is missing" if
|
|
111
|
-
return event.fetch("payload").merge("journal_commit" =>
|
|
154
|
+
acceptance_commit = git!("log", "--diff-filter=A", "-1", "--format=%H", commit, "--", path).first
|
|
155
|
+
raise AttemptErrors::EvidenceUnavailable, "Mutation acceptance commit is missing" if acceptance_commit.empty?
|
|
156
|
+
return event.fetch("payload").merge("journal_commit" => acceptance_commit)
|
|
112
157
|
end
|
|
113
158
|
nil
|
|
114
159
|
end
|
|
@@ -133,12 +178,36 @@ module Ace
|
|
|
133
178
|
def blob(path, commit: ref_value)
|
|
134
179
|
validate_blob_path!(path)
|
|
135
180
|
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence ref is missing" unless commit
|
|
181
|
+
return @read_boundary.blob(path, commit: commit, max_bytes: CanonicalEvidence::MAX_ARTIFACT_BYTES) if @read_boundary
|
|
136
182
|
out, _error, status = Open3.capture3("git", "show", "#{commit}:#{path}",
|
|
137
183
|
chdir: repo_root, stdin_data: "")
|
|
138
184
|
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence blob is missing" unless status.success?
|
|
139
185
|
out.b
|
|
140
186
|
end
|
|
141
187
|
|
|
188
|
+
# Prepared inputs use the same immutable Git owner, with a bounded
|
|
189
|
+
# capture before any bytes can reach a worker transport.
|
|
190
|
+
def bounded_blob(path, commit:, max_bytes:)
|
|
191
|
+
validate_blob_path!(path)
|
|
192
|
+
unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) &&
|
|
193
|
+
max_bytes.is_a?(Integer) && max_bytes.between?(1, 64 * 1024 * 1024)
|
|
194
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob selector differs"
|
|
195
|
+
end
|
|
196
|
+
return @read_boundary.blob(path, commit: commit, max_bytes: max_bytes) if @read_boundary
|
|
197
|
+
environment = ENV.keys.to_h { |key| [key, nil] }
|
|
198
|
+
environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
|
|
199
|
+
"GIT_TERMINAL_PROMPT" => "0", "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
|
|
200
|
+
result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repo_root,
|
|
201
|
+
"-c", "core.hooksPath=/dev/null", "cat-file", "blob", "#{commit}:#{path}"],
|
|
202
|
+
stdin_data: "", timeout_s: 30, output_limit: max_bytes)
|
|
203
|
+
unless result.status.success? && !result.oversized
|
|
204
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob is missing or oversized"
|
|
205
|
+
end
|
|
206
|
+
result.stdout.b
|
|
207
|
+
rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, SystemCallError, IOError
|
|
208
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob read is unavailable"
|
|
209
|
+
end
|
|
210
|
+
|
|
142
211
|
private
|
|
143
212
|
|
|
144
213
|
def validate_mutation_id!(id)
|
|
@@ -146,7 +215,7 @@ module Ace
|
|
|
146
215
|
end
|
|
147
216
|
|
|
148
217
|
def validate_blob_path!(path)
|
|
149
|
-
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
218
|
+
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
150
219
|
!%w[. ..].include?(File.basename(path))
|
|
151
220
|
raise ArgumentError, "invalid canonical blob reference"
|
|
152
221
|
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/herdr/molecules/guarded_native_origin"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module Molecules
|
|
7
|
+
# Durable N2 phases in the existing canonical journal, never an effect ledger.
|
|
8
|
+
module JournalPromptMutation
|
|
9
|
+
# Source-only phase: commit intent before the caller obtains any
|
|
10
|
+
# native dispatch permit. The scope owner reauthenticates inside this
|
|
11
|
+
# side-effect-free callback on EVERY journal CAS attempt.
|
|
12
|
+
def issue_prompt(binding:, issued_by:, &admission)
|
|
13
|
+
validate_prompt_binding!(binding)
|
|
14
|
+
unless issued_by.is_a?(Hash) && issued_by.keys.all? { |key| key.is_a?(String) } &&
|
|
15
|
+
issued_by.keys.sort == %w[gid groups host parent_pid pid started_at uid] &&
|
|
16
|
+
%w[uid gid groups].all? { |key| issued_by[key] == binding.fetch("caller").fetch(key) } &&
|
|
17
|
+
%w[pid parent_pid].all? { |key| issued_by[key].is_a?(Integer) && issued_by[key].positive? } &&
|
|
18
|
+
%w[host started_at].all? { |key| issued_by[key].is_a?(String) && issued_by[key].bytesize.between?(1, 255) }
|
|
19
|
+
raise ArgumentError, "invalid original prompt issuer evidence"
|
|
20
|
+
end
|
|
21
|
+
binding = JSON.parse(JSON.generate(binding))
|
|
22
|
+
issued_by = JSON.parse(JSON.generate(issued_by))
|
|
23
|
+
raise ArgumentError, "prompt admission callback is required" unless admission
|
|
24
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(binding)))
|
|
25
|
+
internal_id = "prompt-issue.#{digest}"
|
|
26
|
+
response = mutate(assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
27
|
+
mutation_id: internal_id, operation: "prompt_issue", parameters_digest: digest,
|
|
28
|
+
expected_generation: binding.fetch("expected_generation"), with_replay: true, prompt_binding: binding) do |events, commit, generation|
|
|
29
|
+
admission.call(events, commit, generation)
|
|
30
|
+
{events: [{type: "prompt_issued", payload: {"external_mutation_id" => binding.fetch("mutation_id"),
|
|
31
|
+
"binding_digest" => digest, "binding" => binding, "issued_by" => issued_by}}],
|
|
32
|
+
data: {"mutation_id" => binding.fetch("mutation_id"), "binding_digest" => digest, "intent_mutation_id" => internal_id}}
|
|
33
|
+
end
|
|
34
|
+
response
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fixed source-owned completion. Its selector authenticates the actual
|
|
38
|
+
# accepted issue on every CAS; the callback rejoins the original scope
|
|
39
|
+
# and guard without performing an effect. Replay keeps the FIRST public
|
|
40
|
+
# reply even if a later observation has a different outcome.
|
|
41
|
+
def finalize_prompt(mutation_id:, intent_event_id:, binding_digest:, evidence:, &authentication)
|
|
42
|
+
raise ArgumentError, "prompt completion authentication is required" unless authentication
|
|
43
|
+
intent = prompt_intent(mutation_id)
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt issue is unavailable" unless intent
|
|
45
|
+
binding = intent.fetch("payload").fetch("binding")
|
|
46
|
+
selector = {"intent_event_id" => intent_event_id, "binding_digest" => binding_digest}
|
|
47
|
+
mutate(assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
48
|
+
mutation_id: mutation_id, operation: "prompt_attempt", parameters_digest: binding_digest,
|
|
49
|
+
expected_generation: nil, generation_mode: :prompt_completion, prompt_completion: selector, with_replay: true) do |events, commit, generation|
|
|
50
|
+
if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
51
|
+
raise AttemptErrors::EvidenceUnavailable, "Released prompt scope cannot accept new outcome"
|
|
52
|
+
end
|
|
53
|
+
original = authentication.call(events, commit, generation)
|
|
54
|
+
unless original.is_a?(Hash) && original.keys.sort == %w[binding_digest origin] &&
|
|
55
|
+
original["binding_digest"] == binding.fetch("original_binding_digest")
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "Original prompt scope binding differs"
|
|
57
|
+
end
|
|
58
|
+
validate_prompt_evidence!(evidence, original.fetch("origin"))
|
|
59
|
+
{events: [{type: "prompt_outcome", payload: {"external_mutation_id" => mutation_id,
|
|
60
|
+
"intent_event_id" => intent_event_id, "binding_digest" => binding_digest, "evidence" => evidence}}],
|
|
61
|
+
data: {"mutation_id" => mutation_id, "attempt_id" => binding.fetch("attempt_id"),
|
|
62
|
+
"intent_event_id" => intent_event_id, "outcome" => evidence.fetch("outcome"), "guarded_evidence" => evidence}}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_prompt_completion!(selector, mutation_id:, digest:, assignment_id:, attempt_id:, commit:)
|
|
67
|
+
unless selector.is_a?(Hash) && selector.keys.all? { |key| key.is_a?(String) } && selector.keys.sort == %w[binding_digest intent_event_id] &&
|
|
68
|
+
selector.values.all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) }
|
|
69
|
+
raise ArgumentError, "invalid fixed prompt completion selector"
|
|
70
|
+
end
|
|
71
|
+
intent = prompt_intent(mutation_id, commit: commit)
|
|
72
|
+
unless intent && intent["digest"] == selector.fetch("intent_event_id") &&
|
|
73
|
+
intent.dig("payload", "binding_digest") == digest && selector.fetch("binding_digest") == digest &&
|
|
74
|
+
intent.dig("payload", "binding", "assignment_id") == assignment_id && intent["attempt_id"] == attempt_id
|
|
75
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt completion does not join accepted issue"
|
|
76
|
+
end
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
private :verify_prompt_completion!
|
|
80
|
+
|
|
81
|
+
# Later authenticated native completion is a separate canonical fact.
|
|
82
|
+
# It never replaces the first immutable public prompt reply.
|
|
83
|
+
def observe_prompt_completion(mutation_id:, intent_event_id:, binding_digest:, evidence:, &authentication)
|
|
84
|
+
raise ArgumentError, "prompt observation authentication is required" unless authentication
|
|
85
|
+
intent = prompt_intent(mutation_id)
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt issue is unavailable" unless intent
|
|
87
|
+
binding = intent.fetch("payload").fetch("binding")
|
|
88
|
+
selector = {"external_mutation_id" => mutation_id, "intent_event_id" => intent_event_id, "binding_digest" => binding_digest}
|
|
89
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(selector.merge("evidence" => evidence))))
|
|
90
|
+
mutate(assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
91
|
+
mutation_id: "prompt-observe.#{intent_event_id}", operation: "prompt_observation", parameters_digest: digest,
|
|
92
|
+
expected_generation: nil, generation_mode: :prompt_observation, prompt_observation: selector, with_replay: true) do |events, commit, generation|
|
|
93
|
+
if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
94
|
+
raise AttemptErrors::EvidenceUnavailable, "Released prompt scope cannot accept new observation"
|
|
95
|
+
end
|
|
96
|
+
original = authentication.call(events, commit, generation)
|
|
97
|
+
unless original.is_a?(Hash) && original.keys.sort == %w[binding_digest origin] && original["binding_digest"] == binding.fetch("original_binding_digest")
|
|
98
|
+
raise AttemptErrors::EvidenceUnavailable, "Original prompt scope binding differs"
|
|
99
|
+
end
|
|
100
|
+
validate_prompt_evidence!(evidence, original.fetch("origin"))
|
|
101
|
+
unless %w[submitted not_issued].include?(evidence.fetch("outcome"))
|
|
102
|
+
raise AttemptErrors::EvidenceUnavailable, "Unknown native outcome is not completed evidence"
|
|
103
|
+
end
|
|
104
|
+
{events: [{type: "prompt_completion_observed", payload: selector.merge("evidence" => evidence)}],
|
|
105
|
+
data: selector.merge("outcome" => evidence.fetch("outcome"), "guarded_evidence" => evidence)}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verify_prompt_observation!(selector, mutation_id:, assignment_id:, attempt_id:, commit:)
|
|
110
|
+
unless selector.is_a?(Hash) && selector.keys.sort == %w[binding_digest external_mutation_id intent_event_id] &&
|
|
111
|
+
mutation_id == "prompt-observe.#{selector["intent_event_id"]}"
|
|
112
|
+
raise ArgumentError, "invalid fixed prompt observation selector"
|
|
113
|
+
end
|
|
114
|
+
verify_prompt_completion!(selector.slice("intent_event_id", "binding_digest"),
|
|
115
|
+
mutation_id: selector.fetch("external_mutation_id"), digest: selector.fetch("binding_digest"),
|
|
116
|
+
assignment_id: assignment_id, attempt_id: attempt_id, commit: commit)
|
|
117
|
+
end
|
|
118
|
+
private :verify_prompt_observation!
|
|
119
|
+
|
|
120
|
+
def validate_prompt_evidence!(evidence, original)
|
|
121
|
+
unless original.is_a?(Hash) && evidence.is_a?(Hash) && evidence.keys.all? { |key| key.is_a?(String) } && evidence["origin"] == original
|
|
122
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt outcome original guard differs"
|
|
123
|
+
end
|
|
124
|
+
Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(original, terminal_id: original.fetch("terminal_id"), child: original.fetch("child"))
|
|
125
|
+
Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(evidence.fetch("origin"), terminal_id: original.fetch("terminal_id"), child: original.fetch("child"))
|
|
126
|
+
case evidence["outcome"]
|
|
127
|
+
when "submitted"
|
|
128
|
+
valid = evidence.keys.sort == %w[origin outcome submission] && evidence["submission"] == "submitted"
|
|
129
|
+
when "not_issued"
|
|
130
|
+
valid = evidence.keys.sort == %w[code origin outcome phase] && evidence["phase"] == "not_issued" &&
|
|
131
|
+
%w[guard_mismatch origin_unavailable origin_exited agent_blocked agent_not_ready target_missing submission_busy].include?(evidence["code"])
|
|
132
|
+
when "uncertain"
|
|
133
|
+
valid = evidence.keys.sort == %w[origin outcome]
|
|
134
|
+
end
|
|
135
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt outcome is malformed" unless valid
|
|
136
|
+
true
|
|
137
|
+
rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
|
|
138
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt outcome original guard is malformed"
|
|
139
|
+
end
|
|
140
|
+
private :validate_prompt_evidence!
|
|
141
|
+
|
|
142
|
+
# A read-only selected canonical chain lookup, never a dispatch permit.
|
|
143
|
+
def prompt_intent(mutation_id, commit: ref_value)
|
|
144
|
+
validate_mutation_id!(mutation_id)
|
|
145
|
+
found = nil
|
|
146
|
+
assignment_ids(commit: commit).each do |assignment_id|
|
|
147
|
+
events = read_events(assignment_id, commit: commit)
|
|
148
|
+
events.group_by { |event| event.fetch("attempt_id") }.each_value do |chain|
|
|
149
|
+
unless Models::EvidenceEvent.chain_valid?(chain)
|
|
150
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt intent event chain is corrupt"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
events.each do |event|
|
|
154
|
+
next unless event["type"] == "prompt_issued" && event.dig("payload", "external_mutation_id") == mutation_id
|
|
155
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt intent is ambiguous" if found
|
|
156
|
+
payload = event.fetch("payload")
|
|
157
|
+
validate_prompt_binding!(payload.fetch("binding"))
|
|
158
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(payload.fetch("binding"))))
|
|
159
|
+
binding = payload.fetch("binding")
|
|
160
|
+
acceptance = events.find { |entry| entry["type"] == "authority_mutation" &&
|
|
161
|
+
entry.dig("payload", "mutation_id") == "prompt-issue.#{digest}" &&
|
|
162
|
+
entry.dig("payload", "operation") == "prompt_issue" &&
|
|
163
|
+
entry.dig("payload", "parameters_digest") == digest && entry["previous_digest"] == event["digest"] }
|
|
164
|
+
unless acceptance && payload["binding_digest"] == digest && binding["mutation_id"] == mutation_id &&
|
|
165
|
+
binding["assignment_id"] == assignment_id && binding["attempt_id"] == event["attempt_id"]
|
|
166
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt intent acceptance binding differs"
|
|
167
|
+
end
|
|
168
|
+
found = event
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
found
|
|
172
|
+
rescue KeyError, TypeError, ArgumentError
|
|
173
|
+
raise AttemptErrors::EvidenceUnavailable, "Prompt intent is malformed"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_prompt_binding!(binding)
|
|
177
|
+
fields = %w[action assignment_id attempt_id caller expected_generation mapping_id mutation_id original_binding_digest project_id text_bytes text_sha256]
|
|
178
|
+
unless binding.is_a?(Hash) && binding.keys.all? { |key| key.is_a?(String) } && binding.keys.sort == fields &&
|
|
179
|
+
binding["action"] == "prompt_attempt" && binding["expected_generation"].is_a?(Integer) && binding["expected_generation"] >= 0 &&
|
|
180
|
+
binding["text_bytes"].is_a?(Integer) && binding["text_bytes"].between?(1, 16_384) &&
|
|
181
|
+
%w[text_sha256 original_binding_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) }
|
|
182
|
+
raise ArgumentError, "invalid fixed prompt binding"
|
|
183
|
+
end
|
|
184
|
+
%w[assignment_id attempt_id mapping_id mutation_id project_id].each { |key| validate_mutation_id!(binding.fetch(key)) }
|
|
185
|
+
if binding.fetch("mutation_id").start_with?("prompt-issue.", "prompt-observe.", "input-inhibit.")
|
|
186
|
+
raise ArgumentError, "public mutation ID uses reserved prompt namespace"
|
|
187
|
+
end
|
|
188
|
+
caller = binding.fetch("caller")
|
|
189
|
+
unless caller.is_a?(Hash) && caller.keys.all? { |key| key.is_a?(String) } && caller.keys.sort == %w[gid groups role uid] &&
|
|
190
|
+
%w[launcher supervisor].include?(caller["role"]) &&
|
|
191
|
+
%w[uid gid].all? { |key| caller[key].is_a?(Integer) && caller[key].between?(0, 0xffff_ffff) } &&
|
|
192
|
+
caller["groups"].is_a?(Array) && caller["groups"].size <= 256 &&
|
|
193
|
+
caller["groups"].all? { |group| group.is_a?(Integer) && group.between?(0, 0xffff_ffff) } &&
|
|
194
|
+
caller["groups"] == caller["groups"].sort.uniq
|
|
195
|
+
raise ArgumentError, "invalid authenticated stable prompt principal"
|
|
196
|
+
end
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
private :validate_prompt_binding!
|
|
200
|
+
|
|
201
|
+
def canonical_prompt_value(value)
|
|
202
|
+
case value
|
|
203
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical_prompt_value(value.fetch(key))] }
|
|
204
|
+
when Array then value.map { |item| canonical_prompt_value(item) }
|
|
205
|
+
else value
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
private :canonical_prompt_value
|
|
209
|
+
|
|
210
|
+
def verify_prompt_external_namespace!(external_id, digest, assignment_id:, attempt_id:, commit:)
|
|
211
|
+
intent = prompt_intent(external_id, commit: commit)
|
|
212
|
+
accepted = mutation_result(external_id, commit: commit)
|
|
213
|
+
if accepted || (intent && (intent.dig("payload", "binding_digest") != digest ||
|
|
214
|
+
intent.dig("payload", "binding", "assignment_id") != assignment_id || intent["attempt_id"] != attempt_id))
|
|
215
|
+
raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
private :verify_prompt_external_namespace!
|
|
219
|
+
|
|
220
|
+
def verify_prompt_mutation_namespace!(mutation_id:, operation:, parameters_digest:, assignment_id:, attempt_id:, prompt_completion:, prompt_observation: nil, commit:)
|
|
221
|
+
# Ordinary mutations cannot steal an external ID while its accepted
|
|
222
|
+
# prompt issue has no finalized public reply yet.
|
|
223
|
+
if mutation_id.start_with?("prompt-issue.")
|
|
224
|
+
raise ArgumentError, "reserved internal prompt namespace" unless operation == "prompt_issue"
|
|
225
|
+
return
|
|
226
|
+
end
|
|
227
|
+
if mutation_id.start_with?("prompt-observe.")
|
|
228
|
+
raise ArgumentError, "reserved internal prompt observation namespace" unless operation == "prompt_observation" && prompt_observation
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
return if operation == "prompt_attempt" && prompt_completion
|
|
232
|
+
if prompt_intent(mutation_id, commit: commit)
|
|
233
|
+
raise AttemptErrors::Conflict, "Mutation ID is reserved by accepted prompt intent"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
private :verify_prompt_mutation_namespace!
|
|
237
|
+
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|