phronomy 0.19.0 → 0.21.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 +4 -4
- data/.mutant.yml +3 -1
- data/CHANGELOG.md +41 -11
- data/CONTRIBUTING.md +74 -6
- data/README.md +15 -6
- data/docs/decisions/010-cooperative-first-concurrency.md +86 -69
- data/docs/decisions/014-unified-persistence-durable-state.md +5 -0
- data/docs/decisions/015-tool-public-facade-and-rbs-boundary.md +184 -0
- data/docs/features.md +31 -2
- data/docs/getting-started.md +10 -1
- data/docs/migrations/0.19.md +12 -6
- data/docs/persistence-backends.md +504 -0
- data/docs/runtime-and-concurrency.md +110 -147
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/agent/tool_executor.rb +7 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +142 -245
- data/lib/phronomy/engine/task.rb +50 -6
- data/lib/phronomy/invocation_context.rb +11 -52
- data/lib/phronomy/llm_adapter/base.rb +29 -32
- data/lib/phronomy/llm_adapter/ruby_llm.rb +13 -12
- data/lib/phronomy/llm_adapter.rb +10 -7
- data/lib/phronomy/output_parser/base.rb +5 -1
- data/lib/phronomy/persistence.rb +101 -7
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- data/lib/phronomy/tool/base.rb +15 -0
- data/lib/phronomy/tool.rb +11 -0
- data/lib/phronomy/vector_store/async_backend.rb +15 -38
- data/lib/phronomy/vector_store/base.rb +12 -13
- data/lib/phronomy/vector_store/embeddings/base.rb +11 -9
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +6 -0
- data/scripts/run_mutation.sh +2 -1
- data/sig/phronomy/agent.rbs +36 -0
- data/sig/phronomy/extensions.rbs +50 -0
- data/sig/phronomy/llm_adapter.rbs +11 -0
- data/sig/phronomy/persistence.rbs +70 -0
- data/sig/phronomy/runtime.rbs +43 -0
- data/sig/phronomy/tool.rbs +39 -0
- data/sig/phronomy/workflow.rbs +30 -0
- data/sig/phronomy.rbs +49 -1
- metadata +20 -3
- data/scripts/check_private_enforcement.rb +0 -93
|
@@ -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).
|