phronomy 0.19.0 → 0.20.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34dcf04a611b47c058b1620f34b1663d926421d6db1d98603d282b6b2e49ff0a
4
- data.tar.gz: 60bcf027dbca418974fdf4cd9bbb292a4a8a5e934800db08cee6845b313b2337
3
+ metadata.gz: 8a9f9f2ab4acdb4e17edeb4e0b7f0b33660b7102e5b345c2bd8d411e6b0760de
4
+ data.tar.gz: 331265c52dfadbe4be8f85b9a48b35de384042664538f4fa74db0d34829e3b62
5
5
  SHA512:
6
- metadata.gz: 63a976c4476cf47fe8c6e0d6bc2b3727eb439401f4502106091fd0e6d072ac36513604b1bcfc3ee4a961a29772ed218a21ceb0f75aaab47a7332775ca208f434
7
- data.tar.gz: ff95b192d20e915b5bdc65b23244c68ff27ccc0d6c884e84335b2cc805aa086ac302349c33079207a1377c72eb2cce9fded3d0b1c13da4fd2482d69c166cbe40
6
+ metadata.gz: f3c7e0b15ede3129a2ac426ed86ebde36e353c664b5c64fc559878996ab6331b86f88c289e2f47179f0d1c58e91ec23c0c3d23060f066168c1a441e349dd534f
7
+ data.tar.gz: 527e858a300d3e8fb305782cdab29d6195b3836686df5b4d41a7abf8a40b584de8a8f0dcdab8ccd5b7b5dcfbcbf0c0dad45a7be2f092eae69c2c643ff69e9734
data/CONTRIBUTING.md CHANGED
@@ -52,6 +52,36 @@ When adding, removing, or renaming a public method or class:
52
52
  bundle exec ruby scripts/api_snapshot.rb --write
53
53
  ```
54
54
 
55
+ ### `@api` classification vs Ruby visibility
56
+
57
+ Phronomy's YARD `@api` annotation describes the compatibility boundary; it is
58
+ not a synonym for Ruby's `public` / `protected` / `private` keywords.
59
+
60
+ - `@api public` means consumers or extension implementers may rely on the
61
+ documented contract. Ruby visibility still follows the intended calling
62
+ model: ordinary methods may be public, subclass extension helpers may be
63
+ protected, and `initialize` remains Ruby-private while construction is
64
+ exposed through `.new`.
65
+ - `@api private` means the method is internal and carries no public compatibility
66
+ promise. It may still be Ruby-public when Phronomy components need to call it
67
+ through an explicit receiver.
68
+ - Ruby visibility is therefore not inferred from the `@api` annotation in
69
+ either direction.
70
+
71
+ Run the annotation coverage guard when changing documented methods:
72
+
73
+ ```bash
74
+ ruby scripts/check_api_annotations.rb
75
+ ```
76
+
77
+ Ruby-public compatibility for the primary Stable/Beta product surface is
78
+ protected by `scripts/api_snapshot.rb` and
79
+ `spec/phronomy/api_compatibility_spec.rb`. Extension contracts whose calling
80
+ model is protected/private should be protected by focused specs for that
81
+ contract rather than by a repository-wide visibility inference rule.
82
+
83
+ Do not change Ruby visibility merely to make it match an `@api` annotation.
84
+
55
85
  ---
56
86
 
57
87
  ## Architecture Decision Records
@@ -69,6 +69,11 @@ persistence.workflow_states.save(
69
69
  A stale expected revision raises `Phronomy::Persistence::ConflictError`. Upper
70
70
  layers do not automatically reload and merge after such a conflict.
71
71
 
72
+ The normative contract for custom durable backends, including repository
73
+ semantics, transaction requirements, capabilities, durable codecs, and the Agent
74
+ watermark precondition, is documented in
75
+ [`docs/persistence-backends.md`](../persistence-backends.md).
76
+
72
77
  ### Activation is transient Runtime state
73
78
 
74
79
  `AgentExecutionActivation` is not part of the Persistence contract. Live
data/docs/features.md CHANGED
@@ -19,7 +19,7 @@ for production deployments.
19
19
  |---|---|
20
20
  | **Workflow** — Stateful, branching workflows with `wait_state` and explicit events | Stable |
21
21
  | **Agent** — Stateful ReAct-style agents with stable `agent_id`, persistence-backed execution state, canonical history, guardrails, and conversation context | Stable |
22
- | **Unified Persistence** — One durable backend abstraction for Agent state and Workflow `workflow_states`; live Agent/Workflow state remains owned by the active instance/session between durable commits | Beta |
22
+ | **Unified Persistence** — One durable backend abstraction for Agent state and Workflow `workflow_states`; live Agent/Workflow state remains owned by the active instance/session between durable commits; custom backends implement the documented Backend SPI and repository/transaction semantics | Beta |
23
23
  | **Before-Large-Language-Model (LLM) Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
24
24
  | **Context Management** — Journal + Context Policy + per-LLM-call Manifest with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies | Stable |
25
25
  | **Filters** — Input/output transformation and blocking via `Filter::Base` | Beta |
@@ -78,6 +78,21 @@ Source declarations marked `@api private`, including most EventLoop/FSMSession a
78
78
  OffloadPool internals, are implementation details and may change without the same
79
79
  compatibility guarantees.
80
80
 
81
+ The YARD `@api` classification is independent from Ruby language visibility in
82
+ both directions. `@api public` marks a compatibility contract, but the Ruby
83
+ visibility still follows the intended calling model: ordinary APIs may be
84
+ public, subclass extension helpers may be protected, and constructors use
85
+ Ruby-private `initialize` behind `.new`. `@api private` means "internal/no
86
+ compatibility promise" and does not require a Ruby `private` declaration; some
87
+ internal methods remain Ruby-public because Phronomy components call them
88
+ through explicit receivers.
89
+
90
+ Persistence Backend SPI methods are a deliberate exception to the ordinary
91
+ application-facing interpretation of `@api public`: they are public extension
92
+ contracts for backend implementers, but application business logic should usually
93
+ interact with Agents/Workflows instead of calling low-level repository operations.
94
+ See [Persistence backend contract](persistence-backends.md).
95
+
81
96
  `Phronomy::StateStore` is no longer a public backend abstraction. Workflow
82
97
  durability is provided through `Phronomy::Persistence#workflow_states`; see the
83
98
  0.19 migration guide when upgrading code that used `state_store:`.
@@ -88,6 +103,11 @@ durability is provided through `Phronomy::Persistence#workflow_states`; see the
88
103
  |---|---|
89
104
  | **`Phronomy::Diagnostics`** — Snapshot of EventLoop lag/queue state and OffloadPool activity | Experimental |
90
105
  | **`Phronomy::Testing::FakeClock`** — Test-only deterministic clock helper | Beta |
106
+ | **`Phronomy::Testing::PersistenceContract`** — Explicitly loaded RSpec conformance suite for custom Persistence backends | Beta |
107
+
108
+ `Phronomy::Testing::PersistenceContract` is available only after explicit
109
+ `require "phronomy/testing/persistence_contract"`. Ordinary
110
+ `require "phronomy"` and production eager-load do not load RSpec.
91
111
 
92
112
  For runtime ownership and the distinction between public lifecycle APIs and
93
113
  private execution machinery, see [Runtime and concurrency](runtime-and-concurrency.md).
@@ -79,12 +79,18 @@ persistence.workflow_states.save(
79
79
  A stale revision must raise `Phronomy::Persistence::ConflictError` rather than
80
80
  silently overwriting newer durable state.
81
81
 
82
- Custom durable backends must also implement the internal
83
- `Persistence#assert_agent_watermark!` precondition used before next-LLM durable
84
- barriers. It verifies the caller's Agent revision and Journal position without
85
- returning replacement mutable state; mismatches raise `ConflictError`. SQL
86
- backends should implement this check inside the same database transaction as the
87
- subsequent durable write.
82
+ Custom durable backends must also implement the public Backend SPI operation
83
+ `Persistence#assert_agent_watermark!` used before next-LLM durable barriers. It
84
+ verifies the caller's Agent revision and Journal position without returning
85
+ replacement mutable state; mismatches raise `ConflictError`. SQL backends should
86
+ implement this check inside the same database transaction as the subsequent
87
+ durable write.
88
+
89
+ The complete custom-backend contract is documented in
90
+ [`docs/persistence-backends.md`](../persistence-backends.md). In particular,
91
+ custom backends must provide the required capabilities, transaction atomicity,
92
+ repository compare-and-swap semantics, and durable domain-object reconstruction
93
+ described there.
88
94
 
89
95
  ## Agent ownership and approval resume
90
96
 
@@ -0,0 +1,504 @@
1
+ # Persistence backend contract
2
+
3
+ `Phronomy::Persistence` is the single durable-state backend abstraction used by
4
+ stateful Agents and durable Workflows. This document is the normative contract
5
+ for authors of custom Persistence backends.
6
+
7
+ The Backend SPI is **Beta**. It may evolve in a minor pre-1.0 release, but a
8
+ backend should not depend on Phronomy private APIs or Runtime internals.
9
+
10
+ ## Architecture boundary
11
+
12
+ A backend implements durable storage only:
13
+
14
+ ```text
15
+ Application
16
+
17
+ Agent / Workflow
18
+
19
+ Runtime / EventLoop / ExecutionCoordinator
20
+
21
+ Phronomy::Persistence synchronous Backend SPI
22
+
23
+ Database / durable storage
24
+ ```
25
+
26
+ Persistence does not own live execution state. In particular, a backend must not
27
+ persist or reconstruct the following as part of this SPI:
28
+
29
+ - `AgentExecutionActivation`;
30
+ - `AgentInvocation`;
31
+ - `FSMSession`;
32
+ - `Task` or callbacks;
33
+ - EventLoop queue contents;
34
+ - Runtime Workflow admission entries;
35
+ - in-flight provider operations.
36
+
37
+ Persistence operations are synchronous. Framework-owned blocking Persistence I/O
38
+ is submitted to the Runtime OffloadPool by Phronomy; a backend must not post
39
+ EventLoop events or introduce `load_async` / `save_async` variants into this
40
+ contract.
41
+
42
+ ## Required root surface
43
+
44
+ A Persistence backend exposes five durable repositories:
45
+
46
+ ```text
47
+ contents
48
+ agents
49
+ journals
50
+ executions
51
+ workflow_states
52
+ ```
53
+
54
+ and two root operations:
55
+
56
+ ```ruby
57
+ persistence.transaction { |tx| ... }
58
+ persistence.assert_agent_watermark!(
59
+ agent_id:,
60
+ agent_revision:,
61
+ journal_position:
62
+ )
63
+ ```
64
+
65
+ The object yielded by `transaction` is a transaction-scoped Persistence view. It
66
+ must respond to all five repository accessors and
67
+ `assert_agent_watermark!`. It may be the Persistence instance itself, but SQL
68
+ backends may instead yield an object bound to a checked-out connection or
69
+ transaction session.
70
+
71
+ ## Required capabilities
72
+
73
+ Every backend must advertise:
74
+
75
+ ```ruby
76
+ {
77
+ atomic_all: true,
78
+ atomic_admission: true,
79
+ optimistic_revision: true
80
+ }
81
+ ```
82
+
83
+ `Phronomy::Persistence::REQUIRED_CAPABILITIES` is the executable definition of
84
+ this requirement.
85
+
86
+ ### `atomic_all`
87
+
88
+ All durable repositories must be able to participate in one atomic transaction
89
+ domain. A transaction may change `contents`, `agents`, `journals`, `executions`,
90
+ and `workflow_states` and then either commit all changes or roll them all back.
91
+
92
+ This requirement deliberately does not claim exactly-once semantics after an
93
+ indeterminate database/network failure. If the underlying database cannot tell
94
+ the caller whether a commit happened, the backend should surface the storage
95
+ failure rather than pretending the outcome is known.
96
+
97
+ ### `atomic_admission`
98
+
99
+ This capability refers to **Agent execution admission**, not Workflow distributed
100
+ locking.
101
+
102
+ For one Agent, `executions.create_active` must atomically guarantee both:
103
+
104
+ ```text
105
+ execution_id is unique
106
+ AND
107
+ no active/suspended execution already exists for agent_id
108
+ ```
109
+
110
+ A conflict with an existing active/suspended execution raises
111
+ `Phronomy::AgentBusyError`.
112
+
113
+ Workflow admission remains Runtime/process-local. Cross-process Workflow
114
+ lease/fencing is an application/distributed-coordination concern and is not part
115
+ of this Backend SPI.
116
+
117
+ ### `optimistic_revision`
118
+
119
+ The backend must implement compare-and-swap semantics used by Agent roots,
120
+ Agent executions, Journals, Workflow snapshots, and the durable Agent watermark.
121
+ Stale writers must receive `Phronomy::Persistence::ConflictError`; they must not
122
+ silently overwrite newer durable state.
123
+
124
+ ## Error contract
125
+
126
+ Backends should translate backend-specific constraint errors into the following
127
+ portable Phronomy errors when the meaning matches.
128
+
129
+ ### `Phronomy::Persistence::NotFoundError`
130
+
131
+ A requested durable record does not exist.
132
+
133
+ ### `Phronomy::Persistence::ConflictError`
134
+
135
+ A persistence precondition failed, including revision, Journal position,
136
+ identity, duplicate-ID, or compare-and-swap conflicts.
137
+
138
+ ### `Phronomy::AgentBusyError`
139
+
140
+ An Agent already has an active or suspended execution and another execution
141
+ cannot be admitted.
142
+
143
+ ### `Phronomy::Persistence::SerializationError`
144
+
145
+ The backend cannot encode a value into its supported durable representation.
146
+ This is intended primarily for durable backends whose Workflow state domain is
147
+ narrower than the InMemory backend's Ruby-object domain.
148
+
149
+ ### `Phronomy::Persistence::UnsupportedBackendError`
150
+
151
+ The backend does not provide a required structural or capability contract.
152
+
153
+ Database availability, connection loss, and other transport/storage failures
154
+ must not be misreported as ordinary optimistic conflicts merely to fit this
155
+ error taxonomy.
156
+
157
+ ## Contents repository
158
+
159
+ The content repository should normally inherit from
160
+ `Phronomy::ContentStore::Base`, which supplies text/JSON helpers and the canonical
161
+ content-ID calculation.
162
+
163
+ Required primitive surface:
164
+
165
+ ```ruby
166
+ def put(bytes, canonicalization_version:)
167
+ def fetch(content_id)
168
+ def exist?(content_id)
169
+ ```
170
+
171
+ Required semantics:
172
+
173
+ - content is immutable and content-addressed;
174
+ - writing identical bytes is idempotent and returns the same content ID;
175
+ - `fetch` returns a binary `String` isolated from caller mutation;
176
+ - a missing content ID raises `Persistence::NotFoundError`;
177
+ - one content ID must never resolve to different bytes; a digest-integrity
178
+ violation raises `ContentStore::IntegrityError`.
179
+
180
+ Do not redefine the `sha256:<digest>` identity scheme in a backend. Content
181
+ references are durable data used by other Phronomy records.
182
+
183
+ ## Agents repository
184
+
185
+ Required surface:
186
+
187
+ ```ruby
188
+ def create(root)
189
+ def load(agent_id)
190
+ def save(agent_id, expected_revision:, root:)
191
+ def delete(agent_id)
192
+ ```
193
+
194
+ `create`:
195
+
196
+ - rejects an empty Agent ID;
197
+ - rejects a duplicate Agent ID with `ConflictError`;
198
+ - returns the stored `AgentRoot`.
199
+
200
+ `load`:
201
+
202
+ - returns `Phronomy::Agent::AgentRoot`, not a raw database Hash;
203
+ - raises `NotFoundError` when missing.
204
+
205
+ `save` atomically checks:
206
+
207
+ ```text
208
+ stored.agent_revision == expected_revision
209
+ root.agent_id == requested agent_id
210
+ root.agent_revision == expected_revision + 1
211
+ ```
212
+
213
+ Any failed precondition raises `ConflictError`.
214
+
215
+ `delete` is idempotent.
216
+
217
+ ## Journals repository
218
+
219
+ Required surface:
220
+
221
+ ```ruby
222
+ def append(agent_id, expected_position:, records:)
223
+ def read(agent_id, after: nil, limit: nil)
224
+ def head(agent_id)
225
+ def delete(agent_id)
226
+ ```
227
+
228
+ `append` atomically checks:
229
+
230
+ ```text
231
+ current Journal position == expected_position
232
+ every record.agent_id == agent_id
233
+ record_id is not already present in that Agent Journal
234
+ record_id is not duplicated inside the incoming batch
235
+ ```
236
+
237
+ Successful append assigns monotonically increasing sequences beginning at
238
+ `expected_position + 1` and returns the sequence-bearing `JournalRecord` values.
239
+
240
+ `read` returns records in ascending sequence order. `after: N` means records with
241
+ `sequence > N`; `limit:` caps the returned count. Caller mutation of a returned
242
+ collection must not mutate durable state.
243
+
244
+ `head` returns the current Journal position, or `0` for an empty Journal.
245
+
246
+ ## Executions repository
247
+
248
+ Required surface:
249
+
250
+ ```ruby
251
+ def create_active(execution)
252
+ def load(execution_id)
253
+ def save(execution_id, expected_revision:, execution:)
254
+ def list_active(agent_id)
255
+ def delete(execution_id)
256
+ def delete_for_agent(agent_id)
257
+ def assert_idle!(agent_id)
258
+ ```
259
+
260
+ `create_active` performs atomic Agent admission. A duplicate `execution_id`
261
+ raises `ConflictError`; an already busy Agent raises `AgentBusyError`.
262
+
263
+ `load` returns `Phronomy::Agent::AgentExecution`, not a raw database Hash, and
264
+ raises `NotFoundError` when missing.
265
+
266
+ `save` atomically checks:
267
+
268
+ ```text
269
+ stored.execution_revision == expected_revision
270
+ execution.execution_id == requested execution_id
271
+ execution.execution_revision == expected_revision + 1
272
+ ```
273
+
274
+ A failed precondition raises `ConflictError`.
275
+
276
+ `list_active(agent_id)` returns the Agent's active/suspended executions.
277
+
278
+ `assert_idle!` is used inside transactions before Agent context/Knowledge changes
279
+ and destructive operations. It must raise `AgentBusyError` if an active/suspended
280
+ execution exists. A SQL implementation must make this check part of a consistency
281
+ boundary that cannot race with Agent execution admission; a best-effort SELECT
282
+ outside the transaction is not sufficient.
283
+
284
+ ## Workflow states repository
285
+
286
+ Required surface:
287
+
288
+ ```ruby
289
+ def load(thread_id)
290
+ def save(thread_id, expected_revision:, snapshot:)
291
+ def delete(thread_id, expected_revision:)
292
+ ```
293
+
294
+ `load` returns `nil` when no row exists. Otherwise it returns a Hash containing a
295
+ snapshot and revision. String or Symbol Hash keys are accepted by Phronomy:
296
+
297
+ ```ruby
298
+ {
299
+ snapshot: {
300
+ fields: { ... },
301
+ phase: "awaiting_approval"
302
+ },
303
+ revision: 3
304
+ }
305
+ ```
306
+
307
+ `save` is compare-and-swap:
308
+
309
+ - missing row + `expected_revision: nil` creates revision `1`;
310
+ - existing revision `N` + `expected_revision: N` creates revision `N + 1`;
311
+ - any mismatch raises `ConflictError`.
312
+
313
+ `delete` succeeds only at the supplied current revision; a mismatch raises
314
+ `ConflictError`.
315
+
316
+ Caller mutation of a loaded snapshot must not mutate durable storage.
317
+
318
+ ### Workflow value serialization
319
+
320
+ `WorkflowContext#to_h` may contain ordinary Ruby application values. The
321
+ InMemory backend can preserve a broader set of Ruby values than a JSON database.
322
+ A durable backend is not required to serialize arbitrary Ruby objects such as
323
+ `Proc`, IO objects, sockets, or runtime callbacks.
324
+
325
+ A JSON/JSONB backend should document its supported value domain. A recommended
326
+ domain is:
327
+
328
+ ```text
329
+ nil
330
+ String
331
+ Integer / Float representable by the chosen JSON format
332
+ true / false
333
+ Array of supported values
334
+ Hash with String/Symbol keys and supported values
335
+ ```
336
+
337
+ If a value cannot be represented, raise `Persistence::SerializationError` rather
338
+ than silently converting it into a lossy form. JSON backends may return String
339
+ keys after decoding; `WorkflowRunner` deliberately accepts String and Symbol keys
340
+ and normalizes them when comparing durable snapshots.
341
+
342
+ Do not add generic Ruby object serialization to Phronomy core merely to make a
343
+ particular database backend accept arbitrary Workflow values.
344
+
345
+ ## Durable Agent watermark
346
+
347
+ `assert_agent_watermark!` is a public **Backend SPI** operation. It is not an
348
+ ordinary application API.
349
+
350
+ Phronomy uses it at durable barriers because a hydrated live Agent owns the
351
+ current logical state and Phronomy deliberately does not reload mutable Agent
352
+ state before every LLM/Tool cycle.
353
+
354
+ The backend must verify, in one storage consistency view:
355
+
356
+ ```text
357
+ stored AgentRoot.agent_revision == agent_revision
358
+ current Journal position == journal_position
359
+ ```
360
+
361
+ If the Agent is missing, raise `NotFoundError`. If either watermark component
362
+ differs, raise `ConflictError`. On success return `true`.
363
+
364
+ The operation must not return a replacement AgentRoot or Journal. A mismatch is a
365
+ conflict, not a request to reload/merge mutable state.
366
+
367
+ When used inside `Persistence#transaction`, a SQL backend should perform the
368
+ watermark check in the same database transaction as the subsequent durable
369
+ write.
370
+
371
+ ## Transaction contract
372
+
373
+ A transaction block may combine operations across all repositories:
374
+
375
+ ```ruby
376
+ persistence.transaction do |tx|
377
+ tx.assert_agent_watermark!(...)
378
+ content_ref = tx.contents.put_text("...")
379
+ tx.journals.append(...)
380
+ tx.executions.save(...)
381
+ tx.agents.save(...)
382
+ end
383
+ ```
384
+
385
+ If an exception escapes the block, mutations performed through the transaction
386
+ view must be rolled back as one unit.
387
+
388
+ A backend must not satisfy the SPI by committing Agent, Journal, Execution, or
389
+ Workflow changes in independent transactions and relying on later compensation.
390
+
391
+ ## Durable domain codecs
392
+
393
+ Repositories return Phronomy domain objects. A database backend should not copy
394
+ constructor knowledge for those objects into adapter code.
395
+
396
+ The supported canonical Hash codecs are:
397
+
398
+ ```ruby
399
+ Phronomy::Agent::AgentRoot#to_h
400
+ Phronomy::Agent::AgentRoot.from_h(hash)
401
+
402
+ Phronomy::Agent::JournalRecord#to_h
403
+ Phronomy::Agent::JournalRecord.from_h(hash)
404
+
405
+ Phronomy::Agent::LLMCallRecord#to_h
406
+ Phronomy::Agent::LLMCallRecord.from_h(hash)
407
+
408
+ Phronomy::Agent::AgentExecution#to_h
409
+ Phronomy::Agent::AgentExecution.from_h(hash)
410
+ ```
411
+
412
+ `AgentExecution.from_h` recursively restores nested `working_records` as
413
+ `JournalRecord` objects and nested `llm_calls` as `LLMCallRecord` objects.
414
+ String and Symbol top-level keys are accepted by these new execution/call codecs,
415
+ which permits adapters to use parsed JSON without reimplementing constructors.
416
+
417
+ The canonical Hash representation is the Phronomy/domain boundary. A backend is
418
+ free to map that representation to normalized SQL columns, JSON, or another
419
+ storage format internally.
420
+
421
+ ## Conformance tests
422
+
423
+ Phronomy ships its backend-independent RSpec shared examples as explicit test
424
+ support in the released gem. Backend projects opt in with:
425
+
426
+ ```ruby
427
+ require "phronomy/testing/persistence_contract"
428
+ ```
429
+
430
+ RSpec remains a **development/test dependency of the backend project**, not a
431
+ Phronomy runtime dependency. Ordinary `require "phronomy"` does not load RSpec,
432
+ and Phronomy's production Zeitwerk eager-load explicitly excludes the contract
433
+ support paths.
434
+
435
+ The entry point registers these shared examples:
436
+
437
+ ```text
438
+ a persistence content store
439
+ an Agent repository
440
+ a Journal repository
441
+ an Execution repository
442
+ a workflow state repository
443
+ a Persistence backend
444
+ ```
445
+
446
+ A backend's RSpec suite can apply the complete contract as follows:
447
+
448
+ ```ruby
449
+ require "phronomy"
450
+ require "phronomy/testing/persistence_contract"
451
+
452
+ RSpec.describe MyPersistenceBackend do
453
+ let(:persistence) { described_class.new(...) }
454
+
455
+ it_behaves_like "a persistence content store"
456
+ it_behaves_like "an Agent repository"
457
+ it_behaves_like "a Journal repository"
458
+ it_behaves_like "an Execution repository"
459
+ it_behaves_like "a workflow state repository"
460
+ it_behaves_like "a Persistence backend"
461
+ end
462
+ ```
463
+
464
+ `Persistence::InMemory` is run through the same shipped contract source in
465
+ Phronomy CI. The files under `spec/support/shared_examples/` are compatibility
466
+ require wrappers only; the authoritative shared-example implementations live
467
+ under `lib/phronomy/testing/persistence_contract/` so the core suite and external
468
+ backends cannot drift through copied definitions.
469
+
470
+ The generic suite verifies repository behavior, CAS semantics, admission,
471
+ mutation isolation, and whole-backend transaction behavior. Database-specific
472
+ concurrency/locking mechanisms remain backend integration-test concerns; the SPI
473
+ specifies outcomes rather than a particular SQL locking strategy.
474
+
475
+ ## SQL implementation guidance
476
+
477
+ The SPI specifies outcomes, not a locking mechanism. Typical SQL implementations
478
+ may use combinations of:
479
+
480
+ - unique constraints;
481
+ - conditional `UPDATE ... WHERE revision = ?`;
482
+ - row locks;
483
+ - serializable/repeatable-read isolation where appropriate;
484
+ - partial unique indexes for active Agent execution admission;
485
+ - transaction-scoped checks for Agent revision + Journal head.
486
+
487
+ Backend-specific database exceptions should be translated to the Phronomy error
488
+ contract where their meaning is known.
489
+
490
+ ## Explicit non-goals
491
+
492
+ This Backend SPI does not provide:
493
+
494
+ - durable reconstruction of a lost Agent Activation;
495
+ - serialization of Runtime objects;
496
+ - cross-process Workflow execution exclusion;
497
+ - exactly-once external Tool side effects;
498
+ - automatic conflict reload/merge;
499
+ - a generic serializer registry for arbitrary Workflow field classes;
500
+ - an async Persistence API.
501
+
502
+ For the architectural reasons behind these boundaries, see
503
+ [ADR-014: Unified Persistence for Durable State](decisions/014-unified-persistence-durable-state.md)
504
+ and [Runtime and concurrency](runtime-and-concurrency.md).
@@ -84,6 +84,11 @@ module Phronomy
84
84
  self.class.new(**values)
85
85
  end
86
86
 
87
+ # Returns the canonical durable representation of this execution.
88
+ # Nested JournalRecord and LLMCallRecord values are recursively encoded.
89
+ #
90
+ # @return [Hash{String => Object}]
91
+ # @api public
87
92
  def to_h
88
93
  ATTRIBUTES.to_h do |name|
89
94
  value = public_send(name)
@@ -92,6 +97,30 @@ module Phronomy
92
97
  [name.to_s, value]
93
98
  end
94
99
  end
100
+
101
+ # Restores an execution from its canonical durable representation.
102
+ # String and Symbol top-level keys are accepted. Nested working Journal
103
+ # records and LLM Call records are restored through their public codecs so
104
+ # storage backends do not need to know their constructor details.
105
+ #
106
+ # @param hash [Hash]
107
+ # @return [AgentExecution]
108
+ # @api public
109
+ def self.from_h(hash)
110
+ attributes = ATTRIBUTES.to_h do |name|
111
+ key = hash.key?(name.to_s) ? name.to_s : name
112
+ [name, hash.fetch(key)]
113
+ end
114
+
115
+ attributes[:working_records] = attributes.fetch(:working_records).map do |record|
116
+ record.is_a?(JournalRecord) ? record : JournalRecord.from_h(record)
117
+ end
118
+ attributes[:llm_calls] = attributes.fetch(:llm_calls).map do |call|
119
+ call.is_a?(LLMCallRecord) ? call : LLMCallRecord.from_h(call)
120
+ end
121
+
122
+ new(**attributes)
123
+ end
95
124
  end
96
125
  end
97
126
  end