ace-assign 0.63.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "ace/herdr/molecules/bounded_process"
6
+ require_relative "journal_prompt_mutation"
7
+ require_relative "journal_input_inhibition"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Molecules
12
+ # Protected authority mutations share the execution ref's lock and CAS.
13
+ # Replies live in chained attempt events, rather than an RPC ledger.
14
+ module JournalMutation
15
+ include JournalPromptMutation
16
+ include JournalInputInhibition
17
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
18
+
19
+ # Shared canonical projection used by admission and protected status.
20
+ # Callers hold the same owner exclusion when projecting current events.
21
+ def authority_generation(events)
22
+ unless Models::EvidenceEvent.chain_valid?(events)
23
+ raise AttemptErrors::EvidenceUnavailable, "Attempt event chain is corrupt"
24
+ end
25
+ events.count { |event| event["type"] == "authority_mutation" }
26
+ end
27
+
28
+ def canonical_attempt_state(events)
29
+ authority_generation(events)
30
+ derive_state(events)
31
+ end
32
+
33
+ # The block runs against the current ref on every CAS attempt. It
34
+ # returns events [{type:, payload:}], immutable blobs, and public data.
35
+ # Only the authority calls this internal journal API; wire requests
36
+ # cannot select blob paths, event types, or accepted response data.
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)
38
+ unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
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))
43
+ raise ArgumentError, "invalid fixed mutation generation mode"
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
70
+ [assignment_id, attempt_id, mutation_id].each { |id| validate_mutation_id!(id) }
71
+ unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
72
+ raise ArgumentError, "invalid mutation parameter digest"
73
+ end
74
+ with_lock do
75
+ self.class::CAS_ATTEMPTS.times do
76
+ old = ref_value
77
+ ensure_checkout!
78
+ old = ref_value if old.nil?
79
+ sync_checkout(old)
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)
93
+ if replay
94
+ unless replay["operation"] == operation && replay["parameters_digest"] == parameters_digest &&
95
+ replay["assignment_id"] == assignment_id && replay["attempt_id"] == attempt_id
96
+ raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
97
+ end
98
+ result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
99
+ return with_replay ? {data: result, replayed: true} : result
100
+ end
101
+ current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
102
+ generation = authority_generation(current)
103
+ unless %i[recorded_completion prompt_completion prompt_observation input_inhibition].include?(generation_mode) || expected_generation == generation
104
+ raise AttemptErrors::Conflict, "Authority registration generation changed"
105
+ end
106
+ plan = yield(current, old, generation)
107
+ blobs = plan.fetch(:blobs, {})
108
+ events = chain_mutation_events(attempt_id, current.last&.fetch("digest"), plan.fetch(:events, []))
109
+ updates = prepare_service_updates(plan.fetch(:service_updates, []), assignment_id: assignment_id,
110
+ attempt_id: attempt_id, pending: {current_events: current, pending_events: events, blobs: blobs, commit: old, operation: operation,
111
+ service_inputs: service_input_context(plan)})
112
+ events.concat(chain_mutation_events(attempt_id, events.last&.fetch("digest") || current.last&.fetch("digest"),
113
+ updates.map { |prepared| prepared.fetch(:event) }))
114
+ data = plan.fetch(:data).merge("generation" => generation + 1)
115
+ receipt = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: attempt_id,
116
+ previous_digest: events.last&.fetch("digest") || current.last&.fetch("digest"),
117
+ payload: {"mutation_id" => mutation_id, "operation" => operation,
118
+ "parameters_digest" => parameters_digest, "assignment_id" => assignment_id,
119
+ "attempt_id" => attempt_id, "data" => data})
120
+ events << receipt
121
+ write_mutation_blobs(blobs, commit: old)
122
+ write_service_records(updates)
123
+ write_event_files(assignment_id, events)
124
+ git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}")
125
+ stage_mutation_blobs(blobs)
126
+ stage_service_records(updates)
127
+ git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
128
+ "-c", "core.hooksPath=/dev/null", "commit", "-m", "evidence: authority #{operation} #{mutation_id}")
129
+ commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
130
+ if update_ref_cas(commit, old)
131
+ result = data.merge("journal_commit" => commit)
132
+ return with_replay ? {data: result, replayed: false} : result
133
+ end
134
+ end
135
+ raise AttemptErrors::EvidenceUnavailable, "Authority mutation ref stayed conflicting"
136
+ end
137
+ end
138
+
139
+ def mutation_result(mutation_id, commit: ref_value)
140
+ validate_mutation_id!(mutation_id)
141
+ assignment_ids(commit: commit).each do |assignment_id|
142
+ events = read_events(assignment_id, commit: commit)
143
+ events.group_by { |event| event["attempt_id"] }.each_value do |chain|
144
+ unless Models::EvidenceEvent.chain_valid?(chain)
145
+ raise AttemptErrors::EvidenceUnavailable, "Authority mutation event chain is corrupt"
146
+ end
147
+ end
148
+ event = events.find do |entry|
149
+ entry["type"] == "authority_mutation" && entry.dig("payload", "mutation_id") == mutation_id
150
+ end
151
+ next unless event
152
+
153
+ path = "execution/#{assignment_id}/events/#{event_filename(event)}"
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)
157
+ end
158
+ nil
159
+ end
160
+
161
+ # Source composition supplies original body bytes only for the current
162
+ # service CAS callback. They are never part of persisted mutation data.
163
+ def service_input_context(plan)
164
+ inputs = plan.fetch(:service_inputs, {})
165
+ raise ArgumentError, "invalid ephemeral service input context" unless inputs.is_a?(Hash)
166
+ return {}.freeze if inputs.empty?
167
+ updates = plan.fetch(:service_updates, [])
168
+ unless inputs.size == 1 && updates.size == 1 && inputs.keys == [updates.first.fetch(:request_id)] &&
169
+ inputs.values.first.is_a?(String) && inputs.values.first.bytesize.between?(1, 64 * 1024)
170
+ raise ArgumentError, "invalid ephemeral service input context"
171
+ end
172
+ inputs.to_h { |key, bytes| [key.dup.freeze, bytes.dup.freeze] }.freeze
173
+ end
174
+ private :service_input_context
175
+
176
+ # Read immutable bytes directly from a selected canonical ref commit.
177
+ # Worktree projections are never consulted for protected evidence.
178
+ def blob(path, commit: ref_value)
179
+ validate_blob_path!(path)
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
182
+ out, _error, status = Open3.capture3("git", "show", "#{commit}:#{path}",
183
+ chdir: repo_root, stdin_data: "")
184
+ raise AttemptErrors::EvidenceUnavailable, "Canonical evidence blob is missing" unless status.success?
185
+ out.b
186
+ end
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
+
211
+ private
212
+
213
+ def validate_mutation_id!(id)
214
+ raise ArgumentError, "invalid authority ID" unless id.is_a?(String) && id.match?(ID)
215
+ end
216
+
217
+ def validate_blob_path!(path)
218
+ unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
219
+ !%w[. ..].include?(File.basename(path))
220
+ raise ArgumentError, "invalid canonical blob reference"
221
+ end
222
+ end
223
+
224
+ def chain_mutation_events(attempt_id, previous, entries)
225
+ entries.map do |entry|
226
+ recorded_at = entry.fetch(:recorded_at) { Time.now.utc }
227
+ raise ArgumentError, "internal event timestamp must be a UTC Time" unless recorded_at.is_a?(Time) && recorded_at.utc?
228
+ event = Models::EvidenceEvent.build(type: entry.fetch(:type), attempt_id: attempt_id,
229
+ payload: entry.fetch(:payload), previous_digest: previous, recorded_at: recorded_at)
230
+ previous = event.fetch("digest")
231
+ event
232
+ end
233
+ end
234
+
235
+ # Git clean/smudge filters and CRLF conversion cannot redefine accepted
236
+ # evidence bytes. Insert unfiltered objects after staging the events.
237
+ def stage_mutation_blobs(blobs)
238
+ blobs.each do |path, bytes|
239
+ oid, error, status = Open3.capture3("git", "hash-object", "-w", "--stdin", "--no-filters",
240
+ chdir: repo_root, stdin_data: bytes.b)
241
+ unless status.success?
242
+ raise AttemptErrors::EvidenceUnavailable, "Cannot write canonical evidence object: #{error.strip}"
243
+ end
244
+ git!("-C", checkout_dir, "update-index", "--add", "--cacheinfo", "100644,#{oid.strip},#{path}")
245
+ end
246
+ end
247
+
248
+ def write_mutation_blobs(blobs, commit:)
249
+ blobs.map do |path, bytes|
250
+ validate_blob_path!(path)
251
+ raise ArgumentError, "canonical blob must contain bytes" unless bytes.is_a?(String)
252
+ target = File.join(checkout_dir, path)
253
+ if File.exist?(target)
254
+ # A checkout may have smudged the file; immutability is checked
255
+ # against accepted bytes at the canonical commit, not projection.
256
+ unless blob(path, commit: commit) == bytes.b
257
+ raise AttemptErrors::Conflict, "Canonical evidence blob is immutable"
258
+ end
259
+ else
260
+ FileUtils.mkdir_p(File.dirname(target))
261
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
262
+ file.write(bytes.b)
263
+ end
264
+ end
265
+ path
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ 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