igniter 0.2.0 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +296 -1
  5. data/docs/BACKLOG.md +166 -0
  6. data/docs/BRANCHES_V1.md +213 -0
  7. data/docs/COLLECTIONS_V1.md +303 -0
  8. data/docs/EXECUTION_MODEL_V2.md +79 -0
  9. data/docs/PATTERNS.md +222 -0
  10. data/docs/STORE_ADAPTERS.md +126 -0
  11. data/examples/README.md +127 -0
  12. data/examples/async_store.rb +47 -0
  13. data/examples/collection.rb +43 -0
  14. data/examples/collection_partial_failure.rb +50 -0
  15. data/examples/marketing_ergonomics.rb +57 -0
  16. data/examples/ringcentral_routing.rb +269 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +90 -0
  18. data/lib/igniter/compiler/graph_compiler.rb +12 -2
  19. data/lib/igniter/compiler/type_resolver.rb +54 -0
  20. data/lib/igniter/compiler/validation_context.rb +61 -0
  21. data/lib/igniter/compiler/validation_pipeline.rb +30 -0
  22. data/lib/igniter/compiler/validator.rb +1 -187
  23. data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
  24. data/lib/igniter/compiler/validators/dependencies_validator.rb +153 -0
  25. data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
  26. data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
  27. data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
  28. data/lib/igniter/compiler.rb +8 -0
  29. data/lib/igniter/contract.rb +152 -4
  30. data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
  31. data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
  32. data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
  33. data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
  34. data/lib/igniter/diagnostics/report.rb +186 -11
  35. data/lib/igniter/dsl/contract_builder.rb +271 -5
  36. data/lib/igniter/dsl/schema_builder.rb +73 -0
  37. data/lib/igniter/dsl.rb +1 -0
  38. data/lib/igniter/errors.rb +11 -0
  39. data/lib/igniter/events/bus.rb +5 -0
  40. data/lib/igniter/events/event.rb +29 -0
  41. data/lib/igniter/executor.rb +74 -0
  42. data/lib/igniter/executor_registry.rb +44 -0
  43. data/lib/igniter/extensions/auditing/timeline.rb +4 -0
  44. data/lib/igniter/extensions/introspection/graph_formatter.rb +33 -3
  45. data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
  46. data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
  47. data/lib/igniter/extensions/introspection.rb +1 -0
  48. data/lib/igniter/extensions/reactive/engine.rb +49 -2
  49. data/lib/igniter/extensions/reactive/reaction.rb +3 -2
  50. data/lib/igniter/model/branch_node.rb +46 -0
  51. data/lib/igniter/model/collection_node.rb +31 -0
  52. data/lib/igniter/model/composition_node.rb +2 -2
  53. data/lib/igniter/model/compute_node.rb +58 -2
  54. data/lib/igniter/model/input_node.rb +2 -2
  55. data/lib/igniter/model/output_node.rb +24 -4
  56. data/lib/igniter/model.rb +2 -0
  57. data/lib/igniter/runtime/cache.rb +64 -25
  58. data/lib/igniter/runtime/collection_result.rb +111 -0
  59. data/lib/igniter/runtime/deferred_result.rb +40 -0
  60. data/lib/igniter/runtime/execution.rb +261 -11
  61. data/lib/igniter/runtime/input_validator.rb +2 -24
  62. data/lib/igniter/runtime/invalidator.rb +1 -1
  63. data/lib/igniter/runtime/job_worker.rb +18 -0
  64. data/lib/igniter/runtime/node_state.rb +20 -0
  65. data/lib/igniter/runtime/planner.rb +126 -0
  66. data/lib/igniter/runtime/resolver.rb +310 -15
  67. data/lib/igniter/runtime/result.rb +14 -2
  68. data/lib/igniter/runtime/runner_factory.rb +20 -0
  69. data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
  70. data/lib/igniter/runtime/runners/store_runner.rb +29 -0
  71. data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
  72. data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
  73. data/lib/igniter/runtime/stores/file_store.rb +43 -0
  74. data/lib/igniter/runtime/stores/memory_store.rb +40 -0
  75. data/lib/igniter/runtime/stores/redis_store.rb +44 -0
  76. data/lib/igniter/runtime.rb +12 -0
  77. data/lib/igniter/type_system.rb +44 -0
  78. data/lib/igniter/version.rb +1 -1
  79. data/lib/igniter.rb +23 -0
  80. metadata +43 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9146f111caa1071ea7ba8ede536e39a00450193c60748a176ab2e6a0cb0fc15
4
- data.tar.gz: 6f120c1d14c286dea458b58dbefbdd1d908a3eca77edad22fe933b145103639a
3
+ metadata.gz: 659dfe833fdf98b7d1b446e08d02464545fd0fe9b61badd5cf8e7ca73beb6a1b
4
+ data.tar.gz: 5af168ce8c6fad1c18dd0d9703281ea64062c3a6d3e954b12172f29210e12c5b
5
5
  SHA512:
6
- metadata.gz: d6de8e1d3228aaa17148abba206fe1185458ef316bd9a7153e3b02b460297bc7d838df47f6c3a95b266a56759bbc3aab3a2de0af68d0834005b863ae1a64f66a
7
- data.tar.gz: 74f0b01d50f7d301c5519af7e54ce4042b02736321af2ae6d9beba3cb79dcbf1464e6ec59751668025cc0ad9012cce5a42ec36ee2fb1b46d23d43306e4366656
6
+ metadata.gz: 5865bc3fb30c137baa5bdf87b63f9023c95c50afa87701a052a47726d139ab6113ed52820f629d0b45de421247778d560133e2c2d2f5e756681a14af2cac0026
7
+ data.tar.gz: de8fa4b7f92a566016f21d81acdbf9d5ec4f4f0fa458ac99b18f8c5cced124390276ea541a1f252067009fd4e3ccf688d97d239080b8995227fc697f0d5b9e6e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.1] - 2026-03-19
4
+
5
+ - Add DX-oriented DSL helpers `project` and `aggregate` for compact extraction and summary nodes.
6
+ - Extend `branch` and `collection` with `map_inputs:` and named `using:` mappers to reduce orchestration wiring noise.
7
+ - Allow `collection` mapper mode to iterate over hash-like sources directly without a preparatory `to_a` node.
8
+ - Add diagnostics-only output presenters via `present` for compact human-facing summaries without changing raw machine-readable outputs.
9
+ - Improve diagnostics formatting for nested branch/collection outputs and clean up inline value rendering for hashes and symbol-heavy summaries.
10
+ - Validate and exercise the new DX surface against private production-like scheduler migration POCs.
11
+
12
+ ## [0.3.0] - 2026-03-19
13
+
14
+ - Add executor metadata and global executor registry for self-describing, schema-friendly execution steps.
15
+ - Split compiler validation into a pluggable validation pipeline and add shared type compatibility checks.
16
+ - Introduce planner/runner runtime architecture with `:inline`, `:thread_pool`, and store-backed execution modes.
17
+ - Add deferred nodes, pending state, snapshot/restore, token-based resume, worker-style resume flow, and reference file/ActiveRecord/Redis store adapters.
18
+ - Expand the DSL with `with`, matcher-style `guard`, `scope`, `namespace`, `branch`, `collection`, `expose`, `on_failure`, and `on_exit`.
19
+ - Add `branch` and `collection` as graph primitives with compile-time validation, nested runtime support, and item-level collection events.
20
+ - Improve diagnostics and auditing with collection summaries, partial-failure visibility, item-level failure reporting, and richer markdown/text output.
21
+ - Add production-like runnable examples for async resume, ergonomic domain contracts, collection partial failure, and nested branch + collection routing.
22
+ - Add design docs for branches, collections, store adapters, and orchestration patterns.
23
+
3
24
  ## [0.2.0] - 2026-03-18
4
25
 
5
26
  - Complete the `arbor` to `igniter` rename across runtime, docs, examples, console setup, and shipped signatures.
data/README.md CHANGED
@@ -9,7 +9,10 @@ Igniter is a Ruby gem for expressing business logic as a validated dependency gr
9
9
  - runtime auditing
10
10
  - diagnostics reports
11
11
  - reactive side effects
12
+ - ergonomic DSL helpers (`with`, `const`, `lookup`, `map`, `project`, `aggregate`, `guard`, `export`, `expose`, `effect`, `on_success`, `scope`, `namespace`, `branch`, `collection`)
12
13
  - graph and runtime introspection
14
+ - async-capable pending nodes with snapshot/restore
15
+ - store-backed execution resume flows
13
16
 
14
17
  The repository now contains a working v2 core built around explicit compile-time and runtime boundaries.
15
18
 
@@ -66,18 +69,25 @@ contract.diagnostics_text
66
69
  - Diagnostics: build compact text, markdown, or structured reports for triage.
67
70
  - Reactive: subscribe declaratively to runtime events.
68
71
  - Introspection: render graphs as text or Mermaid and inspect runtime state.
72
+ - Ergonomics: use compact DSL helpers for common lookup, transform, guard, export, and side-effect patterns.
69
73
 
70
74
  ## Quick Start Recipes
71
75
 
72
76
  The repository contains runnable examples in [`examples/`](examples).
73
77
  They also have matching specs, so they stay in sync with the implementation.
74
78
  The examples folder also has its own quick index in [`examples/README.md`](examples/README.md).
79
+ There is also a short patterns guide in [`docs/PATTERNS.md`](docs/PATTERNS.md).
75
80
 
76
81
  | Example | Run | Shows |
77
82
  | --- | --- | --- |
78
83
  | `basic_pricing.rb` | `ruby examples/basic_pricing.rb` | basic contract, lazy resolution, input updates |
79
84
  | `composition.rb` | `ruby examples/composition.rb` | nested contracts and composed results |
80
85
  | `diagnostics.rb` | `ruby examples/diagnostics.rb` | diagnostics text plus machine-readable output |
86
+ | `async_store.rb` | `ruby examples/async_store.rb` | pending execution, file-backed store, worker-style resume |
87
+ | `marketing_ergonomics.rb` | `ruby examples/marketing_ergonomics.rb` | compact domain DSL with `with`, matcher-style `guard`, `scope`/`namespace`, `expose`, `on_success`, and `explain_plan` |
88
+ | `collection.rb` | `ruby examples/collection.rb` | declarative fan-out, stable item keys, and `CollectionResult` |
89
+ | `collection_partial_failure.rb` | `ruby examples/collection_partial_failure.rb` | `:collect` mode, partial failure summary, and collection diagnostics |
90
+ | `ringcentral_routing.rb` | `ruby examples/ringcentral_routing.rb` | top-level `branch`, nested `collection`, `project`, `aggregate`, `using:`/`map_inputs`, and nested diagnostics semantics |
81
91
 
82
92
  There are also matching living examples in `spec/igniter/examples_spec.rb`.
83
93
  Those are useful if you want to read the examples in test form.
@@ -155,6 +165,195 @@ contract.execution.as_json
155
165
  contract.events.map(&:as_json)
156
166
  ```
157
167
 
168
+ ### 5. Async Store And Resume
169
+
170
+ ```ruby
171
+ class AsyncQuoteExecutor < Igniter::Executor
172
+ input :order_total, type: :numeric
173
+
174
+ def call(order_total:)
175
+ defer(token: "quote-#{order_total}", payload: { kind: "pricing_quote" })
176
+ end
177
+ end
178
+
179
+ class AsyncPricingContract < Igniter::Contract
180
+ run_with runner: :store
181
+
182
+ define do
183
+ input :order_total, type: :numeric
184
+
185
+ compute :quote_total, depends_on: [:order_total], call: AsyncQuoteExecutor
186
+
187
+ compute :gross_total, depends_on: [:quote_total] do |quote_total:|
188
+ quote_total * 1.2
189
+ end
190
+
191
+ output :gross_total
192
+ end
193
+ end
194
+
195
+ contract = AsyncPricingContract.new(order_total: 100)
196
+ deferred = contract.result.gross_total
197
+ execution_id = contract.execution.events.execution_id
198
+
199
+ resumed = AsyncPricingContract.resume_from_store(
200
+ execution_id,
201
+ token: deferred.token,
202
+ value: 150
203
+ )
204
+
205
+ resumed.result.gross_total
206
+ # => 180.0
207
+ ```
208
+
209
+ ### 6. Ergonomic DSL
210
+
211
+ ```ruby
212
+ class MarketingQuoteContract < Igniter::Contract
213
+ define do
214
+ input :service, type: :string
215
+ input :zip_code, type: :string
216
+
217
+ const :vendor_id, "eLocal"
218
+
219
+ scope :routing do
220
+ map :trade_name, from: :service do |service:|
221
+ %w[heating cooling ventilation air_conditioning].include?(service.downcase) ? "HVAC" : service
222
+ end
223
+ end
224
+
225
+ scope :pricing do
226
+ lookup :trade, with: :trade_name do |trade_name:|
227
+ { name: trade_name, base_bid: 45.0 }
228
+ end
229
+ end
230
+
231
+ namespace :validation do
232
+ guard :zip_supported, with: :zip_code, in: %w[60601 10001], message: "Unsupported zip"
233
+ end
234
+
235
+ compute :quote, with: %i[vendor_id trade zip_supported zip_code] do |vendor_id:, trade:, zip_supported:, zip_code:|
236
+ zip_supported
237
+ { vendor_id: vendor_id, trade: trade[:name], zip_code: zip_code, bid: trade[:base_bid] }
238
+ end
239
+
240
+ expose :quote, as: :response
241
+ end
242
+
243
+ on_success :response do |value:, **|
244
+ puts "Persist #{value.inspect}"
245
+ end
246
+ end
247
+
248
+ contract = MarketingQuoteContract.new(service: "heating", zip_code: "60601")
249
+
250
+ contract.explain_plan
251
+ contract.result.response
252
+ ```
253
+
254
+ You can also use matcher-style guards directly:
255
+
256
+ ```ruby
257
+ guard :usa_only, with: :country_code, eq: "USA", message: "Unsupported country"
258
+ guard :supported_country, with: :country_code, in: %w[USA CAN], message: "Unsupported country"
259
+ guard :valid_zip, with: :zip_code, matches: /\A\d{5}\z/, message: "Invalid zip"
260
+ ```
261
+
262
+ ### 7. Declarative Branching
263
+
264
+ ```ruby
265
+ class DeliveryContract < Igniter::Contract
266
+ define do
267
+ input :country
268
+ input :order_total
269
+
270
+ branch :delivery_strategy, with: :country, inputs: {
271
+ country: :country,
272
+ order_total: :order_total
273
+ } do
274
+ on "US", contract: USDeliveryContract
275
+ on "UA", contract: LocalDeliveryContract
276
+ default contract: DefaultDeliveryContract
277
+ end
278
+
279
+ export :price, :eta, from: :delivery_strategy
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### 8. Branch + Collection Routing
285
+
286
+ ```ruby
287
+ class RingcentralWebhookContract < Igniter::Contract
288
+ define do
289
+ input :payload
290
+
291
+ scope :parse do
292
+ map :body, from: :payload do |payload:|
293
+ payload.fetch("body", {})
294
+ end
295
+
296
+ map :telephony_status, from: :body do |body:|
297
+ body["telephonyStatus"]
298
+ end
299
+
300
+ map :active_calls, from: :body do |body:|
301
+ body["activeCalls"] || []
302
+ end
303
+ end
304
+
305
+ branch :status_route, with: :telephony_status, inputs: {
306
+ extension_id: :extension_id,
307
+ telephony_status: :telephony_status,
308
+ active_calls: :active_calls
309
+ } do
310
+ on "CallConnected", contract: CallConnectedContract
311
+ on "NoCall", contract: NoCallContract
312
+ default contract: UnknownStatusContract
313
+ end
314
+
315
+ export :routing_summary, from: :status_route
316
+ end
317
+ end
318
+ ```
319
+
320
+ In nested flows, diagnostics stay attached to the execution that actually owns the node:
321
+
322
+ - the parent execution sees the top-level `branch_selected`
323
+ - collection item events live on the selected child execution
324
+ - collection summaries are easiest to read from the child contract diagnostics
325
+
326
+ `branch` is a graph primitive for explicit routing. It selects one child contract from ordered cases and resolves only the chosen branch.
327
+
328
+ ### 8. Declarative Collections
329
+
330
+ ```ruby
331
+ class TechnicianBatchContract < Igniter::Contract
332
+ define do
333
+ input :technician_inputs, type: :array
334
+
335
+ collection :technicians,
336
+ with: :technician_inputs,
337
+ each: TechnicianContract,
338
+ key: :technician_id,
339
+ mode: :collect
340
+
341
+ output :technicians
342
+ end
343
+ end
344
+ ```
345
+
346
+ `collection` is a graph primitive for explicit fan-out. It runs one child contract per item hash and returns a `CollectionResult` keyed by stable item identity.
347
+
348
+ In `mode: :collect`, a collection can succeed overall while still containing failed items. In that case:
349
+
350
+ - `result.summary` gives collection-level status such as `:partial_failure`
351
+ - `result.items_summary` gives compact per-item status
352
+ - `result.failed_items` gives only failed item details
353
+ - `contract.diagnostics_text` and `contract.diagnostics_markdown` include collection failure summaries
354
+
355
+ See `examples/collection_partial_failure.rb` for a runnable example.
356
+
158
357
  ## Composition Example
159
358
 
160
359
  ```ruby
@@ -195,7 +394,22 @@ class NotifyingContract < Igniter::Contract
195
394
  output :order_total
196
395
  end
197
396
 
198
- react_to :node_succeeded, path: "order_total" do |event:, **|
397
+ on_success :order_total do |value:, **|
398
+ puts "Resolved #{value}"
399
+ end
400
+ end
401
+ ```
402
+
403
+ Or attach directly to a node event when you want the node value:
404
+
405
+ ```ruby
406
+ class NotifyingContract < Igniter::Contract
407
+ define do
408
+ input :order_total, type: :numeric
409
+ output :order_total
410
+ end
411
+
412
+ effect "order_total" do |event:, value:, **|
199
413
  puts "Resolved #{event.path}"
200
414
  end
201
415
  end
@@ -212,6 +426,7 @@ contract.result.gross_total
212
426
 
213
427
  contract.result.states
214
428
  contract.result.explain(:gross_total)
429
+ contract.explain_plan
215
430
  contract.execution.to_h
216
431
  contract.execution.as_json
217
432
  contract.result.as_json
@@ -227,6 +442,10 @@ contract.audit_snapshot
227
442
  - [Architecture v2](docs/ARCHITECTURE_V2.md)
228
443
  - [Execution Model v2](docs/EXECUTION_MODEL_V2.md)
229
444
  - [API Draft v2](docs/API_V2.md)
445
+ - [Patterns](docs/PATTERNS.md)
446
+ - [Branches v1](docs/BRANCHES_V1.md)
447
+ - [Collections v1](docs/COLLECTIONS_V1.md)
448
+ - [Store Adapters](docs/STORE_ADAPTERS.md)
230
449
  - [Concepts and Principles](docs/IGNITER_CONCEPTS.md)
231
450
 
232
451
  ## Direction
@@ -251,6 +470,10 @@ rake spec
251
470
  Current baseline:
252
471
 
253
472
  - synchronous runtime
473
+ - parallel thread-pool runner
474
+ - pending/deferred runtime states
475
+ - snapshot/restore execution lifecycle
476
+ - store-backed resume flow
254
477
  - compile-time graph validation
255
478
  - typed inputs
256
479
  - composition
data/docs/API_V2.md CHANGED
@@ -54,6 +54,7 @@ contract.result.gross_total
54
54
  contract.result.to_h
55
55
  contract.success?
56
56
  contract.failed?
57
+ contract.pending?
57
58
 
58
59
  contract.update_inputs(order_total: 120)
59
60
  contract.result.gross_total
@@ -67,9 +68,11 @@ Suggested instance methods:
67
68
  - `update_inputs`
68
69
  - `events`
69
70
  - `execution`
71
+ - `explain_plan`
70
72
  - `diagnostics`
71
73
  - `success?`
72
74
  - `failed?`
75
+ - `pending?`
73
76
 
74
77
  ## Result API
75
78
 
@@ -82,6 +85,7 @@ Suggested methods:
82
85
  - `as_json`
83
86
  - `success?`
84
87
  - `failed?`
88
+ - `pending?`
85
89
  - `errors`
86
90
  - `states`
87
91
  - `explain`
@@ -119,6 +123,133 @@ Method form:
119
123
  compute :vat_rate, depends_on: [:country], call: :resolve_vat_rate
120
124
  ```
121
125
 
126
+ Executor registry form:
127
+
128
+ ```ruby
129
+ Igniter.register_executor("pricing.multiply", MultiplyExecutor)
130
+
131
+ compute :gross_total,
132
+ depends_on: %i[order_total multiplier],
133
+ executor: "pricing.multiply"
134
+ ```
135
+
136
+ Ergonomic helper forms:
137
+
138
+ ```ruby
139
+ const :vendor_id, "eLocal"
140
+
141
+ lookup :trade, depends_on: [:trade_name] do |trade_name:|
142
+ Trade.enabled.find_by!(name: trade_name)
143
+ end
144
+
145
+ map :normalized_trade_name, from: :service do |service:|
146
+ service.downcase == "heating" ? "HVAC" : service
147
+ end
148
+
149
+ project :telephony_status, from: :body, key: "telephonyStatus"
150
+
151
+ aggregate :available_slots, with: :technicians do |technicians:|
152
+ technicians.successes.values.sum { |item| item.result.summary[:available_slots] }
153
+ end
154
+
155
+ guard :business_hours_valid, depends_on: %i[vendor current_time], message: "Closed" do |vendor:, current_time:|
156
+ current_time.between?(vendor.start_at, vendor.stop_at)
157
+ end
158
+
159
+ expose :bid_details, as: :response
160
+ ```
161
+
162
+ Short dependency alias:
163
+
164
+ ```ruby
165
+ compute :zip_code, with: :zip_code_raw do |zip_code_raw:|
166
+ ZipCode.find_by_code!(zip_code_raw)
167
+ end
168
+ ```
169
+
170
+ Matcher-style guards:
171
+
172
+ ```ruby
173
+ guard :usa_only, with: :country_code, eq: "USA", message: "Unsupported country"
174
+ guard :supported_country, with: :country_code, in: %w[USA CAN], message: "Unsupported country"
175
+ guard :valid_zip, with: :zip_code, matches: /\A\d{5}\z/, message: "Invalid zip"
176
+ ```
177
+
178
+ Declarative routing:
179
+
180
+ ```ruby
181
+ branch :delivery_strategy, with: :country, inputs: {
182
+ country: :country,
183
+ order_total: :order_total
184
+ } do
185
+ on "US", contract: USDeliveryContract
186
+ on "UA", contract: LocalDeliveryContract
187
+ default contract: DefaultDeliveryContract
188
+ end
189
+
190
+ branch :status_route,
191
+ with: :telephony_status,
192
+ depends_on: %i[extension_id active_calls],
193
+ map_inputs: ->(selector:, extension_id:, active_calls:) {
194
+ {
195
+ extension_id: extension_id,
196
+ telephony_status: selector,
197
+ active_calls: active_calls
198
+ }
199
+ } do
200
+ on "CallConnected", contract: CallConnectedContract
201
+ on "NoCall", contract: NoCallContract
202
+ default contract: UnknownStatusContract
203
+ end
204
+ ```
205
+
206
+ Declarative fan-out:
207
+
208
+ ```ruby
209
+ collection :technicians,
210
+ with: :technician_inputs,
211
+ each: TechnicianContract,
212
+ key: :technician_id,
213
+ mode: :collect
214
+
215
+ collection :calls,
216
+ with: :active_calls,
217
+ each: CallEventContract,
218
+ key: :session_id,
219
+ mode: :collect,
220
+ map_inputs: ->(item:) {
221
+ {
222
+ session_id: item.fetch("telephonySessionId"),
223
+ direction: item.fetch("direction"),
224
+ from: item.fetch("from"),
225
+ to: item.fetch("to"),
226
+ start_time: item.fetch("startTime")
227
+ }
228
+ }
229
+ ```
230
+
231
+ For repeated mappers, prefer `using:` over inline lambdas:
232
+
233
+ ```ruby
234
+ collection :company_locations,
235
+ with: :locations_map,
236
+ each: CompanyLocationSchedulerContract,
237
+ key: :location_id,
238
+ depends_on: %i[services_map property_type date],
239
+ using: :build_company_location_inputs
240
+ ```
241
+
242
+ ```ruby
243
+ branch :status_route,
244
+ with: :telephony_status,
245
+ depends_on: %i[extension_id active_calls],
246
+ using: :build_status_route_inputs do
247
+ on "CallConnected", contract: CallConnectedContract
248
+ on "NoCall", contract: NoCallContract
249
+ default contract: UnknownStatusContract
250
+ end
251
+ ```
252
+
122
253
  Rules:
123
254
 
124
255
  - one compute node has one callable
@@ -149,6 +280,99 @@ compose :pricing, contract: PriceContract, inputs: {
149
280
  output :pricing, from: :pricing
150
281
  ```
151
282
 
283
+ Child output export:
284
+
285
+ ```ruby
286
+ output :gross_total, from: "pricing.gross_total"
287
+ ```
288
+
289
+ Bulk child output export:
290
+
291
+ ```ruby
292
+ export :gross_total, :vat_rate, from: :pricing
293
+ ```
294
+
295
+ Branch output export:
296
+
297
+ ```ruby
298
+ export :price, :eta, from: :delivery_strategy
299
+ ```
300
+
301
+ Collection output:
302
+
303
+ ```ruby
304
+ output :technicians
305
+ ```
306
+
307
+ This returns a `CollectionResult` rather than a plain array.
308
+
309
+ Suggested `CollectionResult` surface:
310
+
311
+ - `keys`
312
+ - `successes`
313
+ - `failures`
314
+ - `summary`
315
+ - `items_summary`
316
+ - `failed_items`
317
+ - `to_h`
318
+ - `as_json`
319
+
320
+ Nested routing and fan-out can be combined:
321
+
322
+ ```ruby
323
+ branch :status_route, with: :telephony_status, inputs: {
324
+ extension_id: :extension_id,
325
+ telephony_status: :telephony_status,
326
+ active_calls: :active_calls
327
+ } do
328
+ on "CallConnected", contract: CallConnectedContract
329
+ on "NoCall", contract: NoCallContract
330
+ default contract: UnknownStatusContract
331
+ end
332
+ ```
333
+
334
+ Inside the selected branch contract:
335
+
336
+ ```ruby
337
+ collection :calls,
338
+ with: :call_inputs,
339
+ each: CallEventContract,
340
+ key: :session_id,
341
+ mode: :collect
342
+ ```
343
+
344
+ Diagnostics and audit stay local to the execution that owns the node:
345
+
346
+ - the parent execution records top-level branch selection
347
+ - collection item events belong to the selected child execution
348
+ - collection summaries should usually be read from the child contract diagnostics
349
+
350
+ Pass-through or aliased output exposure:
351
+
352
+ ```ruby
353
+ expose :bid_details, as: :response
354
+ expose :gross_total
355
+ ```
356
+
357
+ Grouped node paths:
358
+
359
+ ```ruby
360
+ scope :availability do
361
+ lookup :vendor, with: %i[trade vendor_id], call: LookupVendor
362
+ lookup :zip_code, with: :zip_code_raw, call: LookupZipCode
363
+ compute :geo_bids, with: %i[zip_code vendor], call: LookupGeoBids
364
+ end
365
+
366
+ namespace :validation do
367
+ guard :valid_zip, with: :zip_code, matches: /\A\d{5}\z/, message: "Invalid zip"
368
+ end
369
+ ```
370
+
371
+ `scope` and `namespace` currently improve structure and introspection by prefixing node paths.
372
+ They do not yet introduce a separate runtime boundary.
373
+
374
+ Branch nodes introduce explicit control flow and behave like composition-like nested results.
375
+
152
376
  Collection composition can be added later, but should not complicate the first kernel API.
153
377
 
154
378
  ## Introspection API
@@ -161,6 +385,8 @@ PriceContract.graph.to_h
161
385
  PriceContract.graph.to_mermaid
162
386
 
163
387
  contract.execution.states
388
+ contract.execution.plan
389
+ contract.explain_plan
164
390
  contract.execution.to_h
165
391
  contract.execution.as_json
166
392
  contract.result.as_json
@@ -168,6 +394,7 @@ contract.events.map(&:as_json)
168
394
  contract.diagnostics.to_h
169
395
  contract.diagnostics.to_text
170
396
  contract.diagnostics.to_markdown
397
+ contract.snapshot
171
398
  ```
172
399
 
173
400
  The main rule is that introspection reads stable compile/runtime objects rather than poking through private internals.
@@ -191,6 +418,66 @@ Or:
191
418
  contract.subscribe(auditor)
192
419
  ```
193
420
 
421
+ Reactive side-effect shorthand:
422
+
423
+ ```ruby
424
+ class LoggingContract < Igniter::Contract
425
+ define do
426
+ input :order_total
427
+ output :order_total
428
+ end
429
+
430
+ effect "order_total" do |event:, value:, **|
431
+ AuditLog.create!(path: event.path, value: value)
432
+ end
433
+ end
434
+ ```
435
+
436
+ Final-output success shorthand:
437
+
438
+ ```ruby
439
+ class PersistingContract < Igniter::Contract
440
+ define do
441
+ input :order_total
442
+ compute :gross_total, depends_on: [:order_total] do |order_total:|
443
+ order_total * 1.2
444
+ end
445
+ expose :gross_total, as: :response
446
+ end
447
+
448
+ on_success :response do |value:, contract:, **|
449
+ AuditLog.create!(response: value, inputs: contract.execution.inputs)
450
+ end
451
+ end
452
+ ```
453
+
454
+ Async/store-backed flow:
455
+
456
+ ```ruby
457
+ contract = AsyncPricingContract.new(order_total: 100)
458
+ deferred = contract.result.gross_total
459
+
460
+ AsyncPricingContract.resume_from_store(
461
+ contract.execution.events.execution_id,
462
+ token: deferred.token,
463
+ value: 150
464
+ )
465
+ ```
466
+
467
+ Reference store adapters:
468
+
469
+ ```ruby
470
+ Igniter.execution_store = Igniter::Runtime::Stores::ActiveRecordStore.new(
471
+ record_class: IgniterExecutionSnapshot
472
+ )
473
+ ```
474
+
475
+ ```ruby
476
+ Igniter.execution_store = Igniter::Runtime::Stores::RedisStore.new(
477
+ redis: Redis.new(url: ENV.fetch("REDIS_URL"))
478
+ )
479
+ ```
480
+
194
481
  Where a subscriber responds to:
195
482
 
196
483
  ```ruby
@@ -238,5 +525,13 @@ Each Igniter error also carries structured context when available:
238
525
  - collection composition
239
526
  - advanced typed schemas
240
527
  - retries
241
- - async executors
242
528
  - Rails-specific DSL sugar
529
+
530
+ ### Now Present In v2 Core
531
+
532
+ - executor registry
533
+ - schema-driven graph compilation
534
+ - thread-pool runner
535
+ - deferred/pending executor protocol
536
+ - execution snapshot/restore
537
+ - store-backed resume flow