dexkit 0.9.0 → 0.11.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
data/guides/llm/FORM.md CHANGED
@@ -10,58 +10,80 @@ All examples below build on this form unless noted otherwise:
10
10
 
11
11
  ```ruby
12
12
  class OnboardingForm < Dex::Form
13
+ description "Employee onboarding"
13
14
  model User
14
15
 
15
- attribute :first_name, :string
16
- attribute :last_name, :string
17
- attribute :email, :string
18
- attribute :department, :string
19
- attribute :start_date, :date
16
+ field :first_name, :string
17
+ field :last_name, :string
18
+ field :email, :string
19
+ field :department, :string
20
+ field :start_date, :date
21
+ field :locale, :string
22
+ field? :notes, :string
23
+
24
+ context :locale
20
25
 
21
26
  normalizes :email, with: -> { _1&.strip&.downcase.presence }
22
27
 
23
- validates :email, presence: true, uniqueness: true
24
- validates :first_name, :last_name, :department, presence: true
25
- validates :start_date, presence: true
28
+ validates :email, uniqueness: true
26
29
 
27
30
  nested_one :address do
28
- attribute :street, :string
29
- attribute :city, :string
30
- attribute :postal_code, :string
31
- attribute :country, :string
32
-
33
- validates :street, :city, :country, presence: true
31
+ field :street, :string
32
+ field :city, :string
33
+ field :postal_code, :string
34
+ field :country, :string
35
+ field? :apartment, :string
34
36
  end
35
37
 
36
38
  nested_many :documents do
37
- attribute :document_type, :string
38
- attribute :document_number, :string
39
-
40
- validates :document_type, :document_number, presence: true
39
+ field :document_type, :string
40
+ field :document_number, :string
41
41
  end
42
42
  end
43
43
  ```
44
44
 
45
45
  ---
46
46
 
47
- ## Defining Forms
47
+ ## Declaring Fields
48
48
 
49
- Forms use ActiveModel under the hood. Attributes are declared with `attribute` (same as ActiveModel::Attributes).
49
+ ### `field` required
50
+
51
+ Declares a required field. Auto-adds presence validation. Unconditional `validates :attr, presence: true` deduplicates with it; scoped or conditional presence validators do not make the field optional outside those cases.
50
52
 
51
53
  ```ruby
52
- class ProfileForm < Dex::Form
53
- attribute :name, :string
54
- attribute :age, :integer
55
- attribute :bio, :string
56
- attribute :active, :boolean, default: true
57
- attribute :born_on, :date
58
- end
54
+ field :name, :string
55
+ field :email, :string, desc: "Work email"
56
+ field :currency, :string, default: "USD"
57
+ ```
58
+
59
+ ### `field?` — optional
60
+
61
+ Declares an optional field. Defaults to `nil` unless overridden.
62
+
63
+ ```ruby
64
+ field? :notes, :string
65
+ field? :priority, :integer, default: 0
59
66
  ```
60
67
 
68
+ ### Options
69
+
70
+ | Option | Description |
71
+ |--------|-------------|
72
+ | `desc:` | Human-readable description (for introspection and JSON Schema) |
73
+ | `default:` | Default value (forwarded to ActiveModel) |
74
+
61
75
  ### Available types
62
76
 
63
77
  `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:time`.
64
78
 
79
+ ### `attribute` escape hatch
80
+
81
+ Raw ActiveModel `attribute` is still available. Not tracked in field registry, no auto-presence, not in exports.
82
+
83
+ ### Boolean fields
84
+
85
+ `field :active, :boolean` checks for `nil` (not `blank?`), so `false` is valid.
86
+
65
87
  ### `model(klass)`
66
88
 
67
89
  Declares the backing model class. Used by:
@@ -93,11 +115,11 @@ normalizes :name, :email, with: -> { _1&.strip.presence } # multiple attrs
93
115
 
94
116
  ## Validation
95
117
 
96
- Full ActiveModel validation DSL:
118
+ Full ActiveModel validation DSL. Required fields auto-validate presence — no need to add `validates :name, presence: true` when using `field :name, :string`.
97
119
 
98
120
  ```ruby
99
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
100
- validates :name, presence: true, length: { minimum: 2, maximum: 100 }
121
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
122
+ validates :name, length: { minimum: 2, maximum: 100 }
101
123
  validates :role, inclusion: { in: %w[admin user] }
102
124
  validates :email, uniqueness: true # checks database
103
125
  validate :custom_validation_method
@@ -111,6 +133,15 @@ form.errors[:email] # => ["can't be blank"]
111
133
  form.errors.full_messages # => ["Email can't be blank"]
112
134
  ```
113
135
 
136
+ ### Contextual requirements
137
+
138
+ Use `field?` with an explicit validation context:
139
+
140
+ ```ruby
141
+ field? :published_at, :datetime
142
+ validates :published_at, presence: true, on: :publish
143
+ ```
144
+
114
145
  ### ValidationError
115
146
 
116
147
  ```ruby
@@ -121,6 +152,94 @@ error.form # => the form instance
121
152
 
122
153
  ---
123
154
 
155
+ ## Ambient Context
156
+
157
+ Auto-fill fields from `Dex.context` – same DSL as Operation and Event:
158
+
159
+ ```ruby
160
+ class Order::Form < Dex::Form
161
+ field :locale, :string
162
+ field :currency, :string
163
+
164
+ context :locale # shorthand: field name = context key
165
+ context currency: :default_currency # explicit: field name → context key
166
+ end
167
+
168
+ Dex.with_context(locale: "en", default_currency: "USD") do
169
+ form = Order::Form.new
170
+ form.locale # => "en"
171
+ form.currency # => "USD"
172
+ end
173
+ ```
174
+
175
+ Explicit values always win. Context references must point to declared fields or attributes.
176
+
177
+ ---
178
+
179
+ ## Registry & Export
180
+
181
+ ### Description
182
+
183
+ ```ruby
184
+ class OnboardingForm < Dex::Form
185
+ description "Employee onboarding"
186
+ end
187
+
188
+ OnboardingForm.description # => "Employee onboarding"
189
+ ```
190
+
191
+ ### Registry
192
+
193
+ ```ruby
194
+ Dex::Form.registry # => #<Set: {OnboardingForm, ...}>
195
+ ```
196
+
197
+ ### Class-level `to_h`
198
+
199
+ ```ruby
200
+ OnboardingForm.to_h
201
+ # => {
202
+ # name: "OnboardingForm",
203
+ # description: "Employee onboarding",
204
+ # fields: {
205
+ # first_name: { type: :string, required: true },
206
+ # email: { type: :string, required: true },
207
+ # notes: { type: :string, required: false },
208
+ # ...
209
+ # },
210
+ # nested: {
211
+ # address: { type: :one, fields: { ... }, nested: { ... } },
212
+ # documents: { type: :many, fields: { ... } }
213
+ # }
214
+ # }
215
+ ```
216
+
217
+ ### `to_json_schema`
218
+
219
+ ```ruby
220
+ OnboardingForm.to_json_schema
221
+ # => {
222
+ # "$schema": "https://json-schema.org/draft/2020-12/schema",
223
+ # type: "object",
224
+ # title: "OnboardingForm",
225
+ # description: "Employee onboarding",
226
+ # properties: { email: { type: "string" }, ... },
227
+ # required: ["first_name", "last_name", "email", ...],
228
+ # additionalProperties: false
229
+ # }
230
+ ```
231
+
232
+ ### Global export
233
+
234
+ ```ruby
235
+ Dex::Form.export(format: :json_schema)
236
+ # => [{ ... OnboardingForm schema ... }, ...]
237
+ ```
238
+
239
+ Bulk export returns top-level named forms only. Nested helper classes generated by `nested_one` and `nested_many` stay embedded in their parent export instead of appearing as separate entries.
240
+
241
+ ---
242
+
124
243
  ## Nested Forms
125
244
 
126
245
  ### `nested_one`
@@ -129,9 +248,9 @@ One-to-one nested form. Automatically coerces Hash input.
129
248
 
130
249
  ```ruby
131
250
  nested_one :address do
132
- attribute :street, :string
133
- attribute :city, :string
134
- validates :street, :city, presence: true
251
+ field :street, :string
252
+ field :city, :string
253
+ field? :apartment, :string
135
254
  end
136
255
  ```
137
256
 
@@ -152,9 +271,8 @@ One-to-many nested form. Handles Array, Rails numbered Hash, and `_destroy`.
152
271
 
153
272
  ```ruby
154
273
  nested_many :documents do
155
- attribute :document_type, :string
156
- attribute :document_number, :string
157
- validates :document_type, :document_number, presence: true
274
+ field :document_type, :string
275
+ field :document_number, :string
158
276
  end
159
277
  ```
160
278
 
@@ -190,7 +308,7 @@ Override the auto-generated constant name:
190
308
 
191
309
  ```ruby
192
310
  nested_one :address, class_name: "HomeAddress" do
193
- attribute :street, :string
311
+ field :street, :string
194
312
  end
195
313
  # Creates MyForm::HomeAddress instead of MyForm::Address
196
314
  ```
@@ -306,7 +424,7 @@ form.to_hash # alias for to_h
306
424
 
307
425
  ### Controller pattern
308
426
 
309
- Strong parameters (`permit`) are not required — the form's attribute declarations are the whitelist. Just `require` the top-level key:
427
+ Strong parameters (`permit`) are not required — the form's field declarations are the whitelist. Just `require` the top-level key:
310
428
 
311
429
  ```ruby
312
430
  class OnboardingController < ApplicationController
@@ -405,33 +523,34 @@ A form spanning User, Employee, and Address — the core reason form objects exi
405
523
 
406
524
  ```ruby
407
525
  class OnboardingForm < Dex::Form
408
- attribute :first_name, :string
409
- attribute :last_name, :string
410
- attribute :email, :string
411
- attribute :department, :string
412
- attribute :position, :string
413
- attribute :start_date, :date
526
+ description "Employee onboarding"
527
+
528
+ field :first_name, :string
529
+ field :last_name, :string
530
+ field :email, :string
531
+ field :department, :string
532
+ field :position, :string
533
+ field :start_date, :date
534
+ field :locale, :string
535
+ field? :notes, :string
536
+
537
+ context :locale
414
538
 
415
539
  normalizes :email, with: -> { _1&.strip&.downcase.presence }
416
540
 
417
- validates :email, presence: true, uniqueness: { model: User }
418
- validates :first_name, :last_name, :department, :position, presence: true
419
- validates :start_date, presence: true
541
+ validates :email, uniqueness: { model: User }
420
542
 
421
543
  nested_one :address do
422
- attribute :street, :string
423
- attribute :city, :string
424
- attribute :postal_code, :string
425
- attribute :country, :string
426
-
427
- validates :street, :city, :country, presence: true
544
+ field :street, :string
545
+ field :city, :string
546
+ field :postal_code, :string
547
+ field :country, :string
548
+ field? :apartment, :string
428
549
  end
429
550
 
430
551
  nested_many :documents do
431
- attribute :document_type, :string
432
- attribute :document_number, :string
433
-
434
- validates :document_type, :document_number, presence: true
552
+ field :document_type, :string
553
+ field :document_number, :string
435
554
  end
436
555
 
437
556
  def self.for(user)
@@ -484,23 +603,38 @@ Forms are standard ActiveModel objects. Test them with plain Minitest — no spe
484
603
 
485
604
  ```ruby
486
605
  class OnboardingFormTest < Minitest::Test
487
- def test_validates_required_fields
606
+ def test_required_fields_validated
488
607
  form = OnboardingForm.new
489
608
  assert form.invalid?
490
609
  assert form.errors[:email].any?
491
610
  assert form.errors[:first_name].any?
492
611
  end
493
612
 
613
+ def test_optional_fields_allowed_blank
614
+ form = OnboardingForm.new(
615
+ first_name: "Alice", last_name: "Smith",
616
+ email: "alice@example.com", department: "Eng",
617
+ position: "Dev", start_date: Date.today, locale: "en"
618
+ )
619
+ assert form.valid?
620
+ assert_nil form.notes
621
+ end
622
+
494
623
  def test_normalizes_email
495
624
  form = OnboardingForm.new(email: " ALICE@EXAMPLE.COM ")
496
625
  assert_equal "alice@example.com", form.email
497
626
  end
498
627
 
628
+ def test_context_fills_locale
629
+ form = Dex.with_context(locale: "en") { OnboardingForm.new }
630
+ assert_equal "en", form.locale
631
+ end
632
+
499
633
  def test_nested_validation_propagation
500
634
  form = OnboardingForm.new(
501
635
  first_name: "Alice", last_name: "Smith",
502
636
  email: "alice@example.com", department: "Eng",
503
- position: "Developer", start_date: Date.today,
637
+ position: "Developer", start_date: Date.today, locale: "en",
504
638
  address: { street: "", city: "", country: "" }
505
639
  )
506
640
  assert form.invalid?
@@ -516,5 +650,12 @@ class OnboardingFormTest < Minitest::Test
516
650
  assert_equal "Alice", h[:first_name]
517
651
  assert_equal "123 Main", h[:address][:street]
518
652
  end
653
+
654
+ def test_json_schema_export
655
+ schema = OnboardingForm.to_json_schema
656
+ assert_equal "object", schema[:type]
657
+ assert_includes schema[:required], "first_name"
658
+ refute_includes schema[:required], "notes"
659
+ end
519
660
  end
520
661
  ```
@@ -111,7 +111,7 @@ Optional declarations documenting intent and catching mistakes at runtime.
111
111
  success _Ref(User) # perform must return a User (or nil)
112
112
  ```
113
113
 
114
- **`error(*codes)`** — restricts which codes `error!`/`assert!` accept (raises `ArgumentError` on undeclared):
114
+ **`error(*codes)`** — restricts which codes `error!` accepts (raises `ArgumentError` on undeclared):
115
115
 
116
116
  ```ruby
117
117
  error :email_taken, :invalid_email
@@ -264,7 +264,7 @@ info = Order::Place.explain(product: product, customer: customer, quantity: 2)
264
264
  # transaction: { enabled: true },
265
265
  # rescue_from: { "Stripe::CardError" => :card_declined },
266
266
  # callbacks: { before: 1, after: 2, around: 0 },
267
- # pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
267
+ # pipeline: [:trace, :result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
268
268
  # callable: true
269
269
  # }
270
270
  ```
@@ -297,13 +297,6 @@ success!(user) # return value early
297
297
  success!(name: "John", age: 30) # kwargs become Hash
298
298
  ```
299
299
 
300
- **`assert!(code, &block)` / `assert!(value, code)`** — returns value if truthy, otherwise `error!(code)`:
301
-
302
- ```ruby
303
- user = assert!(:not_found) { User.find_by(id: id) }
304
- assert!(user.active?, :inactive)
305
- ```
306
-
307
300
  **Dex::Error** has `code` (Symbol), `message` (String, defaults to code.to_s), `details` (any). Pattern matching:
308
301
 
309
302
  ```ruby
@@ -317,7 +310,7 @@ rescue Dex::Error => e
317
310
  end
318
311
  ```
319
312
 
320
- **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks, but are still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
313
+ **Key differences:** `error!` rolls back transaction, skips `after` callbacks, but is still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
321
314
 
322
315
  ---
323
316
 
@@ -351,6 +344,10 @@ end
351
344
 
352
345
  `Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
353
346
 
347
+ **Deconstruct forms:**
348
+ - Hash: `in Dex::Ok(key:)` — destructures value's keys. `in Dex::Err(code:, message:)` — named error fields.
349
+ - Array: `in Dex::Ok[value]` — binds entire value. `in Dex::Err[error]` — binds the `Dex::Error` instance.
350
+
354
351
  ---
355
352
 
356
353
  ## Rescue Mapping
@@ -464,16 +461,82 @@ On timeout: raises `Dex::Error(code: :lock_timeout)`. Works with `.safe`.
464
461
  Enqueue as background jobs (requires ActiveJob):
465
462
 
466
463
  ```ruby
467
- CreateUser.new(email: "a@b.com", name: "Alice").async.call
468
- CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
469
- CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
470
- CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
464
+ ticket = CreateUser.new(email: "a@b.com", name: "Alice").async.call
465
+ ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
466
+ ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
467
+ ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
471
468
  ```
472
469
 
473
470
  Class-level defaults: `async queue: "mailers"`. Runtime options override.
474
471
 
475
472
  Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref` — all handled). Non-serializable props raise `ArgumentError` at enqueue time.
476
473
 
474
+ ### Ticket
475
+
476
+ `async.call` returns a `Dex::Operation::Ticket` with `record` (operation record if recording enabled) and `job` (the enqueued job).
477
+
478
+ ```ruby
479
+ ticket.id # record ID
480
+ ticket.operation_name # operation class name
481
+ ticket.status # "pending"/"running"/"completed"/"error"/"failed"
482
+ ticket.recorded? # true if record strategy was used
483
+ ticket.pending? # status predicates
484
+ ticket.terminal? # completed? || error? || failed?
485
+ ticket.reload # refresh from DB, returns self
486
+ ticket.to_param # id.to_s (Rails path helpers)
487
+ ticket.as_json # { "id": ..., "name": ..., "status": ..., "result"?: ..., "error"?: ... }
488
+ ```
489
+
490
+ `Ticket.from_record(record)` constructs from any operation record (for polling endpoints/dashboards).
491
+
492
+ Record-dependent methods raise `ArgumentError` when `record` is nil (direct strategy without recording).
493
+
494
+ ### Outcome Reconstruction
495
+
496
+ `ticket.outcome` reconstructs `Ok`/`Err` from the record — same types as `.safe.call`:
497
+
498
+ - `completed` → `Ok(result)` with deep-symbolized keys
499
+ - `error` → `Err(Dex::Error)` with symbolized code/details
500
+ - `failed`/`pending`/`running` → `nil`
501
+
502
+ Never raises, never reloads. Call `reload` first for fresh data.
503
+
504
+ ### wait / wait!
505
+
506
+ Speculative sync — poll until terminal or timeout:
507
+
508
+ ```ruby
509
+ # Safe mode (Ok/Err/nil)
510
+ case ticket.wait(3.seconds)
511
+ in Dex::Ok(url:)
512
+ redirect_to url
513
+ in Dex::Err(code:, message:)
514
+ flash[:error] = message
515
+ redirect_to fallback_path
516
+ else
517
+ redirect_to pending_path(ticket)
518
+ end
519
+
520
+ # Strict mode (value or exception)
521
+ result = ticket.wait!(3.seconds)
522
+ redirect_to result[:url]
523
+ ```
524
+
525
+ | | Success | Business Error | Infra Crash | Timeout |
526
+ |---|---|---|---|---|
527
+ | `call` | value | `Dex::Error` | exception | n/a |
528
+ | `safe.call` | `Ok` | `Err` | exception | n/a |
529
+ | `wait!(t)` | value | `Dex::Error` | `OperationFailed` | `Dex::Timeout` |
530
+ | `wait(t)` | `Ok` | `Err` | `OperationFailed` | `nil` |
531
+
532
+ Options: `wait(timeout, interval: 0.2)`. Interval accepts a number or callable (`->(n) { ... }`).
533
+
534
+ `Dex::OperationFailed` (inherits `StandardError`): `operation_name`, `exception_class`, `exception_message`.
535
+ `Dex::Timeout` (inherits `StandardError`): `timeout`, `ticket_id`, `operation_name`.
536
+ Neither inherits `Dex::Error` — `rescue Dex::Error` never catches them.
537
+
538
+ `safe` and `async` are non-composable — `op.safe.async` and `op.async.safe` raise `NoMethodError` with prescriptive messages.
539
+
477
540
  ---
478
541
 
479
542
  ## Recording
@@ -481,8 +544,12 @@ Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref
481
544
  Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
482
545
 
483
546
  ```ruby
484
- create_table :operation_records do |t|
547
+ create_table :operation_records, id: :string do |t|
485
548
  t.string :name, null: false # operation class name
549
+ t.string :trace_id # shared trace / correlation ID
550
+ t.string :actor_type # root actor type
551
+ t.string :actor_id # root actor ID
552
+ t.jsonb :trace # full trace snapshot
486
553
  t.jsonb :params # serialized props (nil = not captured)
487
554
  t.jsonb :result # serialized return value
488
555
  t.string :status, null: false # pending/running/completed/error/failed
@@ -498,6 +565,8 @@ end
498
565
  add_index :operation_records, :name
499
566
  add_index :operation_records, :status
500
567
  add_index :operation_records, [:name, :status]
568
+ add_index :operation_records, :trace_id
569
+ add_index :operation_records, [:actor_type, :actor_id]
501
570
  ```
502
571
 
503
572
  Control per-operation:
@@ -518,12 +587,37 @@ Required attributes by feature:
518
587
  - Async record jobs: `params`
519
588
  - `once`: `once_key`, plus `once_key_expires_at` when `expires_in:` is used
520
589
 
590
+ Trace columns (`id`, `trace_id`, `actor_type`, `actor_id`, `trace`) are recommended for tracing. Dex persists them when present, omits them when missing.
591
+
521
592
  Untyped results are sanitized to JSON-safe values before persistence: Hash keys round-trip as strings, and objects fall back to `as_json`/`to_s` under `"_dex_value"`.
522
593
 
523
594
  Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
524
595
 
525
596
  When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
526
597
 
598
+ ## Execution tracing
599
+
600
+ Every operation call gets an `op_...` execution ID and joins a fiber-local trace shared across operations, handlers, and async jobs.
601
+
602
+ ```ruby
603
+ Dex::Trace.start(actor: { type: :user, id: current_user.id }) do
604
+ Order::Place.call(product: product, customer: customer, quantity: 2)
605
+ end
606
+
607
+ Dex::Trace.trace_id # => "tr_..."
608
+ Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
609
+ Dex::Trace.to_s # => "user:42 > Order::Place(op_2nFg7K)"
610
+ Dex.actor # => { type: "user", id: "123" } or nil
611
+ ```
612
+
613
+ `Dex.actor` returns the actor hash (reconstituted to the shape you passed in), or `nil`. Use it in `perform` when you need to write actor info into domain models.
614
+
615
+ `Dex.system(name = nil)` is a convenience for background jobs: `Dex::Trace.start(actor: Dex.system("payroll")) { ... }`.
616
+
617
+ Tracing is always on – no opt-in needed. Async operations serialize and restore the trace automatically. When recording is enabled, `trace_id`, `actor_type`, `actor_id`, and `trace` are persisted alongside the usual record fields.
618
+
619
+ IDs are generated by `Dex::Id` – a general-purpose Stripe-style ID generator. Use it for your own models: `Dex::Id.generate("ord_")`. Parse with `Dex::Id.parse(id)` to extract prefix, timestamp, and random components.
620
+
527
621
  ---
528
622
 
529
623
  ## Idempotency (once)
@@ -577,7 +671,7 @@ ChargeOrder.clear_once!("webhook-123") # by raw string key
577
671
 
578
672
  Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
579
673
 
580
- **Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
674
+ **Pipeline position:** trace → result → guard → **once** → lock → record → transaction → rescue → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
581
675
 
582
676
  **Requirements:**
583
677
 
@@ -649,19 +743,6 @@ refute_err result
649
743
  refute_err result, :not_found # Ok OR different code
650
744
  ```
651
745
 
652
- ### One-Liner Assertions
653
-
654
- Call + assert in one step:
655
-
656
- ```ruby
657
- assert_operation(email: "a@b.com", name: "Alice") # Ok
658
- assert_operation(CreateUser, email: "a@b.com", name: "Alice") # explicit class
659
- assert_operation(email: "a@b.com", name: "Alice", returns: user) # check value
660
-
661
- assert_operation_error(:invalid_email, email: "bad", name: "A")
662
- assert_operation_error(CreateUser, :email_taken, email: "taken@b.com", name: "A")
663
- ```
664
-
665
746
  ### Contract Assertions
666
747
 
667
748
  ```ruby
@@ -688,7 +769,7 @@ refute_callable(:unauthorized, post: post, user: user) # specific guar
688
769
  refute_callable(PublishPost, :unauthorized, post: post, user: user)
689
770
  ```
690
771
 
691
- Guard failures on the normal `call` path produce `Dex::Error`, so `assert_operation_error` and `assert_err` also work.
772
+ Guard failures on the normal `call` path produce `Dex::Error`, so `assert_err` also works.
692
773
 
693
774
  ### Param Validation
694
775
 
@@ -714,21 +795,6 @@ assert_rolls_back(User) { CreateUser.call(email: "bad", name: "A") }
714
795
  assert_commits(User) { CreateUser.call(email: "ok@b.com", name: "A") }
715
796
  ```
716
797
 
717
- ### Batch Assertions
718
-
719
- ```ruby
720
- assert_all_succeed(params_list: [
721
- { email: "a@b.com", name: "A" },
722
- { email: "b@b.com", name: "B" }
723
- ])
724
-
725
- assert_all_fail(code: :invalid_email, params_list: [
726
- { email: "", name: "A" },
727
- { email: "no-at", name: "B" }
728
- ])
729
- # Also supports message: and details: options
730
- ```
731
-
732
798
  ### Stubbing
733
799
 
734
800
  Replace an operation within a block. Bypasses all wrappers, not recorded in TestLog:
@@ -798,19 +864,9 @@ class CreateUserTest < Minitest::Test
798
864
  assert_ok(result) { |user| assert_equal "Alice", user.name }
799
865
  end
800
866
 
801
- def test_one_liner
802
- assert_operation(email: "a@b.com", name: "Alice")
803
- end
804
-
805
867
  def test_rejects_bad_email
806
- assert_operation_error(:invalid_email, email: "bad", name: "A")
807
- end
808
-
809
- def test_batch_rejects
810
- assert_all_fail(code: :invalid_email, params_list: [
811
- { email: "", name: "A" },
812
- { email: "no-at", name: "B" }
813
- ])
868
+ result = call_operation(email: "bad", name: "A")
869
+ assert_err result, :invalid_email
814
870
  end
815
871
 
816
872
  def test_stubs_dependency
@@ -887,6 +943,8 @@ end
887
943
 
888
944
  Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
889
945
 
946
+ See `TOOL.md` for the full Tool reference including query tools.
947
+
890
948
  ---
891
949
 
892
950
  **End of reference.**