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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. 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
- exact_fields!(value, PROCESS_FIELDS)
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
- prompt = "/as-assign-drive #{scoped_assignment}"
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 == "complete_service" && expected_generation.nil?)
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
- replay = mutation_result(mutation_id)
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 generation_mode == :recorded_completion || expected_generation == generation
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
- commit = git!("log", "--diff-filter=A", "-1", "--format=%H", ref_value, "--", path).first
110
- raise AttemptErrors::EvidenceUnavailable, "Mutation acceptance commit is missing" if commit.empty?
111
- return event.fetch("payload").merge("journal_commit" => 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