ace-herdr 0.4.0 → 0.5.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +18 -0
  4. data/docs/usage.md +57 -35
  5. data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli.rb +3 -0
  8. data/lib/ace/herdr/errors.rb +23 -0
  9. data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
  10. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
  11. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  12. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  13. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  14. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  15. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  16. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  17. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  18. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  19. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  20. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  21. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  22. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  24. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  25. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  26. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  27. data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
  28. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  29. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  30. data/lib/ace/herdr/native_source/selection.json +24 -0
  31. data/lib/ace/herdr/organisms/inbox.rb +293 -203
  32. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  33. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  34. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  35. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  36. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  37. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  38. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  39. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  40. data/lib/ace/herdr/version.rb +1 -1
  41. data/lib/ace/herdr.rb +5 -28
  42. metadata +68 -9
  43. data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
@@ -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,7 +16,9 @@ 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/
19
24
  TARGET_IDENTITY = %w[session pane terminal_id agent thread thread_kind].freeze
@@ -29,60 +34,127 @@ module Ace
29
34
  AGENT_STATUSES = %w[idle busy working blocked done].freeze
30
35
  WAKE_STATUSES = %w[idle done].freeze
31
36
 
32
- # One configured verifier construction for the Herdr CLI and signed
33
- # receipt consumers. Configuration remains supervisor-owned.
37
+ # The maintained CLI uses the configured durable delivery store.
34
38
  def self.from_config(config: Ace::Herdr.config, root: Dir.pwd,
35
39
  executor: Molecules::HerdrExecutor.new, native: Molecules::NativeQueueExecutor.new)
36
- path = config["inbox_receipt_public_key"]
37
- key = begin
38
- candidate = OpenSSL::PKey.read(File.read(path)) if path.is_a?(String) && path.start_with?("/")
39
- candidate if candidate.is_a?(OpenSSL::PKey::RSA) && !candidate.private?
40
- rescue SystemCallError, OpenSSL::PKey::PKeyError
41
- nil
42
- end
43
40
  new(executor: executor, native: native,
44
- deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root),
45
- receipt_public_key: key)
41
+ deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root))
46
42
  end
47
43
 
48
44
  # Enumerate retained event identities through the same store/lock owner.
49
45
  # Unreadable unattributable records cannot be treated as another attempt.
50
46
  def self.retained_events(deliveries_dir:, attempt:)
51
47
  raise ValidationError, "invalid attempt id" unless attempt.is_a?(String) && EVENT.match?(attempt)
52
- ids = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)].flat_map do |dir|
53
- next [] unless Dir.exist?(dir)
54
- Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
55
- end.uniq
56
- ids.select do |event|
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|
57
114
  raise ValidationError, "retained inbox event id is invalid" unless EVENT.match?(event)
58
- Molecules::DeliveryRecordStore.with_lock(deliveries_dir, event, create: false) do
59
- paths = [Molecules::DeliveryRecordStore.path_for(deliveries_dir, event),
60
- Molecules::DeliveryRecordStore.path_for(Molecules::DeliveryRecordStore.archive_dir(deliveries_dir), event)]
61
- copies = paths.filter_map do |path|
62
- next unless File.exist?(path)
63
- record = Models::DeliveryRecord.from_json(File.read(path))
64
- unless record.event_id == event && record.inbox && record.inbox.fetch("attempt_id").is_a?(String)
65
- raise ValidationError, "retained inbox event is unavailable"
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
66
135
  end
67
- record.to_h
68
136
  end
69
- # Identical retained copies describe one event. Never let live
70
- # precedence hide a corrupt or conflicting archived copy.
71
- if copies.empty? || copies.uniq.length != 1
137
+ if copies.empty? || copies.map(&:to_h).uniq.length != 1
72
138
  raise ValidationError, "retained inbox copies conflict"
73
139
  end
74
- copies.first.fetch("inbox").fetch("attempt_id") == attempt
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)
75
146
  end
76
147
  end
77
- rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError
148
+ raise ValidationError, "retained inbox inventory changed" unless inventory.call == ids
149
+ records.freeze
150
+ rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
78
151
  raise ValidationError, "retained inbox inventory is unavailable"
79
152
  end
80
153
 
81
- def initialize(executor:, native:, deliveries_dir:, receipt_public_key: nil)
154
+ def initialize(executor:, deliveries_dir:, native: Molecules::NativeQueueExecutor.new)
82
155
  @executor = executor
83
156
  @native = native
84
157
  @deliveries_dir = deliveries_dir
85
- @receipt_public_key = receipt_public_key
86
158
  end
87
159
 
88
160
  # Project the accepted runtime owner before consuming an answer. Native
@@ -98,10 +170,12 @@ module Ace
98
170
  target.transform_values { |value| value.dup.freeze }.freeze
99
171
  end
100
172
 
101
- def enqueue(event:, attempt:, ref:, payload:, managed_envelope: nil, expected_target: nil)
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)
102
177
  validate_id!(event, "event")
103
178
  validate_id!(attempt, "attempt")
104
- raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
105
179
  payload = payload.to_s
106
180
  raise ValidationError, "payload is required" if payload.empty?
107
181
  raise ValidationError, "payload contains NUL" if payload.include?("\0")
@@ -112,6 +186,14 @@ module Ace
112
186
  raise ValidationError, "payload is not valid UTF-8 text"
113
187
  end
114
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
115
197
  address = address_for(ref)
116
198
  digest = Digest::SHA256.hexdigest(payload)
117
199
  envelope = if managed_envelope
@@ -136,7 +218,7 @@ module Ace
136
218
  with_event(event) do |record|
137
219
  if record
138
220
  validate_match!(record, attempt, address, digest)
139
- unless record.inbox["managed_envelope"] == envelope
221
+ unless record.inbox["managed_envelope"] == envelope && record.inbox["original_context"] == original_context && record.inbox["original_binding_digest"] == original_binding_digest
140
222
  raise ValidationError, "managed envelope conflicts with immutable inbox event"
141
223
  end
142
224
  if expected_target && record.inbox["origin_target"] != expected_target
@@ -156,7 +238,7 @@ module Ace
156
238
  end
157
239
  payload_limit = target["agent"] == "pi" ?
158
240
  Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
159
- Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
241
+ Molecules::NativeQueueExecutor::CODEX_PAYLOAD_LIMIT_BYTES
160
242
  if payload.bytesize > payload_limit
161
243
  raise ValidationError,
162
244
  "payload exceeds the #{target['agent']} native queue limit " \
@@ -166,8 +248,7 @@ module Ace
166
248
  event_id: event, session: address.session, pane: address.pane,
167
249
  answer_digest: digest, answer: payload, state: "queued",
168
250
  inbox: {"attempt_id" => attempt, "claim_generation" => 0,
169
- "receipt_key_sha256" => key_fingerprint,
170
- "managed_envelope" => envelope,
251
+ "managed_envelope" => envelope, "original_context" => original_context, "original_binding_digest" => original_binding_digest,
171
252
  "origin_target" => target.slice(*TARGET_IDENTITY),
172
253
  "target" => target, "binding" => target.merge("payload_sha256" => digest)}
173
254
  )
@@ -181,31 +262,156 @@ module Ace
181
262
  def status(event:)
182
263
  validate_id!(event, "event")
183
264
  with_event(event) do |record|
184
- raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
265
+ raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
185
266
 
186
267
  public_record(record)
187
268
  end
188
269
  end
189
270
 
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
+
190
328
  # Protected canonical reads only use an already registered event's lock.
191
329
  # Missing retention is unavailable evidence, never query-time repair.
192
- def retained_status(event:)
330
+ def retained_status(event:, deadline: nil)
193
331
  validate_id!(event, "event")
194
- with_event(event, create_lock: false) do |record|
195
- raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
332
+ with_event(event, create_lock: false, deadline: deadline) do |record|
333
+ raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
196
334
  public_record(record)
197
335
  end
198
- rescue SystemCallError
336
+ rescue SystemCallError, Molecules::DeliveryRecordStore::LockUnavailable
199
337
  raise ValidationError, "retained inbox lock is unavailable"
200
338
  end
201
339
 
202
- def deliver(event:)
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)
203
387
  validate_id!(event, "event")
204
388
  with_event(event) do |record|
205
389
  raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
206
- 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"]
207
413
  next public_record(record) if %w[delivered completed uncertain].include?(record.state)
208
- unless record.state == "queued"
414
+ unless record.state == "queued" || prepared_claim
209
415
  if record.inbox["submission_intent"]
210
416
  # A previous owner crashed after saving submission intent:
211
417
  # the submission boundary is unknown to a new process.
@@ -221,12 +427,14 @@ module Ace
221
427
  next public_record(record)
222
428
  end
223
429
 
224
- claim = record.inbox.merge(
430
+ claim = prepared_claim ? record.inbox : record.inbox.reject { |key, _| key == "canonical_completion" }.merge(
225
431
  "claim_owner" => "#{Process.pid}:#{SecureRandom.hex(8)}",
226
432
  "claim_generation" => record.inbox.fetch("claim_generation", 0) + 1
227
433
  )
228
- record = transition(record, "claimed", claim, "claim")
229
- save(record)
434
+ unless prepared_claim
435
+ record = transition(record, "claimed", claim, "claim")
436
+ save(record)
437
+ end
230
438
 
231
439
  begin
232
440
  binding = observe(record)
@@ -248,7 +456,7 @@ module Ace
248
456
  record = transition(record, "uncertain", intent, "submit-intent")
249
457
  save(record)
250
458
 
251
- result = submit(record, binding)
459
+ result = submit(record, binding, guarded_submission)
252
460
  if result["pre_submit"]
253
461
  # Missing executable is the only proven pre-submission error.
254
462
  record = transition(record, "queued", bound.merge("last_error" => result["error"]),
@@ -262,18 +470,26 @@ module Ace
262
470
  # but its DECISION rides the first delivered transition: a crash
263
471
  # between saves must never leave the wake field missing (which
264
472
  # later reads as pending) for a busy target.
265
- 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"])
266
476
  {"status" => "pending"}
267
477
  else
268
478
  {"status" => "none", "reason" => "busy target uses the native queue form"}
269
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
270
486
  record = transition(record, "delivered",
271
487
  intent.merge("receipt" => receipt).merge("wake" => wake), "accepted")
272
488
  save(record)
273
489
  # A pending wake is attempted after the decision is durable, so
274
490
  # a crash or transient failure can be recovered by a later
275
491
  # deliver call without ever resubmitting the message.
276
- record = attempt_wake(record, binding) if wake["status"] == "pending"
492
+ record = attempt_wake(record, binding) if wake["status"] == "pending" && !record.inbox["original_context"]
277
493
  else
278
494
  record = transition(record, "uncertain", intent.merge("last_error" => result["error"]),
279
495
  "submission-uncertain", result["error"])
@@ -283,158 +499,16 @@ module Ace
283
499
  end
284
500
  end
285
501
 
286
- def reconcile(event:, receipt:, signed_bytes: nil, signature: nil, expected_registration: nil)
287
- reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
288
- signature: signature, expected_registration: expected_registration, settle: true)
289
- end
290
-
291
- # Canonical consumers reverify retained signed settlement without creating
292
- # a new local transition. The same event lock and proof owner are used.
293
- def verify_reconciliation(event:, receipt:, signed_bytes:, signature:, expected_registration:)
294
- unless expected_registration.is_a?(Hash) && expected_registration.keys.sort ==
295
- %w[event_id attempt_id payload_sha256 receipt_key_sha256].sort &&
296
- %w[event_id attempt_id].all? { |key| expected_registration[key].is_a?(String) && EVENT.match?(expected_registration[key]) } &&
297
- %w[payload_sha256 receipt_key_sha256].all? { |key| expected_registration[key].is_a?(String) && expected_registration[key].match?(/\A[0-9a-f]{64}\z/) }
298
- raise ValidationError, "canonical reconciliation requires exact registration"
299
- end
300
- reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
301
- signature: signature, expected_registration: expected_registration, settle: false)
302
- rescue SystemCallError
303
- raise ValidationError, "retained inbox lock is unavailable"
304
- end
305
-
306
502
  private
307
503
 
308
- def reconcile_record(event:, receipt:, signed_bytes:, signature:, expected_registration:, settle:)
309
- validate_id!(event, "event")
310
- with_event(event, create_lock: settle) do |record|
311
- raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
312
- # The consumer's accepted registration must match under the same
313
- # event lock that verifies and settles the signed observation.
314
- unless expected_registration.nil?
315
- registration = public_record(record).slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
316
- unless expected_registration == registration
317
- raise ValidationError, "inbox event differs from expected registration"
318
- end
319
- end
320
- # `delivered` only proves native queue acceptance: the message may
321
- # still be consumed or evicted afterwards, so a signed observation
322
- # can reconcile it exactly like an uncertain outcome.
323
- replay = %w[completed queued].include?(record.state) && receipt.is_a?(Hash) &&
324
- receipt == record.inbox["reconciliation"]
325
- unless replay || (settle && %w[uncertain delivered].include?(record.state))
326
- raise ValidationError, "event is not reconcilable from state #{record.state}"
327
- end
328
- binding = record.inbox["binding"]
329
- matches = receipt.is_a?(Hash) && receipt["event_id"] == event &&
330
- receipt["attempt_id"] == record.inbox["attempt_id"] &&
331
- receipt["claim_generation"] == record.inbox["claim_generation"] &&
332
- receipt["payload_sha256"] == record.answer_digest &&
333
- receipt["binding"] == binding
334
- refusal = if !binding
335
- "event has no verified binding"
336
- elsif !matches
337
- "reconciliation receipt does not match bound event"
338
- end
339
- refusal ||= proof_refusal(receipt) if receipt.is_a?(Hash)
340
- refusal ||= signature_refusal(record, receipt, signed_bytes, signature)
341
- replacement = nil
342
- if !refusal && receipt["outcome"] == "superseded" && receipt.key?("replacement_target")
343
- replacement = receipt["replacement_target"]
344
- observed = begin
345
- raise ValidationError, "replacement target must be an object" unless replacement.is_a?(Hash)
346
-
347
- address = address_for(replacement)
348
- observe_target(address.session, address.pane)
349
- rescue ValidationError, ExecutorError, Ace::Hitl::Providers::InvalidRefError => e
350
- refusal = "replacement target cannot be verified: #{e.message}"
351
- nil
352
- end
353
- stable = TARGET_IDENTITY
354
- unless replacement.is_a?(Hash) && observed &&
355
- stable.all? { |key| replacement[key] == observed[key] }
356
- refusal ||= "replacement target does not match the live native session"
357
- end
358
- # The replacement agent's own delivery rules must admit this
359
- # event: its immutable-id requirement and payload bound.
360
- if observed
361
- if observed["agent"] == "pi" && !PI_EVENT_ID.match?(event)
362
- refusal ||= "replacement pi target requires an inbox (inb-) or wake (wnk-) event id"
363
- end
364
- payload_limit = observed["agent"] == "pi" ?
365
- Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
366
- Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
367
- if record.answer.to_s.bytesize > payload_limit
368
- refusal ||= "payload exceeds the replacement agent's native queue limit"
369
- end
370
- end
371
- end
372
- next public_record(record).merge("reconciliation_refusal" => refusal) if refusal
373
- # The consumer may crash after Herdr settles but before recording
374
- # its observation reference. Re-verify the identical signed proof
375
- # without another transition. A later claim has a new generation
376
- # and fails the binding checks above.
377
- next public_record(record) if replay
378
-
379
- outcome = receipt["outcome"]
380
- state = outcome == "consumed" ? "completed" : "queued"
381
- inbox = record.inbox.merge("reconciliation" => receipt)
382
- if state == "queued"
383
- inbox = inbox.reject do |key, _|
384
- %w[submission_intent claim_owner receipt].include?(key)
385
- end
386
- inbox = inbox.merge("target" => replacement) if replacement
387
- end
388
- record = transition(record, state, inbox, "reconcile-#{outcome}")
389
- save(record)
390
- public_record(record)
391
- end
392
- end
393
504
 
394
- private
395
505
 
396
- def key_fingerprint
397
- Digest::SHA256.hexdigest(@receipt_public_key.public_to_der)
398
- end
399
506
 
400
- def signature_refusal(record, receipt, signed_bytes, signature)
401
- return "trusted receipt public key is unavailable" unless @receipt_public_key
402
- unless record.inbox["receipt_key_sha256"] == key_fingerprint
403
- return "trusted receipt public key differs from the enqueued event"
404
- end
405
- return "receipt signature is missing" unless signed_bytes.is_a?(String) && signature.is_a?(String)
406
- return "signed receipt content differs from parsed receipt" unless JSON.parse(signed_bytes) == receipt
407
- return "receipt signature is invalid" unless @receipt_public_key.verify(
408
- OpenSSL::Digest::SHA256.new, signature, signed_bytes)
409
507
 
410
- nil
411
- rescue JSON::ParserError, OpenSSL::PKey::PKeyError
412
- "receipt signature is invalid"
413
- end
414
508
 
415
- # The receipt is an explicit operator or supervisor attestation of a
416
- # native outcome. Native queue submission alone cannot prove consumption
417
- # or nonconsumption; an absent or incomplete attestation is never retried.
418
- def proof_refusal(receipt)
419
- return "invalid reconciliation outcome" unless %w[consumed superseded].include?(receipt["outcome"])
420
- observer = receipt["observer"]
421
- unless observer.is_a?(Hash) && %w[operator supervisor].include?(observer["role"]) &&
422
- observer["id"].is_a?(String) && !observer["id"].strip.empty?
423
- return "receipt requires an identified operator or supervisor"
424
- end
425
- evidence = receipt["evidence"]
426
- kinds = receipt["outcome"] == "consumed" ? %w[consumed_acknowledged] :
427
- %w[queue_evicted queue_expired thread_replaced]
428
- unless evidence.is_a?(Hash) && kinds.include?(evidence["kind"]) &&
429
- evidence["native_reference"].is_a?(String) && !evidence["native_reference"].strip.empty? &&
430
- evidence["observation"].is_a?(String) && !evidence["observation"].strip.empty?
431
- return "receipt requires a native outcome observation and reference"
432
- end
433
- nil
434
- end
435
509
 
436
- def with_event(event, create_lock: true)
437
- Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock) do
510
+ def with_event(event, create_lock: true, deadline: nil)
511
+ Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock, deadline: deadline) do
438
512
  record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
439
513
  if record
440
514
  Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
@@ -492,7 +566,7 @@ module Ace
492
566
  # reconcile, not loop on a pre-send validation error.
493
567
  stable = TARGET_IDENTITY
494
568
  if target && !stable.all? { |key| target[key] == binding[key] }
495
- raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
569
+ raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
496
570
  end
497
571
  if binding["agent"] == "pi" && !PI_EVENT_ID.match?(record.event_id)
498
572
  raise ValidationError, "Pi queue requires an inbox or wake event ID"
@@ -515,7 +589,7 @@ module Ace
515
589
  # An agent change is target drift: classify it as reconciliation-
516
590
  # worthy before any agent-specific validation can mask it.
517
591
  if target && target["agent"] != agent
518
- raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
592
+ raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
519
593
  end
520
594
  raise ValidationError, "unsupported native agent" unless %w[codex pi].include?(agent)
521
595
  session = pane["agent_session"]
@@ -554,9 +628,23 @@ module Ace
554
628
  "agent_status" => status}
555
629
  end
556
630
 
557
- def submit(record, binding)
558
- @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"),
559
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}
560
648
  rescue ExecutorError => e
561
649
  {"accepted" => false, "error" => e.message}
562
650
  end
@@ -593,6 +681,7 @@ module Ace
593
681
  end
594
682
 
595
683
  def wake_pending?(record)
684
+ return false if record.inbox.dig("binding", "agent") == "codex"
596
685
  wake = record.inbox["wake"]
597
686
  wake.nil? || wake["status"] == "pending"
598
687
  end
@@ -612,6 +701,7 @@ module Ace
612
701
  end
613
702
 
614
703
  def wake_idle(binding)
704
+ raise ValidationError, "Codex native queue has no terminal wake operation" unless binding.fetch("agent") == "pi"
615
705
  @executor.agent_prompt_bounded(pane: binding["pane"],
616
706
  text: "Check your native queued messages.", timeout_ms: 10_000)
617
707
  nil
@@ -624,14 +714,14 @@ module Ace
624
714
  "session" => record.session, "pane" => record.pane,
625
715
  "payload_sha256" => record.answer_digest, "state" => record.state,
626
716
  "managed_envelope" => record.inbox["managed_envelope"],
627
- "receipt_key_sha256" => record.inbox["receipt_key_sha256"],
717
+ "original_context" => record.inbox["original_context"],
718
+ "original_binding_digest" => record.inbox["original_binding_digest"],
628
719
  "claim_generation" => record.inbox["claim_generation"],
629
720
  "claim_owner" => record.inbox["claim_owner"],
630
721
  "submission_intent" => !!record.inbox["submission_intent"],
631
722
  "origin_target" => record.inbox["origin_target"],
632
723
  "target" => record.inbox["target"],
633
724
  "binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
634
- "reconciliation" => record.inbox["reconciliation"],
635
725
  "wake" => record.inbox["wake"],
636
726
  "last_error" => record.inbox["last_error"]}
637
727
  end