smith-agents 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.md +116 -6
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +502 -1
  7. data/docs/workflow_claim.md +4 -1
  8. data/lib/smith/agent/lifecycle.rb +19 -10
  9. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  10. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  11. data/lib/smith/agent/registry.rb +8 -0
  12. data/lib/smith/agent.rb +24 -2
  13. data/lib/smith/budget/amount_contract.rb +67 -0
  14. data/lib/smith/budget/amount_representation.rb +43 -0
  15. data/lib/smith/budget/decimal_context.rb +18 -0
  16. data/lib/smith/budget/ledger.rb +110 -18
  17. data/lib/smith/budget/ledger_publication.rb +60 -0
  18. data/lib/smith/budget/ledger_state.rb +21 -0
  19. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  20. data/lib/smith/budget/public_state_validator.rb +25 -0
  21. data/lib/smith/budget/reservation.rb +20 -0
  22. data/lib/smith/budget/reservation_contract.rb +34 -0
  23. data/lib/smith/context/observation_masking.rb +3 -1
  24. data/lib/smith/context/state_injection.rb +1 -1
  25. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
  26. data/lib/smith/error.rb +5 -0
  27. data/lib/smith/errors.rb +6 -0
  28. data/lib/smith/exponential_backoff.rb +128 -0
  29. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  31. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  32. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  33. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  34. data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
  35. data/lib/smith/persistence_adapters/cache_store.rb +4 -1
  36. data/lib/smith/persistence_adapters/memory.rb +45 -14
  37. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  38. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  39. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  40. data/lib/smith/persistence_adapters/redis_store.rb +38 -43
  41. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  42. data/lib/smith/persistence_adapters/retry.rb +22 -10
  43. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  44. data/lib/smith/persistence_adapters.rb +26 -18
  45. data/lib/smith/persistence_payload_conflict.rb +12 -0
  46. data/lib/smith/tool/budget_enforcement.rb +2 -2
  47. data/lib/smith/types.rb +1 -0
  48. data/lib/smith/version.rb +2 -1
  49. data/lib/smith/workflow/branch_env.rb +1 -1
  50. data/lib/smith/workflow/budget_integration.rb +21 -11
  51. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  52. data/lib/smith/workflow/definition_identity.rb +25 -0
  53. data/lib/smith/workflow/deterministic_step.rb +2 -2
  54. data/lib/smith/workflow/dsl.rb +73 -5
  55. data/lib/smith/workflow/durability.rb +2 -1
  56. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  57. data/lib/smith/workflow/execution.rb +16 -18
  58. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  59. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  60. data/lib/smith/workflow/fanout_execution.rb +1 -1
  61. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  62. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  63. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  64. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  65. data/lib/smith/workflow/graph/metrics.rb +1 -3
  66. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  67. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  68. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  69. data/lib/smith/workflow/graph/reachability.rb +19 -31
  70. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  71. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  72. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  73. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  74. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  75. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  76. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  77. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  78. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  79. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  80. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  81. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  82. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  83. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  84. data/lib/smith/workflow/graph/validator.rb +1 -1
  85. data/lib/smith/workflow/graph.rb +62 -4
  86. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  87. data/lib/smith/workflow/identifier.rb +24 -0
  88. data/lib/smith/workflow/message_admission.rb +40 -0
  89. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  90. data/lib/smith/workflow/message_batch.rb +60 -0
  91. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  92. data/lib/smith/workflow/nested_execution.rb +10 -0
  93. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  94. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  95. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  96. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  97. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  98. data/lib/smith/workflow/parallel.rb +19 -22
  99. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  100. data/lib/smith/workflow/parallel_execution.rb +7 -3
  101. data/lib/smith/workflow/persistence.rb +49 -10
  102. data/lib/smith/workflow/prepared_step.rb +122 -0
  103. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  104. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  105. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  106. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  107. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  108. data/lib/smith/workflow/retry_execution.rb +20 -11
  109. data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
  110. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  111. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  112. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
  113. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  114. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  115. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  116. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  117. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  118. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  119. data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
  120. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  121. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  122. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  123. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  124. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  125. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  126. data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
  127. data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
  128. data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
  129. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
  130. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  131. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
  132. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  133. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  134. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  135. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  136. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  137. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  138. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
  139. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  140. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  141. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  142. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  143. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  144. data/lib/smith/workflow/split_step_persistence.rb +79 -0
  145. data/lib/smith/workflow/step_completion.rb +31 -0
  146. data/lib/smith/workflow/string_snapshot.rb +25 -0
  147. data/lib/smith/workflow/transition.rb +122 -133
  148. data/lib/smith/workflow/transition_actionability.rb +22 -0
  149. data/lib/smith/workflow.rb +57 -19
  150. data/lib/smith.rb +42 -2
  151. data/sig/smith.rbs +1 -0
  152. metadata +131 -19
data/docs/PERSISTENCE.md CHANGED
@@ -120,6 +120,508 @@ workflow.clear_persisted!("ticket:T-1042")
120
120
 
121
121
  These helpers do not make Smith a job system or durable runtime. They only remove repetitive restore/checkpoint boilerplate around the configured persistence adapter while leaving queueing, projection, and recovery policy with the host app.
122
122
 
123
+ ### Host-Coordinated Message Admission
124
+
125
+ At a stable workflow boundary, a host can append one message or a bounded batch
126
+ to the persisted session history:
127
+
128
+ ```ruby
129
+ workflow = ReviewWorkflow.restore("ticket:T-1042", adapter: adapter)
130
+ admission = workflow.append_session_messages!(
131
+ role: :user,
132
+ content: { answer: "The order id is 1042" }
133
+ )
134
+
135
+ workflow.persist!("ticket:T-1042", adapter: adapter)
136
+ ```
137
+
138
+ Smith canonicalizes String and Symbol keys, owns an immutable JSON-like copy,
139
+ appends the batch under the workflow lifecycle mutex, and returns an immutable
140
+ `Smith::Workflow::MessageAdmission`. Its `message_digest` is the SHA-256 digest
141
+ of the canonical message batch and can be correlated with host-owned evidence.
142
+
143
+ Admission is bounded to 100 messages, 64 levels, 100,000 visited values, and one
144
+ MiB of canonical JSON. Integers must fit a signed 64-bit value, Floats must be
145
+ finite, Hash keys must be String or Symbol values, and cyclic or unsupported
146
+ values fail closed. Work is `O(N + sum(K log K))`, where `N` is the number of
147
+ visited values and each `K` is the key count of one Hash; retained output is
148
+ `O(N)`.
149
+
150
+ This API deliberately does not persist, resume, schedule, identify a session,
151
+ or provide idempotency. A durable host must lock its run and checkpoint, verify
152
+ the exact before identity, call admission, persist the next Smith version, and
153
+ record its own cause and before/after evidence atomically. Calling the method
154
+ while ordinary execution or a prepared split-step boundary is active is
155
+ rejected.
156
+
157
+ ### Host-Coordinated Step Boundaries
158
+
159
+ Hosts can split one strict transition into explicit prepare, optional exact
160
+ dispatch claim, execution authorization, execute, and checkpoint phases:
161
+
162
+ ```ruby
163
+ def execute_one_step(workflow, key, adapter)
164
+ transition_name = nil
165
+
166
+ ApplicationRecord.transaction do
167
+ transition_name = workflow.prepare_persisted_step!(key, adapter: adapter)
168
+ next unless transition_name
169
+
170
+ prepared = workflow.prepared_persisted_step
171
+ HostEvent.create!(
172
+ name: "step.started",
173
+ transition: transition_name,
174
+ operation_token: prepared.token,
175
+ preparation_digest: prepared.preparation_digest
176
+ )
177
+ end
178
+
179
+ return unless transition_name
180
+
181
+ workflow.confirm_prepared_step!
182
+
183
+ if workflow.class.definition_digest
184
+ dispatch = nil
185
+ ApplicationRecord.transaction do
186
+ dispatch = workflow.claim_prepared_step_dispatch!
187
+ HostAttempt.find_by!(operation_token: workflow.prepared_persisted_step.token)
188
+ .update!(
189
+ status: "dispatch_claimed",
190
+ dispatch_token: dispatch.token,
191
+ dispatch_receipt: dispatch.to_h
192
+ )
193
+ end
194
+ workflow.confirm_prepared_step_dispatch!
195
+ end
196
+
197
+ authorization = workflow.authorize_prepared_step_execution!
198
+ execution_started = false
199
+ begin
200
+ operation_token = authorization.prepared_step.token
201
+ dispatch_token = authorization.dispatch_claim&.token
202
+ start_status = dispatch_token ? "dispatch_claimed" : "prepared"
203
+ changed = ApplicationRecord.transaction do
204
+ HostAttempt.where(
205
+ operation_token:,
206
+ dispatch_token:,
207
+ status: start_status
208
+ ).update_all(status: "executing")
209
+ end
210
+ committed = HostAttempt.find_by(operation_token:, dispatch_token:)
211
+ execution_started = changed == 1 && committed&.status == "executing"
212
+ raise "host execution start did not commit exactly once" unless execution_started
213
+ ensure
214
+ workflow.release_prepared_step_execution!(authorization) unless execution_started
215
+ end
216
+
217
+ execution = workflow.execute_authorized_prepared_step!(authorization) # external work; no host transaction
218
+ raise execution.error if execution.failed?
219
+
220
+ step = execution.step
221
+
222
+ ApplicationRecord.transaction do
223
+ workflow.persist!(key, adapter: adapter)
224
+ HostEvent.create!(name: "step.completed", transition: step.fetch(:transition))
225
+ end
226
+
227
+ workflow.complete_persisted_step!
228
+ end
229
+ ```
230
+
231
+ This contract is available only to workflows using `idempotency_mode :strict`,
232
+ a versioned persistence adapter whose `store_versioned` method accepts `ttl:`,
233
+ and non-expiring workflow persistence. Smith explicitly pins `ttl: nil` for
234
+ every boundary write, so a later global configuration change cannot turn an
235
+ accepted checkpoint into expiring state.
236
+ `prepare_persisted_step!` writes the normal `step_in_progress` marker and returns
237
+ the pending transition name without consuming it. Duplicate preparation on the
238
+ same workflow object is rejected. The boundary is pinned to the exact
239
+ transition object, persistence key, and adapter instance selected during
240
+ preparation. The key is copied into an immutable string and the transition
241
+ contract is frozen. When an adapter can report an open transaction,
242
+ `confirm_prepared_step!` must verify the committed preparation before execution;
243
+ it refuses confirmation while that transaction is still open.
244
+ After preparation, `prepared_persisted_step` returns an immutable
245
+ `Smith::Workflow::PreparedStep` descriptor containing the opaque preparation
246
+ token, transition and origin names, pinned persistence key and logical version,
247
+ next step number, and canonical preparation-payload digest. It exposes no
248
+ workflow context, messages, tool results, prompts, or provider output. The
249
+ descriptor is a host correlation witness for
250
+ the active process-local boundary, not a replacement checkpoint or authority to
251
+ reconstruct and execute a workflow. Its scalar identity remains stable through
252
+ execution and checkpointing. Use `prepared_persisted_step.to_h` when serializing
253
+ the descriptor itself. It returns `nil` before persistence
254
+ acknowledges preparation, after an ambiguous preparation outcome, when
255
+ preparation begins on an already-terminal workflow, and after committed
256
+ completion. With a transactional adapter, the
257
+ descriptor is available inside the still-open transaction so the host can write
258
+ its own correlated record atomically. The adapter's exact transaction identity
259
+ is authoritative even when a host propagates that transaction context across
260
+ fibers or executors. The descriptor is a correlation witness, not proof that the
261
+ transaction subsequently committed.
262
+ Use `Smith::Workflow::PreparedStep.deserialize` to restore a descriptor from a
263
+ Hash or bounded JSON object. The decoder rejects missing, duplicated, and
264
+ unknown attributes, and constrains persistence/step counters to positive signed
265
+ 64-bit values; direct `Dry::Struct` construction is not a transport decoder.
266
+
267
+ For a workflow declaring `definition_digest`,
268
+ `claim_prepared_step_dispatch!` atomically replaces the exact `prepared`
269
+ payload with a `dispatching` payload through the adapter's `replace_exact`
270
+ capability. The logical `persistence_version` does not change during this claim.
271
+ Memory uses its monitor, Redis uses WATCH/MULTI/EXEC within the client's native
272
+ no-reconnect scope, and Active Record uses one conditional SQL update while
273
+ advancing its optimistic-lock column. The method
274
+ performs no transition, provider, or tool work,
275
+ so an Active Record host can commit its own attempt-ledger state in the same
276
+ transaction without holding that transaction across external work. After that
277
+ transaction commits, `confirm_prepared_step_dispatch!` verifies the exact
278
+ durable claim. A rolled-back claim restores the known preparation; a missing,
279
+ modified, concurrently claimed, or ambiguously acknowledged payload fails
280
+ closed. `authorize_prepared_step_execution!` then re-verifies the exact durable
281
+ claim and returns one process-local execution capability without performing
282
+ transition, provider, or tool work. A host can commit its own `executing`
283
+ attempt record after authorization and before external work. That host write
284
+ must be a conditional compare-and-set on the exact dispatch identity, and the
285
+ host must re-read committed database state after the outer transaction returns.
286
+ An in-memory record or transaction return value is not commit evidence because
287
+ Rails deliberately swallows `ActiveRecord::Rollback`. If the exact host state
288
+ does not commit, the host must call `release_prepared_step_execution!` with the
289
+ exact capability. `execute_authorized_prepared_step!` consumes it once and
290
+ returns a `PreparedStepExecutionResult`. Its `status`, `failed?`, and `error`
291
+ fields preserve transition failure independently of Smith failure routing; a
292
+ host must not treat a failure-routed workflow state as proof that external work
293
+ succeeded. The result owns an iterative, cycle-aware snapshot bounded to 128
294
+ levels, 100,000 visited values, and 4 MiB of string data. Hashes, Arrays,
295
+ Strings, finite scalar JSON-like values, String or Symbol Hash keys, and the
296
+ exact top-level `StandardError` are supported; other values fail closed. Only the exact-payload
297
+ winner may enter provider, tool, or deterministic step work. Smith snapshots
298
+ and validates a successful step result before advancing workflow state. If the
299
+ external call returns an unsupported or over-limit value, the validation error
300
+ follows the transition's normal failure route and is returned as a typed failed
301
+ result; the success destination is never committed.
302
+
303
+ The authorization rejects copying and the standard Ruby Marshal, Psych/YAML,
304
+ and JSON serialization hooks. It is bound by object identity to one workflow
305
+ instance and by process id to the issuing Ruby process, so a forked child cannot
306
+ consume inherited authority. It is not a lease, cross-process fence, or durable
307
+ attempt receipt.
308
+ Before consuming the capability, a host may inspect the exact captured agent
309
+ bindings with a block:
310
+
311
+ ```ruby
312
+ authorization.each_agent_binding do |registry_name, agent_class|
313
+ verify_host_execution_identity!(registry_name, agent_class)
314
+ end
315
+ ```
316
+
317
+ This bounded enumeration performs no provider or tool work and exposes the same
318
+ classes Smith will execute. It is available only while the authorization is
319
+ active in its issuing process. Calling it after release or consumption, from a
320
+ forked process, or without a block fails closed. Smith intentionally does not
321
+ return an `Enumerator`, because deferred iteration could outlive the capability
322
+ scope. Smith first discovers binding names through a bounded graph traversal,
323
+ then resolves the complete bounded set while holding one registry monitor epoch.
324
+ Concurrent registry writes cannot split one authorization across old and new
325
+ registry states. The registry-owned bulk capture reads concrete agent bindings
326
+ without invoking lazy container callbacks, and all public registry mutation
327
+ paths share the same monitor.
328
+
329
+ While that authority is active, Smith's prepended subclass boundary dispatches
330
+ private execution methods through the Smith-owned implementations captured by
331
+ the runtime. This includes nested child workflows that inherit the same
332
+ authorization. Ordinary workflow runs remain polymorphic and continue to honor
333
+ subclass overrides; the membrane exists only for the authorized external-work
334
+ boundary.
335
+ Until the capability is consumed or released, the host must retain exclusive
336
+ authority over that workflow recovery attempt. A process crash after the host
337
+ commits `executing` is an unknown outcome for the host to reconcile; Smith does
338
+ not infer that transition work did or did not begin.
339
+
340
+ `execute_prepared_step!` remains the compatible one-call API. It internally
341
+ authorizes and consumes the step and returns the legacy step Hash. Hosts that
342
+ need a durable pre-dispatch attempt boundary should use the explicit
343
+ authorization API instead.
344
+
345
+ Active Record exact replacement is deliberately a low-level persistence write.
346
+ It does not instantiate the model, run callbacks or validations, or update
347
+ timestamp columns; the payload and locking columns must therefore be treated as
348
+ infrastructure-owned state. Values still use Active Record type casting and
349
+ serialization. This matches Rails' documented
350
+ [`update_all`](https://api.rubyonrails.org/v8.1.1/classes/ActiveRecord/Relation.html#method-i-update_all)
351
+ contract and keeps the comparison and replacement in one SQL statement.
352
+ Smith supplies byte-exact predicates for PostgreSQL, SQLite, MySQL, and Trilogy
353
+ Active Record adapters and fails closed before mutation on an unknown adapter.
354
+ The payload column must be `text` or `string`; JSON/JSONB columns normalize
355
+ representation and therefore cannot satisfy this byte-identity contract.
356
+
357
+ The claim returns an immutable `Smith::Workflow::PreparedStepDispatch` receipt
358
+ containing the prepared-step descriptor, an opaque dispatch token, and the
359
+ canonical dispatch-payload digest. Persist `receipt.to_h` with the host attempt
360
+ record. Use `PreparedStepDispatch.deserialize` for a bounded Hash/JSON transport
361
+ round-trip; direct `Dry::Struct` construction is not a transport decoder.
362
+
363
+ Preparation also takes an O(S) defensive snapshot of
364
+ mutable workflow execution state, where S is the serialized state size. This
365
+ is the necessary ownership boundary: aliases held before preparation cannot
366
+ change what the accepted transition later consumes, and public mutable-state
367
+ readers and `to_state` return defensive snapshots while the boundary is active.
368
+ The canonical correlation digest is computed before dispatch in O(S log K)
369
+ time, where K is the largest object-key count, and is bounded to 4 MiB and
370
+ 100,000 JSON nodes. Larger state must use Smith artifact references instead of
371
+ an inline split-step payload.
372
+ Subclass execution entry points remain guarded, and subclass TTL helpers cannot
373
+ override the pinned boundary policy. The
374
+ in-memory marker remains armed so any serialization
375
+ before committed completion still fails closed. Other workflow execution and
376
+ checkpoint APIs are rejected until the host checkpoints the accepted state
377
+ through `persist!`. After that transaction commits,
378
+ `complete_persisted_step!` verifies the exact checkpoint through the adapter
379
+ before clearing the marker and releasing the process-local boundary, and
380
+ likewise refuses to run while the adapter reports an open transaction.
381
+ `prepared_persisted_step?` exposes whether the one execution attempt remains
382
+ available without revealing transition internals.
383
+
384
+ #### Restart-Safe Prepared Recovery
385
+
386
+ Cross-process recovery is opt-in. The workflow class must declare a lowercase
387
+ SHA-256 `definition_digest` covering the complete executable definition:
388
+ workflow topology, deterministic code assets, effective agents, prompts,
389
+ models, tools, schemas, and guardrails. Smith validates this digest but does not
390
+ derive it from Ruby reflection. `Proc#source_location` exposes only a filename
391
+ and line, while `RubyVM::InstructionSequence` is MRI-specific,
392
+ version-sensitive, and not portable across machines or Ruby versions. See the
393
+ official [Proc documentation](https://docs.ruby-lang.org/en/master/Proc.html)
394
+ and [InstructionSequence documentation](https://ruby-doc.org/3.2/RubyVM/InstructionSequence.html).
395
+
396
+ ```ruby
397
+ class GeneratedWorkflow < Smith::Workflow
398
+ definition_digest signed_package.digest
399
+ idempotency_mode :strict
400
+ # states and transitions...
401
+ end
402
+
403
+
404
+ adapter = Smith::PersistenceAdapters::ActiveRecordStore.new(
405
+ model: WorkflowState,
406
+ identity: "primary:workflow-states"
407
+ )
408
+
409
+ descriptor = Smith::Workflow::PreparedStep.deserialize(host_receipt.fetch(:prepared_step))
410
+ decision = Smith::Workflow::PreparedStepRecovery.not_started(descriptor)
411
+ workflow = GeneratedWorkflow.recover_prepared_step(decision, adapter: adapter)
412
+ dispatch = workflow.claim_prepared_step_dispatch!
413
+ authorization = workflow.authorize_prepared_step_execution!
414
+ # Conditionally commit the exact host attempt as executing and re-read it here.
415
+ # Release the authorization unless the committed row proves that this worker
416
+ # won; otherwise consume it outside the transaction.
417
+ execution = workflow.execute_authorized_prepared_step!(authorization)
418
+
419
+ # A replacement worker may continue a committed claim only when the host's
420
+ # exclusive attempt ledger proves transition/provider/tool execution did not start.
421
+ dispatch = Smith::Workflow::PreparedStepDispatch.deserialize(host_receipt.fetch(:dispatch_receipt))
422
+ decision = Smith::Workflow::PreparedStepRecovery.not_started(dispatch)
423
+ workflow = GeneratedWorkflow.recover_prepared_step(decision, adapter: adapter)
424
+ authorization = workflow.authorize_prepared_step_execution!
425
+ execution = workflow.execute_authorized_prepared_step!(authorization)
426
+ ```
427
+
428
+ The host may construct `not_started` only after acquiring exclusive recovery
429
+ authority and proving from its durable attempt ledger that provider/tool
430
+ dispatch never started. Smith does not own leases, queues, attempt records, or
431
+ that fact. Recovery performs one fetch, validates the exact canonical payload,
432
+ class name, schema, definition digest, adapter identity, transition, origin,
433
+ step number, token, key, and version, then reconstructs a guarded process-local
434
+ boundary. It refuses an open adapter transaction and never migrates an
435
+ in-progress payload. Ordinary `restore` remains fail-closed.
436
+
437
+ Restart-safe preparation additionally requires a non-expiring adapter exposing
438
+ both `replace_exact` and a stable, bounded `persistence_identity`. Configure the
439
+ same non-secret identity for every process connected to one durable storage
440
+ domain. Memory's generated identity is process-local and is suitable only for
441
+ same-process tests. Cache adapters do not provide exact CAS and are unsupported
442
+ for restart-safe prepared recovery.
443
+
444
+ Recovery authorizes a payload already marked `dispatching` only when the host
445
+ supplies the exact committed `PreparedStepDispatch` receipt and an explicit
446
+ `not_started` decision. The host may make that decision only while holding
447
+ exclusive recovery authority and after its durable attempt ledger proves that
448
+ transition/provider/tool execution never started. Without that proof, a crash
449
+ or lost acknowledgement after the exact claim has an uncertain external
450
+ outcome. Smith never rewinds or executes it on its own.
451
+
452
+ When claim and host-attempt writes share an Active Record transaction,
453
+ `claim_prepared_step_dispatch!` leaves execution unavailable until
454
+ `confirm_prepared_step_dispatch!` runs after commit. A rollback restores the
455
+ known prepared boundary and permits a new exact claim. Any other confirmation
456
+ result remains uncertain and cannot execute.
457
+
458
+ The definition digest is pinned and sealed on the class object when preparation
459
+ or recovery authority is acquired. A concurrent DSL setter either completes
460
+ before sealing and becomes the pinned identity, or raises before claim and
461
+ execution. Repeating the same digest remains idempotent. A reloaded definition
462
+ must use a new class object; recovery through that class still validates its
463
+ digest against the durable descriptor.
464
+
465
+ Exact replacement is a current-value compare-and-swap, not a historical fencing
466
+ ledger. A restart-safe workflow key must be exclusively owned by Smith's
467
+ versioned/exact persistence path. Hosts must not call unconditional `store`,
468
+ delete/recreate the key, or restore an earlier byte-identical payload while a
469
+ boundary is active. Such an out-of-band A-to-B-to-A rewrite is
470
+ information-theoretically indistinguishable from the original value without a
471
+ separate monotonic storage generation. Hosts that permit other writers must
472
+ fence them in their own durable attempt/storage ledger; Smith does not absorb
473
+ that host policy.
474
+
475
+ Redis versioned and exact CAS require a client-native reconnect-disabling
476
+ scope around WATCH/MULTI/EXEC. redis-rb 5.4 exposes `without_reconnect`, while
477
+ newer clients may expose `disable_reconnection`; Smith accepts either and
478
+ refuses CAS before WATCH when neither exists. This follows redis-rb's official
479
+ [reconnection contract](https://github.com/redis/redis-rb#reconnections) and
480
+ prevents a transaction from being replayed after connection-scoped WATCH state
481
+ has been lost.
482
+
483
+ Workflow classes are part of the execution contract once preparation begins.
484
+ Hosts must not add or prepend methods to that class until the boundary is
485
+ complete, and custom `inherited` hooks must call `super`. The same rule applies
486
+ to reachable nested workflow and captured agent classes. Smith captures and
487
+ revalidates nested transition contracts and exact agent class bindings, while a
488
+ host must not concurrently mutate those class objects. Ruby intentionally
489
+ allows open-class mutation. Smith blocks definition-digest changes after the
490
+ class is sealed, while other post-preparation class mutation remains an
491
+ unsupported host lifecycle violation rather than something Smith attempts to
492
+ sandbox.
493
+
494
+ The lifecycle row and Smith checkpoint are atomic only when the persistence
495
+ adapter participates in the same transaction and database connection domain as
496
+ the host record. `ActiveRecordStore` can provide that property when its model
497
+ uses the same database connection; it also reports transaction state so Smith
498
+ cannot confirm an uncommitted boundary. Memory, Redis, cache-backed,
499
+ cross-database, and external adapters do not participate in
500
+ `ApplicationRecord.transaction`; hosts using them must supply their own
501
+ coordination protocol and must not claim the two writes are atomic.
502
+ Custom transactional adapters should implement both `transaction_open?` and
503
+ `transaction_identity`. The identity must be stable for one open transaction or
504
+ savepoint and change for every later transaction. Smith's `ActiveRecordStore`
505
+ uses the public `current_transaction.uuid` API for this exact fence. Smith fails
506
+ before writing when an open transaction has no exact identity capability.
507
+
508
+ If execution raises or the process dies after external work, the durable marker
509
+ remains set. A strict restore therefore fails closed with
510
+ `StepInProgressOnRestore`; the host must reconcile operation results or classify
511
+ the run as uncertain rather than blindly replaying the transition. The same
512
+ in-memory workflow object cannot retry an attempted transition. If a host
513
+ transaction rolls back after a successful `persist!`,
514
+ `complete_persisted_step!` rejects the rolled-back checkpoint. When the adapter
515
+ still contains the exact committed preparation, Smith restores that known
516
+ version and permits the same in-memory object to retry the unchanged checkpoint.
517
+ Any different or missing payload remains uncertain and non-retryable.
518
+
519
+ Ambiguous persistence acknowledgements also fail closed. If preparation may
520
+ have written before raising, the object cannot execute. If a post-step
521
+ checkpoint may have written before raising, `complete_persisted_step!` can
522
+ verify the exact attempted payload without replaying the write. The host must
523
+ perform that reconciliation before retrying. If the adapter still contains the
524
+ exact preparation, Smith permits one unchanged retry; any other result remains
525
+ non-retryable. Smith retains one checkpoint witness, so retry bookkeeping is
526
+ constant space rather than growing with the number of attempts.
527
+
528
+ Transition contract capture uses cycle-aware `O(V + E)` traversal, with time
529
+ and space bounded to 10,000 visits, 4 MiB of string data, and a maximum depth
530
+ of 128. Strings are represented by SHA-256 digests rather than duplicated.
531
+ Supported mutable data shapes are frozen, `Range` endpoints are traversed, and
532
+ opaque mutable objects fail closed instead of receiving a misleading shallow
533
+ signature. Callable identity is pinned for the boundary; Ruby closure state and
534
+ method bodies remain host lifecycle concerns. Smith detects contract
535
+ replacement but does not claim to sandbox Ruby's open object model. Subclass
536
+ entry points remain guarded even when a host prepends modules after class
537
+ creation, and prepared execution bypasses host `advance!` wrappers so they
538
+ cannot become transition execution authority.
539
+
540
+ ## Active Record Optimistic Locking
541
+
542
+ `ActiveRecordStore` keeps two version domains deliberately separate:
543
+
544
+ - `persistence_version` inside the JSON payload is Smith's logical workflow
545
+ version and is compared with `expected_version`.
546
+ - the host model's Active Record `locking_column` is Rails' row-level
547
+ compare-and-swap token and detects concurrent updates between load and save.
548
+
549
+ Across every versioned adapter, a missing key can be created only with
550
+ `expected_version: 0`. A nonzero expected version against missing state raises
551
+ `PersistenceVersionConflict` with `actual: :missing`, preventing stale workflow
552
+ objects from resurrecting host-deleted state.
553
+
554
+ Legacy object-shaped payloads without `persistence_version` use version zero.
555
+ Valid JSON scalars are not workflow-state documents, and explicit null,
556
+ negative, or non-integer versions fail closed instead of being overwritten.
557
+
558
+ The default host model needs a unique key, a JSON/text payload, and Rails'
559
+ standard optimistic-locking column:
560
+
561
+ ```ruby
562
+ create_table :workflow_states do |t|
563
+ t.string :key, null: false, index: { unique: true }
564
+ t.text :payload, null: false
565
+ t.integer :lock_version, null: false, default: 0
566
+ t.timestamps
567
+ end
568
+
569
+ class WorkflowState < ApplicationRecord
570
+ end
571
+
572
+ Smith.configure do |config|
573
+ config.persistence_adapter = :active_record
574
+ config.persistence_options = {
575
+ model: WorkflowState,
576
+ identity: "primary:workflow-states"
577
+ }
578
+ end
579
+ ```
580
+
581
+ For a custom locking column, configure both the host model and adapter. Smith
582
+ validates this relationship but never mutates the host model:
583
+
584
+ ```ruby
585
+ add_column :workflow_states, :workflow_revision, :integer,
586
+ null: false,
587
+ default: 0
588
+
589
+ class WorkflowState < ApplicationRecord
590
+ self.locking_column = :workflow_revision
591
+ end
592
+
593
+ Smith.configure do |config|
594
+ config.persistence_adapter = :active_record
595
+ config.persistence_options = {
596
+ model: WorkflowState,
597
+ version_column: :workflow_revision
598
+ }
599
+ end
600
+ ```
601
+
602
+ The unique key must be enforced by the database. Before every exact write,
603
+ Smith verifies from database metadata that the configured key is the database
604
+ primary key or has its own unconditional single-column unique index. Model-only
605
+ primary-key declarations, partial indexes, and composite indexes are not
606
+ accepted. Smith uses Active Record's table-keyed schema cache; a cold check is
607
+ `O(I)` in the table's index count and never scans workflow rows. Initial
608
+ creation uses Rails' native
609
+ `create_or_find_by!` savepoint path, so a concurrent key insert does not
610
+ invalidate a PostgreSQL caller transaction. A collision on another unique
611
+ constraint is not misreported as a key-version conflict.
612
+
613
+ Versioned writes are single-attempt. Smith translates known connection failures
614
+ to `PersistenceIOError`, but never replays an uncertain write below the
615
+ host-owned transaction boundary. The host must restore and reconcile persisted
616
+ state before deciding whether to retry. Keep the host model persistence-focused:
617
+ callbacks that raise after commit run after the write is permanent and therefore
618
+ create an inherently uncertain outcome.
619
+
620
+ The adapter participates in the caller's Active Record transaction and never
621
+ commits it. Hosts remain responsible for outer transaction boundaries, queue
622
+ claims, fencing, reconciliation, and recovery policy. See the
623
+ [official Rails optimistic-locking documentation](https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html).
624
+
123
625
  ## Artifacts
124
626
 
125
627
  Use artifacts when outputs are too large to keep inline.
@@ -166,4 +668,3 @@ Why this matters:
166
668
  - large payloads can move out of the inline workflow result
167
669
  - refs are execution-scoped
168
670
  - nested workflows inherit artifact scope correctly
169
-
@@ -55,4 +55,7 @@ If the model has AASM and you want to skip events, call `.cas` explicitly; if yo
55
55
 
56
56
  ## Testing
57
57
 
58
- Specs that exercise the AR strategies are tagged `:ar` and excluded from the default suite. The full suite under `SMITH_AR_SPECS=1` boots an in-memory SQLite database and the `ClaimableRecord` fixture model. Default `bundle exec rspec` runs 816 examples (Claim load-hygiene only); `SMITH_AR_SPECS=1 bundle exec rspec` runs 831 (adds 15 AR-tagged Claim specs).
58
+ The default spec suite boots an in-memory SQLite database and runs the tagged
59
+ Active Record strategy examples against the `ClaimableRecord` fixture model.
60
+ Package smoke tests separately verify that requiring Smith does not make Active
61
+ Record a runtime dependency.
@@ -21,9 +21,9 @@ module Smith
21
21
  instance.after_completion(result, context)
22
22
  end
23
23
 
24
- def invoke_agent(agent_class, prepared_input)
24
+ def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
25
25
  check_deadline!
26
- response, model_used = complete_with_provider(agent_class, prepared_input)
26
+ response, model_used = complete_with_provider(agent_class, prepared_input, output_schema:)
27
27
  snapshot_and_finalize(agent_class, response, model_used)
28
28
  end
29
29
 
@@ -33,12 +33,12 @@ module Smith
33
33
  # parallel fan-out, two branches sharing the workflow could race
34
34
  # and attribute the wrong model to the wrong response. Local data
35
35
  # eliminates the race entirely.
36
- def complete_with_provider(agent_class, prepared_input)
36
+ def complete_with_provider(agent_class, prepared_input, output_schema:)
37
37
  models = build_model_chain(agent_class)
38
38
 
39
39
  models.each_with_index do |model_id, index|
40
40
  check_deadline! if index.positive?
41
- response = attempt_model(agent_class, prepared_input, model_id)
41
+ response = attempt_model(agent_class, prepared_input, model_id, output_schema:)
42
42
  return [response, model_id]
43
43
  rescue Smith::Error
44
44
  raise
@@ -71,10 +71,10 @@ module Smith
71
71
  "model block for #{agent_class} must return a non-empty string; got #{result.inspect}"
72
72
  end
73
73
 
74
- def attempt_model(agent_class, prepared_input, model_id)
74
+ def attempt_model(agent_class, prepared_input, model_id, output_schema:)
75
75
  chat = agent_class.chat(model: model_id, **bridge_workflow_inputs(agent_class))
76
76
  add_prepared_input(chat, prepared_input)
77
- chat = chat.with_schema(agent_class.output_schema) if agent_class.output_schema
77
+ chat = chat.with_schema(output_schema) if output_schema
78
78
  chat.complete
79
79
  end
80
80
 
@@ -121,7 +121,7 @@ module Smith
121
121
  end
122
122
 
123
123
  merge_system_messages!(chat, system_messages) if system_messages.any?
124
- other_messages.each { |message| chat.add_message(message) }
124
+ other_messages.each { |message| add_message(chat, message) }
125
125
  end
126
126
 
127
127
  def provider_safe_prepared_input(prepared_input)
@@ -141,7 +141,7 @@ module Smith
141
141
  end
142
142
 
143
143
  def merge_system_messages!(chat, prepared_system_messages)
144
- return prepared_system_messages.each { |message| chat.add_message(message) } unless chat.respond_to?(:messages)
144
+ return prepared_system_messages.each { |message| add_message(chat, message) } unless chat.respond_to?(:messages)
145
145
 
146
146
  existing_system_contents = chat.messages.filter_map do |message|
147
147
  message.content if message_role(message) == :system
@@ -153,16 +153,25 @@ module Smith
153
153
  combined_contents = existing_system_contents + prepared_system_contents
154
154
  return if combined_contents.empty?
155
155
  unless combined_contents.all?(String)
156
- return prepared_system_messages.each { |message| chat.add_message(message) }
156
+ return prepared_system_messages.each { |message| add_message(chat, message) }
157
157
  end
158
158
 
159
159
  if chat.respond_to?(:with_instructions)
160
160
  chat.with_instructions(combined_contents.join("\n\n"))
161
161
  else
162
- prepared_system_messages.each { |message| chat.add_message(message) }
162
+ prepared_system_messages.each { |message| add_message(chat, message) }
163
163
  end
164
164
  end
165
165
 
166
+ def add_message(chat, message)
167
+ attributes = if message.is_a?(Hash)
168
+ message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
169
+ else
170
+ message
171
+ end
172
+ chat.add_message(attributes)
173
+ end
174
+
166
175
  def message_role(message)
167
176
  message_attribute(message, :role)&.to_sym
168
177
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Registry
6
+ module ExecutionBindingCapture
7
+ def capture_bindings!(requests)
8
+ registry_monitor.synchronize do
9
+ requests.each_with_object({}) do |request, bindings|
10
+ key = normalize_key(request.fetch(:name))
11
+ binding = binding_for(key)
12
+ klass = binding&.fetch(:agent_class, nil)
13
+ unless klass
14
+ raise Smith::WorkflowError,
15
+ "unresolved #{request.fetch(:role)} :#{key}" \
16
+ "#{fetch_suffix(request.fetch(:workflow_class), request.fetch(:transition_name))}"
17
+ end
18
+
19
+ bindings[key] = klass
20
+ end.freeze
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Registry
6
+ module MutationBoundary
7
+ def merge(...)
8
+ registry_monitor.synchronize { super }
9
+ end
10
+
11
+ def decorate(...)
12
+ registry_monitor.synchronize { super }
13
+ end
14
+
15
+ def namespace(...)
16
+ registry_monitor.synchronize { super }
17
+ end
18
+
19
+ def import(...)
20
+ registry_monitor.synchronize { super }
21
+ end
22
+
23
+ def configure(...)
24
+ registry_monitor.synchronize { super }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end