ace-herdr 0.3.2 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +30 -0
  4. data/docs/usage.md +65 -32
  5. data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli/commands/support.rb +2 -6
  8. data/lib/ace/herdr/cli.rb +3 -0
  9. data/lib/ace/herdr/errors.rb +23 -0
  10. data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
  11. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
  12. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  13. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  14. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  15. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  16. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  17. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  18. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  19. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  20. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  21. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  22. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  24. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  25. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  26. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  27. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  28. data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
  29. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  30. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  31. data/lib/ace/herdr/native_source/selection.json +24 -0
  32. data/lib/ace/herdr/organisms/deliverer.rb +2 -3
  33. data/lib/ace/herdr/organisms/inbox.rb +384 -137
  34. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  35. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  36. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  37. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  38. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  39. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  40. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  41. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  42. data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
  43. data/lib/ace/herdr/version.rb +1 -1
  44. data/lib/ace/herdr.rb +5 -28
  45. metadata +69 -8
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require "digest"
4
5
  require "json"
5
6
  require "time"
6
7
  require "securerandom"
7
- require "openssl"
8
8
  require "ace/hitl/contract"
9
+ require_relative "../molecules/native_queue_executor"
10
+ require_relative "inbox_guarded_wake"
11
+ require_relative "../molecules/inbox_direct_effect_binding"
9
12
 
10
13
  module Ace
11
14
  module Herdr
@@ -13,9 +16,12 @@ module Ace
13
16
  # Durable event inbox. All transitions for one event are serialized by
14
17
  # DeliveryRecordStore; a saved submission intent is never replayed.
15
18
  class Inbox
19
+ include InboxGuardedWake
16
20
  class IdentityDriftError < ValidationError; end
21
+ class MissingEventError < ValidationError; end
17
22
 
18
23
  EVENT = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
24
+ TARGET_IDENTITY = %w[session pane terminal_id agent thread thread_kind].freeze
19
25
  THREAD_ID = /\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/
20
26
  PI_PATH = /_([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl\z/
21
27
  # Single validator for Pi queue event ids, shared by enqueue and
@@ -28,17 +34,148 @@ module Ace
28
34
  AGENT_STATUSES = %w[idle busy working blocked done].freeze
29
35
  WAKE_STATUSES = %w[idle done].freeze
30
36
 
31
- def initialize(executor:, native:, deliveries_dir:, receipt_public_key: nil)
37
+ # The maintained CLI uses the configured durable delivery store.
38
+ def self.from_config(config: Ace::Herdr.config, root: Dir.pwd,
39
+ executor: Molecules::HerdrExecutor.new, native: Molecules::NativeQueueExecutor.new)
40
+ new(executor: executor, native: native,
41
+ deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root))
42
+ end
43
+
44
+ # Enumerate retained event identities through the same store/lock owner.
45
+ # Unreadable unattributable records cannot be treated as another attempt.
46
+ def self.retained_events(deliveries_dir:, attempt:)
47
+ raise ValidationError, "invalid attempt id" unless attempt.is_a?(String) && EVENT.match?(attempt)
48
+ retained_records(deliveries_dir: deliveries_dir).filter_map do |record|
49
+ record.event_id if record.inbox.fetch("attempt_id") == attempt
50
+ end
51
+ end
52
+
53
+ # The same existing per-event locks remain held through the consumer's
54
+ # complete validation and retirement transaction, not just enumeration.
55
+ def self.with_retained_records(deliveries_dir:, deadline: nil, &block)
56
+ stat = File.lstat(deliveries_dir)
57
+ raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
58
+ # An empty retained store needs the coordination lock too. Creating
59
+ # only this lock never creates a missing context or a delivery record.
60
+ Molecules::DeliveryRecordStore.with_inventory_lock(deliveries_dir, exclusive: true, create: true, prepare_directory: false, deadline: deadline) do
61
+ with_retained_records_held(deliveries_dir: deliveries_dir, deadline: deadline, &block)
62
+ end
63
+ rescue SystemCallError
64
+ raise ValidationError, "retained inbox inventory is unavailable"
65
+ end
66
+
67
+ def self.with_retained_records_held(deliveries_dir:, deadline: nil, &block)
68
+ raise ArgumentError, "retained inbox block is required" unless block
69
+ roots = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)]
70
+ names = lambda do
71
+ roots.flat_map do |dir|
72
+ next [] if dir != deliveries_dir && !File.exist?(dir) && !File.symlink?(dir)
73
+ stat = File.lstat(dir)
74
+ raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
75
+ Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
76
+ end.uniq.sort
77
+ end
78
+ ids = names.call
79
+ raise ValidationError, "retained inbox inventory exceeds bounds" if ids.size > 100_000
80
+ held = Thread.current[:ace_herdr_retained_inventory_locks] ||= {}
81
+ raise ValidationError, "retained inbox inventory already held" if held.key?(deliveries_dir)
82
+ raise ValidationError, "retained inbox event id is invalid" unless ids.all? { |event| EVENT.match?(event) }
83
+ Molecules::DeliveryRecordStore.with_locks(deliveries_dir, ids, create: false, deadline: deadline) do
84
+ raise ValidationError, "retained inbox inventory changed" unless names.call == ids
85
+ held[deliveries_dir] = ids.freeze
86
+ begin
87
+ result = block.call(retained_records(deliveries_dir: deliveries_dir))
88
+ raise ValidationError, "retained inbox inventory changed" unless names.call == ids
89
+ result
90
+ ensure
91
+ held.delete(deliveries_dir)
92
+ end
93
+ end
94
+ rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
95
+ raise ValidationError, "retained inbox inventory is unavailable"
96
+ end
97
+ private_class_method :with_retained_records_held
98
+
99
+ # Exhaustive live and archived inventory: no attempt filter may hide an
100
+ # unknown record, and live precedence cannot hide conflicting copies.
101
+ def self.retained_records(deliveries_dir:)
102
+ directories = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)]
103
+ inventory = lambda do
104
+ directories.flat_map do |dir|
105
+ next [] if dir != deliveries_dir && !File.exist?(dir) && !File.symlink?(dir)
106
+ stat = File.lstat(dir)
107
+ raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
108
+ Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
109
+ end.uniq.sort
110
+ end
111
+ ids = inventory.call
112
+ raise ValidationError, "retained inbox inventory exceeds bounds" if ids.size > 100_000
113
+ records = ids.map do |event|
114
+ raise ValidationError, "retained inbox event id is invalid" unless EVENT.match?(event)
115
+ read_record = lambda do
116
+ copies = directories.filter_map do |dir|
117
+ path = Molecules::DeliveryRecordStore.path_for(dir, event)
118
+ next unless File.exist?(path) || File.symlink?(path)
119
+ before = File.lstat(path)
120
+ raise ValidationError, "retained inbox record is unsafe" unless before.file? && !before.symlink? && before.size.between?(1, 1_048_576)
121
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle|
122
+ snapshot = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
123
+ raise ValidationError, "retained inbox record changed" unless snapshot.call(before) == snapshot.call(handle.stat)
124
+ bytes = handle.read(before.size + 1)
125
+ raise ValidationError, "retained inbox record changed" unless bytes.bytesize == before.size &&
126
+ snapshot.call(before) == snapshot.call(handle.stat) && snapshot.call(before) == snapshot.call(File.lstat(path))
127
+ value = JSON.parse(bytes.dup.force_encoding(Encoding::UTF_8), create_additions: false,
128
+ max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
129
+ record = Models::DeliveryRecord.from_h(value)
130
+ unless record.event_id == event && record.inbox && record.inbox.fetch("attempt_id").is_a?(String) &&
131
+ EVENT.match?(record.inbox.fetch("attempt_id"))
132
+ raise ValidationError, "retained inbox event is unavailable"
133
+ end
134
+ record
135
+ end
136
+ end
137
+ if copies.empty? || copies.map(&:to_h).uniq.length != 1
138
+ raise ValidationError, "retained inbox copies conflict"
139
+ end
140
+ copies.first
141
+ end
142
+ if Thread.current[:ace_herdr_retained_inventory_locks]&.dig(deliveries_dir)&.include?(event)
143
+ read_record.call
144
+ else
145
+ Molecules::DeliveryRecordStore.with_lock(deliveries_dir, event, create: false, &read_record)
146
+ end
147
+ end
148
+ raise ValidationError, "retained inbox inventory changed" unless inventory.call == ids
149
+ records.freeze
150
+ rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
151
+ raise ValidationError, "retained inbox inventory is unavailable"
152
+ end
153
+
154
+ def initialize(executor:, deliveries_dir:, native: Molecules::NativeQueueExecutor.new)
32
155
  @executor = executor
33
156
  @native = native
34
157
  @deliveries_dir = deliveries_dir
35
- @receipt_public_key = receipt_public_key
36
158
  end
37
159
 
38
- def enqueue(event:, attempt:, ref:, payload:)
160
+ # Project the accepted runtime owner before consuming an answer. Native
161
+ # observation may verify this identity, but cannot choose its replacement.
162
+ def target_from_owner(owner)
163
+ unless owner.is_a?(Hash) && owner["runtime"] == "herdr" &&
164
+ owner["agent_session"].is_a?(Hash) && owner.dig("agent_session", "agent") == owner["agent"]
165
+ raise ValidationError, "accepted native owner is unavailable"
166
+ end
167
+ target = owner.slice("session", "pane", "terminal_id", "agent").merge(
168
+ "thread" => owner.dig("agent_session", "value"), "thread_kind" => owner.dig("agent_session", "kind"))
169
+ validate_expected_target!(target)
170
+ target.transform_values { |value| value.dup.freeze }.freeze
171
+ end
172
+
173
+ def context_root = @deliveries_dir.dup.freeze
174
+
175
+
176
+ def enqueue(event:, attempt:, ref:, payload:, managed_envelope: nil, expected_target: nil, original_context: nil, original_binding_digest: nil)
39
177
  validate_id!(event, "event")
40
178
  validate_id!(attempt, "attempt")
41
- raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
42
179
  payload = payload.to_s
43
180
  raise ValidationError, "payload is required" if payload.empty?
44
181
  raise ValidationError, "payload contains NUL" if payload.include?("\0")
@@ -49,15 +186,51 @@ module Ace
49
186
  raise ValidationError, "payload is not valid UTF-8 text"
50
187
  end
51
188
 
189
+ if original_context
190
+ Molecules::InboxDirectEffectBinding.original!(original_context)
191
+ unless original_binding_digest.is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(original_binding_digest)
192
+ raise ValidationError, "original launch record digest differs"
193
+ end
194
+ elsif original_binding_digest
195
+ raise ValidationError, "original digest requires its context association"
196
+ end
52
197
  address = address_for(ref)
53
198
  digest = Digest::SHA256.hexdigest(payload)
199
+ envelope = if managed_envelope
200
+ Ace::Hitl::Contract::ManagedEnvelope.load(managed_envelope, expected: {
201
+ attempt_id: attempt, payload_sha256: digest,
202
+ reverse: {"schema" => Ace::Hitl::Providers::Ref::SCHEMA, "session" => address.session, "pane" => address.pane}
203
+ })
204
+ end
205
+ if envelope && Ace::Hitl::Contract::SecretGate::PATTERN.match?(payload)
206
+ raise ValidationError, "secret-bearing managed native answers are prohibited"
207
+ end
208
+ if envelope && expected_target.nil?
209
+ raise ValidationError, "managed native delivery requires the accepted original target"
210
+ end
211
+ unless expected_target.nil?
212
+ validate_expected_target!(expected_target)
213
+ expected_target = expected_target.slice(*TARGET_IDENTITY)
214
+ unless expected_target.values_at("session", "pane") == [address.session, address.pane]
215
+ raise ValidationError, "accepted original target differs from reverse address"
216
+ end
217
+ end
54
218
  with_event(event) do |record|
55
219
  if record
56
220
  validate_match!(record, attempt, address, digest)
221
+ unless record.inbox["managed_envelope"] == envelope && record.inbox["original_context"] == original_context && record.inbox["original_binding_digest"] == original_binding_digest
222
+ raise ValidationError, "managed envelope conflicts with immutable inbox event"
223
+ end
224
+ if expected_target && record.inbox["origin_target"] != expected_target
225
+ raise IdentityDriftError, "original native target conflicts with immutable inbox event"
226
+ end
57
227
  next public_record(record)
58
228
  end
59
229
 
60
- target = observe_target(address.session, address.pane)
230
+ target = observe_target(address.session, address.pane, target: expected_target)
231
+ if expected_target && !TARGET_IDENTITY.all? { |key| target[key] == expected_target[key] }
232
+ raise IdentityDriftError, "native target differs from accepted original owner"
233
+ end
61
234
  if target["agent"] == "pi" && !PI_EVENT_ID.match?(event)
62
235
  raise ValidationError,
63
236
  "Pi queue requires an inbox (inb-) or wake (wnk-) event id with at " \
@@ -65,7 +238,7 @@ module Ace
65
238
  end
66
239
  payload_limit = target["agent"] == "pi" ?
67
240
  Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
68
- Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
241
+ Molecules::NativeQueueExecutor::CODEX_PAYLOAD_LIMIT_BYTES
69
242
  if payload.bytesize > payload_limit
70
243
  raise ValidationError,
71
244
  "payload exceeds the #{target['agent']} native queue limit " \
@@ -75,30 +248,170 @@ module Ace
75
248
  event_id: event, session: address.session, pane: address.pane,
76
249
  answer_digest: digest, answer: payload, state: "queued",
77
250
  inbox: {"attempt_id" => attempt, "claim_generation" => 0,
78
- "receipt_key_sha256" => key_fingerprint,
251
+ "managed_envelope" => envelope, "original_context" => original_context, "original_binding_digest" => original_binding_digest,
252
+ "origin_target" => target.slice(*TARGET_IDENTITY),
79
253
  "target" => target, "binding" => target.merge("payload_sha256" => digest)}
80
254
  )
81
255
  save(record)
82
256
  public_record(record)
83
257
  end
258
+ rescue Ace::Hitl::Contract::InvalidEnvelope => e
259
+ raise ValidationError, e.message
84
260
  end
85
261
 
86
262
  def status(event:)
87
263
  validate_id!(event, "event")
88
264
  with_event(event) do |record|
89
- raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
265
+ raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
90
266
 
91
267
  public_record(record)
92
268
  end
93
269
  end
94
270
 
95
- def deliver(event:)
271
+ # Direct completion readback uses the existing event lock and immutable ledger.
272
+ def verify_direct_enqueue(binding)
273
+ selection = binding.fetch("selection")
274
+ address = address_for(selection.fetch("reverse"))
275
+ with_event(binding.fetch("event_id"), create_lock: false) do |record|
276
+ raise ValidationError, "direct enqueue record is unavailable" unless record&.inbox
277
+ validate_match!(record, binding.fetch("attempt_id"), address, selection.fetch("payload_sha256"))
278
+ origin = record.inbox.fetch("origin_target")
279
+ validate_expected_target!(origin)
280
+ unless record.answer.bytesize == selection.fetch("payload_bytes") &&
281
+ record.inbox.fetch("original_context") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) &&
282
+ record.inbox["original_binding_digest"].is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(record.inbox.fetch("original_binding_digest")) &&
283
+ origin.values_at("session", "pane") == [address.session, address.pane] &&
284
+ record.inbox.fetch("binding").fetch("payload_sha256") == record.answer_digest
285
+ raise ValidationError, "direct enqueue retained association differs"
286
+ end
287
+ public_record(record)
288
+ end
289
+ rescue KeyError, TypeError, SystemCallError
290
+ raise ValidationError, "direct enqueue retained record is unavailable"
291
+ end
292
+
293
+ def verify_direct_delivery(binding, require_idle: false)
294
+ with_event(binding.fetch("event_id"), create_lock: false) do |record|
295
+ unless record&.inbox && record.inbox.fetch("attempt_id") == binding.fetch("attempt_id") &&
296
+ record.inbox.fetch("original_context") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) &&
297
+ record.inbox["original_binding_digest"].is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(record.inbox.fetch("original_binding_digest"))
298
+ raise ValidationError, "direct delivery retained association differs"
299
+ end
300
+ validate_expected_target!(record.inbox.fetch("origin_target"))
301
+ unless record.inbox.fetch("origin_target").values_at("session", "pane") == [record.session, record.pane] &&
302
+ record.inbox.fetch("binding").fetch("payload_sha256") == record.answer_digest
303
+ raise ValidationError, "direct delivery retained target differs"
304
+ end
305
+ expected = binding.fetch("selection").fetch("expected_claim_generation")
306
+ generation = record.inbox.fetch("claim_generation")
307
+ raise ValidationError, "direct delivery retained generation differs" unless generation >= expected
308
+ idle = case record.state
309
+ when "queued"
310
+ !record.inbox["submission_intent"] && (generation.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
311
+ when "delivered"
312
+ guarded_queue_record!(record, binding, record.inbox.slice("claim_generation", "claim_owner"))
313
+ receipt = record.inbox["receipt"]
314
+ receipt.is_a?(Hash) && receipt.slice("event_id", "attempt_id", "claim_generation", "payload_sha256", "binding") ==
315
+ {"event_id" => record.event_id, "attempt_id" => binding.fetch("attempt_id"), "claim_generation" => generation,
316
+ "payload_sha256" => record.answer_digest, "binding" => record.inbox.fetch("binding")} &&
317
+ %w[none sent pending not_issued].include?(record.inbox.dig("wake", "status"))
318
+ else
319
+ false
320
+ end
321
+ raise ValidationError, "direct delivery completion is not retained" if require_idle && !idle
322
+ {"record" => public_record(record), "idle" => !!idle}
323
+ end
324
+ rescue KeyError, TypeError, SystemCallError
325
+ raise ValidationError, "direct delivery retained record is unavailable"
326
+ end
327
+
328
+ # Protected canonical reads only use an already registered event's lock.
329
+ # Missing retention is unavailable evidence, never query-time repair.
330
+ def retained_status(event:, deadline: nil)
331
+ validate_id!(event, "event")
332
+ with_event(event, create_lock: false, deadline: deadline) do |record|
333
+ raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
334
+ public_record(record)
335
+ end
336
+ rescue SystemCallError, Molecules::DeliveryRecordStore::LockUnavailable
337
+ raise ValidationError, "retained inbox lock is unavailable"
338
+ end
339
+
340
+ # Called while the fixed context owner holds its admission transaction.
341
+ # Only the event claim is written here; no native observation or effect.
342
+ def prepare_direct_delivery(event:, expected_claim_generation:, expected_attempt:, claim_owner:, operation_id:, key_generation:, effect_binding:)
343
+ validate_id!(event, "event")
344
+ unless claim_owner.is_a?(String) && claim_owner.match?(/\A[0-9a-f]{64}\z/)
345
+ raise ValidationError, "direct claim owner differs"
346
+ end
347
+ with_event(event) do |record|
348
+ unless record&.inbox && record.inbox.fetch("attempt_id") == expected_attempt &&
349
+ expected_claim_generation.is_a?(Integer) && expected_claim_generation >= 0
350
+ raise ValidationError, "direct claim original association differs"
351
+ end
352
+ verified = Molecules::InboxDirectEffectBinding.verify!(effect_binding, key_generation: key_generation)
353
+ unless verified.fetch("selection") == {"expected_claim_generation" => expected_claim_generation} && verified.fetch("event_id") == event &&
354
+ verified.fetch("attempt_id") == expected_attempt && verified.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) == record.inbox.fetch("original_context") &&
355
+ operation_id.is_a?(String) && operation_id.match?(/\A[0-9a-f]{32}\z/) &&
356
+ claim_owner == Digest::SHA256.hexdigest(JSON.generate([record.inbox.fetch("original_context").fetch("inbox_context_id"), operation_id]))
357
+ raise ValidationError, "direct queue issuer association differs"
358
+ end
359
+ current = record.inbox.fetch("claim_generation")
360
+ raise ValidationError, "direct claim expected generation is future" if expected_claim_generation > current
361
+ if record.state == "delivered" && expected_claim_generation == current && %w[pending not_issued].include?(record.inbox.dig("wake", "status"))
362
+ claim = record.inbox.slice("claim_generation", "claim_owner").merge("kind" => "wake")
363
+ guarded_queue_record!(record, effect_binding, claim)
364
+ unless record.inbox.fetch("queue_issuer").fetch("key_generation") == key_generation
365
+ raise ValidationError, "guarded wake key generation differs"
366
+ end
367
+ wake = record.inbox.fetch("wake").reject { |key, _| key == "code" }.merge("status" => "pending",
368
+ "operation_id" => operation_id, "input_sha256" => effect_binding.fetch("input_sha256"))
369
+ save(record.advance_inbox(state: "delivered", inbox: record.inbox.merge("wake" => wake),
370
+ detail: {"action" => "wake-adopt"}, timestamp: Time.now.utc.iso8601))
371
+ next claim
372
+ end
373
+ next nil if expected_claim_generation < current || %w[delivered completed uncertain].include?(record.state)
374
+ unless record.state == "queued" && !record.inbox["submission_intent"] &&
375
+ (current.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
376
+ raise ValidationError, "direct claim is not known pre-submission"
377
+ end
378
+ claim = record.inbox.reject { |key, _| key == "canonical_completion" }.merge("claim_owner" => claim_owner, "claim_generation" => current + 1,
379
+ "queue_issuer" => {"operation_id" => operation_id, "key_generation" => key_generation, "input_sha256" => verified.fetch("input_sha256")})
380
+ save(record.advance_inbox(state: "claimed", inbox: claim,
381
+ detail: {"action" => "claim", "claim_generation" => current + 1, "claim_owner" => claim_owner}, timestamp: Time.now.utc.iso8601))
382
+ {"kind" => "queue", "claim_generation" => current + 1, "claim_owner" => claim_owner}
383
+ end
384
+ end
385
+
386
+ def deliver(event:, expected_claim_generation: nil, expected_attempt: nil, prepared_claim: nil, &guarded_submission)
96
387
  validate_id!(event, "event")
97
388
  with_event(event) do |record|
98
389
  raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
99
- next retry_wake(record) if record.state == "delivered" && wake_pending?(record)
390
+ if prepared_claim
391
+ unless prepared_claim.is_a?(Hash) && prepared_claim.keys.sort == %w[claim_generation claim_owner] &&
392
+ record.state == "claimed" && !record.inbox["submission_intent"] &&
393
+ record.inbox.slice("claim_generation", "claim_owner") == prepared_claim &&
394
+ record.inbox.fetch("attempt_id") == expected_attempt &&
395
+ expected_claim_generation.is_a?(Integer) && prepared_claim.fetch("claim_generation") == expected_claim_generation + 1
396
+ raise ValidationError, "prepared direct claim changed"
397
+ end
398
+ elsif !expected_claim_generation.nil?
399
+ unless expected_claim_generation.is_a?(Integer) && expected_claim_generation >= 0 &&
400
+ expected_attempt.is_a?(String) && EVENT.match?(expected_attempt) &&
401
+ record.inbox.fetch("attempt_id") == expected_attempt
402
+ raise ValidationError, "protected delivery original association differs"
403
+ end
404
+ current = record.inbox.fetch("claim_generation")
405
+ raise ValidationError, "protected delivery expected generation is future" if expected_claim_generation > current
406
+ next public_record(record) if expected_claim_generation < current || %w[delivered completed uncertain].include?(record.state)
407
+ unless record.state == "queued" && !record.inbox["submission_intent"] &&
408
+ (current.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
409
+ raise ValidationError, "protected delivery is not known pre-submission"
410
+ end
411
+ end
412
+ next retry_wake(record) if record.state == "delivered" && wake_pending?(record) && !record.inbox["original_context"]
100
413
  next public_record(record) if %w[delivered completed uncertain].include?(record.state)
101
- unless record.state == "queued"
414
+ unless record.state == "queued" || prepared_claim
102
415
  if record.inbox["submission_intent"]
103
416
  # A previous owner crashed after saving submission intent:
104
417
  # the submission boundary is unknown to a new process.
@@ -114,12 +427,14 @@ module Ace
114
427
  next public_record(record)
115
428
  end
116
429
 
117
- claim = record.inbox.merge(
430
+ claim = prepared_claim ? record.inbox : record.inbox.reject { |key, _| key == "canonical_completion" }.merge(
118
431
  "claim_owner" => "#{Process.pid}:#{SecureRandom.hex(8)}",
119
432
  "claim_generation" => record.inbox.fetch("claim_generation", 0) + 1
120
433
  )
121
- record = transition(record, "claimed", claim, "claim")
122
- save(record)
434
+ unless prepared_claim
435
+ record = transition(record, "claimed", claim, "claim")
436
+ save(record)
437
+ end
123
438
 
124
439
  begin
125
440
  binding = observe(record)
@@ -141,7 +456,7 @@ module Ace
141
456
  record = transition(record, "uncertain", intent, "submit-intent")
142
457
  save(record)
143
458
 
144
- result = submit(record, binding)
459
+ result = submit(record, binding, guarded_submission)
145
460
  if result["pre_submit"]
146
461
  # Missing executable is the only proven pre-submission error.
147
462
  record = transition(record, "queued", bound.merge("last_error" => result["error"]),
@@ -155,18 +470,26 @@ module Ace
155
470
  # but its DECISION rides the first delivered transition: a crash
156
471
  # between saves must never leave the wake field missing (which
157
472
  # later reads as pending) for a busy target.
158
- wake = if WAKE_STATUSES.include?(binding["agent_status"])
473
+ wake = if binding.fetch("agent") == "codex"
474
+ {"status" => "none", "reason" => "terminal prompt was submitted"}
475
+ elsif WAKE_STATUSES.include?(binding["agent_status"])
159
476
  {"status" => "pending"}
160
477
  else
161
478
  {"status" => "none", "reason" => "busy target uses the native queue form"}
162
479
  end
480
+ if record.inbox["original_context"]
481
+ issuer = queue_issuer!(record)
482
+ wake = {"status" => wake.fetch("status"), "claim_generation" => intent.fetch("claim_generation"),
483
+ "binding_digest" => intent.fetch("original_binding_digest"), "operation_id" => issuer.fetch("operation_id"),
484
+ "input_sha256" => issuer.fetch("input_sha256")}
485
+ end
163
486
  record = transition(record, "delivered",
164
487
  intent.merge("receipt" => receipt).merge("wake" => wake), "accepted")
165
488
  save(record)
166
489
  # A pending wake is attempted after the decision is durable, so
167
490
  # a crash or transient failure can be recovered by a later
168
491
  # deliver call without ever resubmitting the message.
169
- record = attempt_wake(record, binding) if wake["status"] == "pending"
492
+ record = attempt_wake(record, binding) if wake["status"] == "pending" && !record.inbox["original_context"]
170
493
  else
171
494
  record = transition(record, "uncertain", intent.merge("last_error" => result["error"]),
172
495
  "submission-uncertain", result["error"])
@@ -176,122 +499,21 @@ module Ace
176
499
  end
177
500
  end
178
501
 
179
- def reconcile(event:, receipt:, signed_bytes: nil, signature: nil)
180
- validate_id!(event, "event")
181
- with_event(event) do |record|
182
- raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
183
- # `delivered` only proves native queue acceptance: the message may
184
- # still be consumed or evicted afterwards, so a signed observation
185
- # can reconcile it exactly like an uncertain outcome.
186
- unless %w[uncertain delivered].include?(record.state)
187
- raise ValidationError, "event is not reconcilable from state #{record.state}"
188
- end
189
- binding = record.inbox["binding"]
190
- matches = receipt.is_a?(Hash) && receipt["event_id"] == event &&
191
- receipt["attempt_id"] == record.inbox["attempt_id"] &&
192
- receipt["claim_generation"] == record.inbox["claim_generation"] &&
193
- receipt["payload_sha256"] == record.answer_digest &&
194
- receipt["binding"] == binding
195
- refusal = if !binding
196
- "event has no verified binding"
197
- elsif !matches
198
- "reconciliation receipt does not match bound event"
199
- end
200
- refusal ||= proof_refusal(receipt) if receipt.is_a?(Hash)
201
- refusal ||= signature_refusal(record, receipt, signed_bytes, signature)
202
- replacement = nil
203
- if !refusal && receipt["outcome"] == "superseded" && receipt.key?("replacement_target")
204
- replacement = receipt["replacement_target"]
205
- observed = begin
206
- raise ValidationError, "replacement target must be an object" unless replacement.is_a?(Hash)
207
-
208
- address = address_for(replacement)
209
- observe_target(address.session, address.pane)
210
- rescue ValidationError, ExecutorError, Ace::Hitl::Providers::InvalidRefError => e
211
- refusal = "replacement target cannot be verified: #{e.message}"
212
- nil
213
- end
214
- stable = %w[session pane terminal_id agent thread thread_kind]
215
- unless replacement.is_a?(Hash) && observed &&
216
- stable.all? { |key| replacement[key] == observed[key] }
217
- refusal ||= "replacement target does not match the live native session"
218
- end
219
- # The replacement agent's own delivery rules must admit this
220
- # event: its immutable-id requirement and payload bound.
221
- if observed
222
- if observed["agent"] == "pi" && !PI_EVENT_ID.match?(event)
223
- refusal ||= "replacement pi target requires an inbox (inb-) or wake (wnk-) event id"
224
- end
225
- payload_limit = observed["agent"] == "pi" ?
226
- Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
227
- Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
228
- if record.answer.to_s.bytesize > payload_limit
229
- refusal ||= "payload exceeds the replacement agent's native queue limit"
230
- end
231
- end
232
- end
233
- next public_record(record).merge("reconciliation_refusal" => refusal) if refusal
234
-
235
- outcome = receipt["outcome"]
236
- state = outcome == "consumed" ? "completed" : "queued"
237
- inbox = record.inbox.merge("reconciliation" => receipt)
238
- if state == "queued"
239
- inbox = inbox.reject do |key, _|
240
- %w[submission_intent claim_owner receipt].include?(key)
241
- end
242
- inbox = inbox.merge("target" => replacement) if replacement
243
- end
244
- record = transition(record, state, inbox, "reconcile-#{outcome}")
245
- save(record)
246
- public_record(record)
247
- end
248
- end
249
-
250
502
  private
251
503
 
252
- def key_fingerprint
253
- Digest::SHA256.hexdigest(@receipt_public_key.public_to_der)
254
- end
255
504
 
256
- def signature_refusal(record, receipt, signed_bytes, signature)
257
- return "trusted receipt public key is unavailable" unless @receipt_public_key
258
- unless record.inbox["receipt_key_sha256"] == key_fingerprint
259
- return "trusted receipt public key differs from the enqueued event"
260
- end
261
- return "receipt signature is missing" unless signed_bytes.is_a?(String) && signature.is_a?(String)
262
- return "signed receipt content differs from parsed receipt" unless JSON.parse(signed_bytes) == receipt
263
- return "receipt signature is invalid" unless @receipt_public_key.verify(
264
- OpenSSL::Digest::SHA256.new, signature, signed_bytes)
265
505
 
266
- nil
267
- rescue JSON::ParserError, OpenSSL::PKey::PKeyError
268
- "receipt signature is invalid"
269
- end
270
506
 
271
- # The receipt is an explicit operator or supervisor attestation of a
272
- # native outcome. Native queue submission alone cannot prove consumption
273
- # or nonconsumption; an absent or incomplete attestation is never retried.
274
- def proof_refusal(receipt)
275
- return "invalid reconciliation outcome" unless %w[consumed superseded].include?(receipt["outcome"])
276
- observer = receipt["observer"]
277
- unless observer.is_a?(Hash) && %w[operator supervisor].include?(observer["role"]) &&
278
- observer["id"].is_a?(String) && !observer["id"].strip.empty?
279
- return "receipt requires an identified operator or supervisor"
280
- end
281
- evidence = receipt["evidence"]
282
- kinds = receipt["outcome"] == "consumed" ? %w[consumed_acknowledged] :
283
- %w[queue_evicted queue_expired thread_replaced]
284
- unless evidence.is_a?(Hash) && kinds.include?(evidence["kind"]) &&
285
- evidence["native_reference"].is_a?(String) && !evidence["native_reference"].strip.empty? &&
286
- evidence["observation"].is_a?(String) && !evidence["observation"].strip.empty?
287
- return "receipt requires a native outcome observation and reference"
288
- end
289
- nil
290
- end
291
507
 
292
- def with_event(event)
293
- Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event) do
294
- yield Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
508
+
509
+
510
+ def with_event(event, create_lock: true, deadline: nil)
511
+ Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock, deadline: deadline) do
512
+ record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
513
+ if record
514
+ Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
515
+ end
516
+ yield record
295
517
  end
296
518
  end
297
519
 
@@ -313,13 +535,20 @@ module Ace
313
535
  hash = ref.is_a?(Hash) ? ref : JSON.parse(File.read(ref))
314
536
  raise ValidationError, "invalid ref: expected a JSON object" unless hash.is_a?(Hash)
315
537
  Ace::Hitl::Providers::Ref.new(
316
- session: Ace::Hitl::Providers::Ref.validate!(hash.fetch("session"), "session"),
317
- pane: Ace::Hitl::Providers::Ref.validate!(hash.fetch("pane"), "pane")
538
+ session: hash.fetch("session"), pane: hash.fetch("pane"), canonical: !ref.is_a?(Hash)
318
539
  )
319
540
  rescue Errno::ENOENT, JSON::ParserError, KeyError, TypeError => e
320
541
  raise ValidationError, "invalid ref: #{e.message}"
321
542
  end
322
543
 
544
+ def validate_expected_target!(target)
545
+ unless target.is_a?(Hash) && TARGET_IDENTITY.all? { |key| target[key].is_a?(String) && !target[key].empty? } &&
546
+ %w[codex pi].include?(target["agent"]) && target["thread_kind"] == "id" &&
547
+ THREAD_ID.match?(target["thread"]) && target["terminal_id"].bytesize <= 64
548
+ raise ValidationError, "accepted original native target is incomplete or invalid"
549
+ end
550
+ end
551
+
323
552
  def validate_match!(record, attempt, address, digest)
324
553
  unless record.inbox && record.inbox["attempt_id"] == attempt &&
325
554
  record.session == address.session && record.pane == address.pane &&
@@ -335,9 +564,9 @@ module Ace
335
564
  # Target drift is classified BEFORE agent-specific event id rules:
336
565
  # a bound event whose pane changed agent (e.g. codex -> pi) must
337
566
  # reconcile, not loop on a pre-send validation error.
338
- stable = %w[session pane terminal_id agent thread thread_kind]
567
+ stable = TARGET_IDENTITY
339
568
  if target && !stable.all? { |key| target[key] == binding[key] }
340
- raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
569
+ raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
341
570
  end
342
571
  if binding["agent"] == "pi" && !PI_EVENT_ID.match?(record.event_id)
343
572
  raise ValidationError, "Pi queue requires an inbox or wake event ID"
@@ -360,7 +589,7 @@ module Ace
360
589
  # An agent change is target drift: classify it as reconciliation-
361
590
  # worthy before any agent-specific validation can mask it.
362
591
  if target && target["agent"] != agent
363
- raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
592
+ raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
364
593
  end
365
594
  raise ValidationError, "unsupported native agent" unless %w[codex pi].include?(agent)
366
595
  session = pane["agent_session"]
@@ -399,9 +628,23 @@ module Ace
399
628
  "agent_status" => status}
400
629
  end
401
630
 
402
- def submit(record, binding)
403
- @native.submit(agent: binding["agent"], thread: binding["thread"],
631
+ def submit(record, binding, guarded_submission)
632
+ if binding.fetch("agent") == "codex"
633
+ if record.inbox["original_context"]
634
+ raise ValidationError, "original terminal submission owner is unavailable" unless guarded_submission
635
+ result = guarded_submission.call(record.answer)
636
+ return {"accepted" => true} if result["outcome"] == "submitted"
637
+ return {"accepted" => false, "pre_submit" => true, "error" => result.fetch("code")} if result["outcome"] == "not_issued"
638
+ return {"accepted" => false, "error" => "original terminal submission outcome unknown"}
639
+ end
640
+ result = @executor.agent_prompt_bounded(pane: binding.fetch("pane"), text: record.answer,
641
+ timeout_ms: 10_000)
642
+ return {"accepted" => true, "stdout" => result.stdout}
643
+ end
644
+ @native.submit(agent: binding.fetch("agent"), thread: binding.fetch("thread"),
404
645
  event_id: record.event_id, digest: record.answer_digest, payload: record.answer)
646
+ rescue ExecutorUnavailableError => e
647
+ {"accepted" => false, "pre_submit" => true, "error" => e.message}
405
648
  rescue ExecutorError => e
406
649
  {"accepted" => false, "error" => e.message}
407
650
  end
@@ -438,6 +681,7 @@ module Ace
438
681
  end
439
682
 
440
683
  def wake_pending?(record)
684
+ return false if record.inbox.dig("binding", "agent") == "codex"
441
685
  wake = record.inbox["wake"]
442
686
  wake.nil? || wake["status"] == "pending"
443
687
  end
@@ -457,6 +701,7 @@ module Ace
457
701
  end
458
702
 
459
703
  def wake_idle(binding)
704
+ raise ValidationError, "Codex native queue has no terminal wake operation" unless binding.fetch("agent") == "pi"
460
705
  @executor.agent_prompt_bounded(pane: binding["pane"],
461
706
  text: "Check your native queued messages.", timeout_ms: 10_000)
462
707
  nil
@@ -468,13 +713,15 @@ module Ace
468
713
  {"event_id" => record.event_id, "attempt_id" => record.inbox["attempt_id"],
469
714
  "session" => record.session, "pane" => record.pane,
470
715
  "payload_sha256" => record.answer_digest, "state" => record.state,
471
- "receipt_key_sha256" => record.inbox["receipt_key_sha256"],
716
+ "managed_envelope" => record.inbox["managed_envelope"],
717
+ "original_context" => record.inbox["original_context"],
718
+ "original_binding_digest" => record.inbox["original_binding_digest"],
472
719
  "claim_generation" => record.inbox["claim_generation"],
473
720
  "claim_owner" => record.inbox["claim_owner"],
474
721
  "submission_intent" => !!record.inbox["submission_intent"],
722
+ "origin_target" => record.inbox["origin_target"],
475
723
  "target" => record.inbox["target"],
476
724
  "binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
477
- "reconciliation" => record.inbox["reconciliation"],
478
725
  "wake" => record.inbox["wake"],
479
726
  "last_error" => record.inbox["last_error"]}
480
727
  end