effect-ledger 0.1.0__tar.gz

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.
@@ -0,0 +1,307 @@
1
+ Metadata-Version: 2.4
2
+ Name: effect-ledger
3
+ Version: 0.1.0
4
+ Summary: Composable effect recovery with atomic SQLite/PostgreSQL stores, host-owned identity and optional LangGraph/MCP adapters.
5
+ Keywords: langchain,langgraph,mcp,postgresql,agents,durable-execution,idempotency
6
+ Author: donggyun112
7
+ Author-email: donggyun112 <ssddgg99@daum.net>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Requires-Dist: langchain>=1.4.0 ; extra == 'langchain'
14
+ Requires-Dist: langgraph>=1.2.11 ; extra == 'langchain'
15
+ Requires-Dist: langgraph-checkpoint-sqlite>=3,<4 ; extra == 'langchain'
16
+ Requires-Dist: mcp>=1.28,<2 ; extra == 'mcp'
17
+ Requires-Dist: psycopg[binary]>=3.2,<4 ; extra == 'postgres'
18
+ Requires-Python: >=3.10
19
+ Project-URL: Source, https://github.com/donggyun112/effect-ledger
20
+ Provides-Extra: langchain
21
+ Provides-Extra: mcp
22
+ Provides-Extra: postgres
23
+ Description-Content-Type: text/markdown
24
+
25
+ # effect-ledger
26
+
27
+ [![CI](https://github.com/donggyun112/effect-ledger/actions/workflows/ci.yml/badge.svg)](https://github.com/donggyun112/effect-ledger/actions/workflows/ci.yml)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/donggyun112/effect-ledger/blob/main/LICENSE)
29
+
30
+ *[한국어 README](https://github.com/donggyun112/effect-ledger/blob/main/README.ko.md)*
31
+
32
+ Your agent charged the card. The process died before the provider's reply came
33
+ back. The graph resumes from its last checkpoint, calls the tool again, and
34
+ charges the card a second time.
35
+
36
+ That is documented behaviour rather than a bug. A task that started but did not
37
+ finish runs again on resume, and keeping the side effect safe is left to you.
38
+
39
+ **effect-ledger commits a record of the attempt before the effect leaves, so the
40
+ second call finds it.** An attempt whose outcome was never recorded stops as
41
+ `unresolved` and waits for a person. Nothing is retried on a guess.
42
+
43
+ It does not make your provider idempotent and it does not give you exactly-once.
44
+ It records what may already have gone out, and refuses to guess the rest.
45
+
46
+ Start with the [LangChain execution boundary](https://github.com/donggyun112/effect-ledger/blob/main/docs/langchain-boundary.md): one
47
+ middleware over the tools you already have. Everything else is chosen
48
+ separately — the store (SQLite/Postgres), the business ID, the recovery policy —
49
+ and the core depends on no framework. [The composition API and extension
50
+ contract](https://github.com/donggyun112/effect-ledger/blob/main/docs/composition.md) covers that, up to a multi-host store.
51
+
52
+ ```python
53
+ from langchain.agents import create_agent
54
+ from effect_ledger import EffectExecutor
55
+ from effect_ledger.langchain import ExecutionBoundary
56
+ from effect_ledger.langgraph import LedgerRunner
57
+
58
+ boundary = ExecutionBoundary(
59
+ EffectExecutor("effects.sqlite", scope="account-1"),
60
+ workflow_id="mail-agent:v1",
61
+ )
62
+ # model, send_message and saver are your existing model, single-effect tool
63
+ # and durable checkpointer.
64
+ agent = create_agent(model, [send_message], middleware=[boundary], checkpointer=saver)
65
+ runner = LedgerRunner(agent)
66
+ ```
67
+
68
+ Every registered tool is protected by default. Tool names and argument schemas
69
+ are preserved. Read-only and control exceptions, and stable effect names, are
70
+ declared through the `tools` mapping described in the
71
+ [LangChain execution boundary guide](https://github.com/donggyun112/effect-ledger/blob/main/docs/langchain-boundary.md). With several
72
+ tool middlewares, install the boundary last.
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ pip install "effect-ledger[langchain]"
78
+ pip install "effect-ledger[mcp]" # to expose effects over MCP
79
+ pip install "effect-ledger[postgres]" # for a multi-host store
80
+ ```
81
+
82
+ Working inside a clone of this repository, use `uv sync --extra langchain`
83
+ (or `--all-extras`) instead.
84
+
85
+ The core needs Python 3.10+ and the standard library only. The MCP extra targets
86
+ SDK v1 (`mcp>=1.28,<2`). The LangChain execution boundary and the LangGraph
87
+ adapters use the `[langchain]` extra.
88
+
89
+ ## Execution contract
90
+
91
+ The host **stores a logical operation ID durably before the call** and reuses it
92
+ on retry. Do not substitute an MCP request ID or a tool call ID that the model
93
+ regenerates each turn. The server fixes the account/tenant scope and the effect
94
+ name and version.
95
+
96
+ ```text
97
+ host: store operation ID
98
+ → server: bind effect, original JSON and provider key to scope + operation ID
99
+ → store: commit the acquired claim and the start record
100
+ → handler: perform one external effect
101
+ → store: record the result
102
+ → host: receive the result, or hold the operation as unresolved
103
+ ```
104
+
105
+ Sending a different effect or request under the same scope and ID is a conflict.
106
+ JSON object key order does not matter, but changing a value makes it a new
107
+ request. Integer and float representations are also distinguished. Only JSON
108
+ values are accepted. Handlers use the stored `operation.provider_key` before
109
+ execution when the provider supports it. Preserving the key does not extend the
110
+ provider's own idempotency retention window.
111
+
112
+ | State | Meaning | `execute` with the same ID |
113
+ |---|---|---|
114
+ | `in_flight` | running, or the worker may have died | returns the current state |
115
+ | `indeterminate` | handler exception or result serialization failure | returns the current state |
116
+ | `ready` | a trusted recovery decision permits exactly one more attempt | consumes the grant atomically, then runs |
117
+ | `completed` | the result is settled by execution or external confirmation | returns the stored result |
118
+
119
+ The first two states carry `unresolved=true`. Elapsed time, cancellation and
120
+ restarts never clear them automatically. In the response, `next_action` is
121
+ `wait` for `in_flight` and `reconcile` for `indeterminate`; `ready` returns
122
+ `execute` and `completed` returns `use_result`. A caller that loses the race
123
+ should first wait on `get_effect` for completion rather than demanding an
124
+ operator decision immediately. Keep the same operation ID even
125
+ when a store error prevented a response from arriving. The handler is a
126
+ synchronous function; SDK-internal retries and partial success across multiple
127
+ effects are the responsibility of the handler or provider adapter.
128
+
129
+ ### There is no lease, and that is the point
130
+
131
+ `in_flight` does not mean a worker is alive. There is no lease and no heartbeat,
132
+ so an operation can sit there because the worker is still running, or because it
133
+ was killed a week ago. The ledger cannot tell those apart, and neither can you
134
+ from the outside.
135
+
136
+ **So nothing expires here.** No amount of elapsed time moves an operation out of
137
+ `in_flight`, because a claim that expires on a timer is a retry permit handed out
138
+ by a clock that never saw the provider. Only a person who stopped the workers and
139
+ checked the provider can settle it, through `resolve`.
140
+
141
+ If a lease is ever added it will be an investigation signal and never a claim:
142
+ expiry would tell you where to look, and would still leave `resolve` as the only
143
+ way to grant another attempt.
144
+
145
+ ## MCP server example
146
+
147
+ [examples/mcp_server.py](https://github.com/donggyun112/effect-ledger/blob/main/examples/mcp_server.py) is a **local non-idempotent
148
+ mailbox** that appends messages to a separate SQLite file. It touches no real
149
+ mail and no external account.
150
+
151
+ ```bash
152
+ uv run --extra mcp python examples/mcp_server.py --ledger /tmp/effects.sqlite --mailbox /tmp/mailbox.sqlite
153
+ ```
154
+
155
+ A stdio MCP client calls these two tools.
156
+
157
+ ```json
158
+ {"name":"execute_effect","arguments":{"operation_id":"message-1","effect":"message.send:v1","request":{"text":"hello"}}}
159
+ ```
160
+
161
+ ```json
162
+ {"name":"get_effect","arguments":{"operation_id":"message-1"}}
163
+ ```
164
+
165
+ Registered effects are limited to the server-side registry passed to
166
+ `create_server(executor, effects)`. Scope and provider key are never tool
167
+ arguments. Provider-specific input validation belongs to the handler.
168
+
169
+ The response carries the same state in `structuredContent` and in the JSON text.
170
+ An unresolved answer is a valid state response and may come with
171
+ `isError=false`. **The host must inspect `unresolved` and hold downstream work.**
172
+ Showing the model an error sentence does not by itself complete a fail-closed
173
+ path. A semantic duplicate — the same business request submitted under a new ID
174
+ — cannot be detected by the server.
175
+
176
+ Adding `--lose-response` simulates a response lost after the mailbox write. A
177
+ repeated call appends nothing and returns `indeterminate`. Real kill-based
178
+ verification lives in the tests.
179
+
180
+ ## Operator recovery
181
+
182
+ The recovery API is not exposed as an MCP tool. Call it from a trusted
183
+ operational path. Decide only **after stopping existing workers and checking the
184
+ state of provider requests already sent.** `workers_stopped=True` is the
185
+ caller's assertion, not a mechanism that blocks a remote effect.
186
+
187
+ The `effect-ledger` console does the reading and the recording. It does not do
188
+ the checking — no command here talks to your provider.
189
+
190
+ ```console
191
+ $ effect-ledger --db effects.sqlite --scope account-1 list
192
+ STATE VER ATT EFFECT OPERATION ID
193
+ indeterminate 2 1 payment.charge:v1 charge-1
194
+
195
+ $ effect-ledger --db effects.sqlite --scope account-1 show charge-1
196
+ { "request": { "amount": 4200, "card": "tok_x" }, "state": "indeterminate", "version": 2, ... }
197
+
198
+ # Now go read the provider's own records for that request. Then, and only then:
199
+ $ effect-ledger --db effects.sqlite --scope account-1 resolve charge-1 \
200
+ --complete --result-json '{"charge_id": "ch_77"}' \
201
+ --expected-version 2 --decision-id operator-charge-1 \
202
+ --reason "Stripe shows ch_77; workers drained" --workers-stopped
203
+ ```
204
+
205
+ `--expected-version` is typed in on purpose. Filling it in from the store would
206
+ make the decision refer to the row as it is at that instant, which is not what
207
+ the operator looked at; passing it by hand is what makes a decision refuse to
208
+ land on a state that changed while you were investigating. `--db` also takes a
209
+ `postgresql://` DSN.
210
+
211
+ ```python
212
+ from effect_ledger import EffectExecutor
213
+
214
+ executor = EffectExecutor("/tmp/effects.sqlite", scope="local-mailbox")
215
+ record = executor.get("message-1")
216
+ if record is None:
217
+ raise LookupError("Unknown operation")
218
+
219
+ # Run only after actually confirming mailbox message_id=1 and that the
220
+ # previous server has stopped.
221
+ executor.resolve(
222
+ "message-1", expected_version=record.version,
223
+ decision_id="operator-confirmed-message-1",
224
+ action="complete", result={"message_id": 1},
225
+ reason="Mailbox confirms message 1; previous server stopped",
226
+ workers_stopped=True,
227
+ )
228
+ ```
229
+
230
+ `action="retry"` takes no result and permits exactly one more attempt. It runs
231
+ only when the host calls execute again with the same ID, effect and original
232
+ request. `complete` requires a confirmed result, and an explicit `None` is
233
+ allowed. The decision ID and its arguments must also be preserved before the
234
+ call.
235
+
236
+ Recovery checks the version and stores the decision and the transition in one
237
+ transaction. Replaying an identical decision returns the current state only. The
238
+ same decision ID with different contents, or a new decision against a stale
239
+ version, is rejected. Decision contents, reason and time remain in the
240
+ `decisions` table. A late result cannot overwrite a changed version, but it
241
+ cannot cancel an external request that already went out either.
242
+
243
+ An `OperationConflict` from `execute()` does not mean the effect failed. It is
244
+ raised before execution when the request binding differs, but a changed claim
245
+ version can also raise it **after the external effect succeeded, at the moment
246
+ the result is stored.** Look up and adjudicate the same operation; never retry
247
+ under a new ID on the strength of the exception alone.
248
+
249
+ ## Store and deployment scope
250
+
251
+ SQLite acquires the claim atomically with `BEGIN IMMEDIATE` and commits it
252
+ before the effect with `synchronous=FULL`. No database lock is held across a
253
+ network call. The scope is processes on one host sharing the same local-disk
254
+ database. It is not an implementation for network filesystems or multiple hosts.
255
+ Losing the database, restoring a stale backup or deleting the ledger breaks the
256
+ guarantee. No automatic expiry or deletion is implemented.
257
+
258
+ The ledger carries a schema version and is upgraded in place when it is opened.
259
+ One written by a newer release is refused at startup rather than misread, so a
260
+ downgrade stops there instead of failing on every read, including the operator's
261
+ own commands.
262
+
263
+ For multiple hosts, inject `PostgresOperationStore(dsn)` from `[postgres]`.
264
+ Hosts using the same database and scope share the claim. It serializes short
265
+ per-scope transactions and holds no lock during an external call. Connection
266
+ pooling, schema migration and database failover are not included. The ledger's
267
+ distributed claim and LangGraph thread scheduling are separate concerns;
268
+ serializing the same thread remains the host's responsibility.
269
+
270
+ Scope is not a substitute for authentication. The examples are for stdio on a
271
+ single trusted host. An HTTP deployment must arrange authentication,
272
+ authorization and per-account routing separately.
273
+
274
+ ## Verification
275
+
276
+ ```bash
277
+ uv run --all-extras python -m unittest discover -s tests -p 'test_operation*.py' -v
278
+ uv run --all-extras python -m unittest discover -s tests -p test_mcp_server.py -v
279
+ uv run --all-extras python -m unittest discover -s tests -p 'test_langgraph*.py' -v
280
+ uv run --all-extras python -m unittest discover -s tests -p test_recovery_agent_example.py -v
281
+ # With a dedicated PostgreSQL test database, run the full contract and kill tests:
282
+ EFFECT_LEDGER_TEST_DSN=postgresql://postgres@localhost/effect_ledger_test \
283
+ uv run --all-extras python -m unittest discover -s tests -v
284
+ ```
285
+
286
+ - A separate HTTP provider commits an effect to its own database, and the worker
287
+ process is killed immediately afterwards.
288
+ - After the new process calls again and an operator confirms completion, the
289
+ provider effect remains a single occurrence.
290
+ - Four independent processes calling concurrently acquire exactly one claim.
291
+ - An MCP stdio connection is genuinely restarted to verify result replay,
292
+ conflict, unresolved state and recovery.
293
+
294
+ CI runs this suite on Python 3.10–3.13 against a real PostgreSQL service, and
295
+ fails the build if any test reports as skipped.
296
+
297
+ The design rationale is preserved in order under `probes/`. Each file runs as-is
298
+ and imports no package code. Design notes and implementation plans are in
299
+ `docs/superpowers/`.
300
+
301
+ The early `EffectLedger` middleware and `MixedEffectDetector` have been removed.
302
+ The middleware sat outside the tool and could not cut a replay of the tool body,
303
+ and the detector was a workaround that warned about that limitation through
304
+ static analysis (`probes/probe_i~l`). `ExecutionBoundary` commits the claim
305
+ before the effect, so an interrupt inside a tool does not release the claim —
306
+ the hazard the detector warned about is gone, and so is the detector. Both
307
+ modules remain in git history.
@@ -0,0 +1,283 @@
1
+ # effect-ledger
2
+
3
+ [![CI](https://github.com/donggyun112/effect-ledger/actions/workflows/ci.yml/badge.svg)](https://github.com/donggyun112/effect-ledger/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/donggyun112/effect-ledger/blob/main/LICENSE)
5
+
6
+ *[한국어 README](https://github.com/donggyun112/effect-ledger/blob/main/README.ko.md)*
7
+
8
+ Your agent charged the card. The process died before the provider's reply came
9
+ back. The graph resumes from its last checkpoint, calls the tool again, and
10
+ charges the card a second time.
11
+
12
+ That is documented behaviour rather than a bug. A task that started but did not
13
+ finish runs again on resume, and keeping the side effect safe is left to you.
14
+
15
+ **effect-ledger commits a record of the attempt before the effect leaves, so the
16
+ second call finds it.** An attempt whose outcome was never recorded stops as
17
+ `unresolved` and waits for a person. Nothing is retried on a guess.
18
+
19
+ It does not make your provider idempotent and it does not give you exactly-once.
20
+ It records what may already have gone out, and refuses to guess the rest.
21
+
22
+ Start with the [LangChain execution boundary](https://github.com/donggyun112/effect-ledger/blob/main/docs/langchain-boundary.md): one
23
+ middleware over the tools you already have. Everything else is chosen
24
+ separately — the store (SQLite/Postgres), the business ID, the recovery policy —
25
+ and the core depends on no framework. [The composition API and extension
26
+ contract](https://github.com/donggyun112/effect-ledger/blob/main/docs/composition.md) covers that, up to a multi-host store.
27
+
28
+ ```python
29
+ from langchain.agents import create_agent
30
+ from effect_ledger import EffectExecutor
31
+ from effect_ledger.langchain import ExecutionBoundary
32
+ from effect_ledger.langgraph import LedgerRunner
33
+
34
+ boundary = ExecutionBoundary(
35
+ EffectExecutor("effects.sqlite", scope="account-1"),
36
+ workflow_id="mail-agent:v1",
37
+ )
38
+ # model, send_message and saver are your existing model, single-effect tool
39
+ # and durable checkpointer.
40
+ agent = create_agent(model, [send_message], middleware=[boundary], checkpointer=saver)
41
+ runner = LedgerRunner(agent)
42
+ ```
43
+
44
+ Every registered tool is protected by default. Tool names and argument schemas
45
+ are preserved. Read-only and control exceptions, and stable effect names, are
46
+ declared through the `tools` mapping described in the
47
+ [LangChain execution boundary guide](https://github.com/donggyun112/effect-ledger/blob/main/docs/langchain-boundary.md). With several
48
+ tool middlewares, install the boundary last.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install "effect-ledger[langchain]"
54
+ pip install "effect-ledger[mcp]" # to expose effects over MCP
55
+ pip install "effect-ledger[postgres]" # for a multi-host store
56
+ ```
57
+
58
+ Working inside a clone of this repository, use `uv sync --extra langchain`
59
+ (or `--all-extras`) instead.
60
+
61
+ The core needs Python 3.10+ and the standard library only. The MCP extra targets
62
+ SDK v1 (`mcp>=1.28,<2`). The LangChain execution boundary and the LangGraph
63
+ adapters use the `[langchain]` extra.
64
+
65
+ ## Execution contract
66
+
67
+ The host **stores a logical operation ID durably before the call** and reuses it
68
+ on retry. Do not substitute an MCP request ID or a tool call ID that the model
69
+ regenerates each turn. The server fixes the account/tenant scope and the effect
70
+ name and version.
71
+
72
+ ```text
73
+ host: store operation ID
74
+ → server: bind effect, original JSON and provider key to scope + operation ID
75
+ → store: commit the acquired claim and the start record
76
+ → handler: perform one external effect
77
+ → store: record the result
78
+ → host: receive the result, or hold the operation as unresolved
79
+ ```
80
+
81
+ Sending a different effect or request under the same scope and ID is a conflict.
82
+ JSON object key order does not matter, but changing a value makes it a new
83
+ request. Integer and float representations are also distinguished. Only JSON
84
+ values are accepted. Handlers use the stored `operation.provider_key` before
85
+ execution when the provider supports it. Preserving the key does not extend the
86
+ provider's own idempotency retention window.
87
+
88
+ | State | Meaning | `execute` with the same ID |
89
+ |---|---|---|
90
+ | `in_flight` | running, or the worker may have died | returns the current state |
91
+ | `indeterminate` | handler exception or result serialization failure | returns the current state |
92
+ | `ready` | a trusted recovery decision permits exactly one more attempt | consumes the grant atomically, then runs |
93
+ | `completed` | the result is settled by execution or external confirmation | returns the stored result |
94
+
95
+ The first two states carry `unresolved=true`. Elapsed time, cancellation and
96
+ restarts never clear them automatically. In the response, `next_action` is
97
+ `wait` for `in_flight` and `reconcile` for `indeterminate`; `ready` returns
98
+ `execute` and `completed` returns `use_result`. A caller that loses the race
99
+ should first wait on `get_effect` for completion rather than demanding an
100
+ operator decision immediately. Keep the same operation ID even
101
+ when a store error prevented a response from arriving. The handler is a
102
+ synchronous function; SDK-internal retries and partial success across multiple
103
+ effects are the responsibility of the handler or provider adapter.
104
+
105
+ ### There is no lease, and that is the point
106
+
107
+ `in_flight` does not mean a worker is alive. There is no lease and no heartbeat,
108
+ so an operation can sit there because the worker is still running, or because it
109
+ was killed a week ago. The ledger cannot tell those apart, and neither can you
110
+ from the outside.
111
+
112
+ **So nothing expires here.** No amount of elapsed time moves an operation out of
113
+ `in_flight`, because a claim that expires on a timer is a retry permit handed out
114
+ by a clock that never saw the provider. Only a person who stopped the workers and
115
+ checked the provider can settle it, through `resolve`.
116
+
117
+ If a lease is ever added it will be an investigation signal and never a claim:
118
+ expiry would tell you where to look, and would still leave `resolve` as the only
119
+ way to grant another attempt.
120
+
121
+ ## MCP server example
122
+
123
+ [examples/mcp_server.py](https://github.com/donggyun112/effect-ledger/blob/main/examples/mcp_server.py) is a **local non-idempotent
124
+ mailbox** that appends messages to a separate SQLite file. It touches no real
125
+ mail and no external account.
126
+
127
+ ```bash
128
+ uv run --extra mcp python examples/mcp_server.py --ledger /tmp/effects.sqlite --mailbox /tmp/mailbox.sqlite
129
+ ```
130
+
131
+ A stdio MCP client calls these two tools.
132
+
133
+ ```json
134
+ {"name":"execute_effect","arguments":{"operation_id":"message-1","effect":"message.send:v1","request":{"text":"hello"}}}
135
+ ```
136
+
137
+ ```json
138
+ {"name":"get_effect","arguments":{"operation_id":"message-1"}}
139
+ ```
140
+
141
+ Registered effects are limited to the server-side registry passed to
142
+ `create_server(executor, effects)`. Scope and provider key are never tool
143
+ arguments. Provider-specific input validation belongs to the handler.
144
+
145
+ The response carries the same state in `structuredContent` and in the JSON text.
146
+ An unresolved answer is a valid state response and may come with
147
+ `isError=false`. **The host must inspect `unresolved` and hold downstream work.**
148
+ Showing the model an error sentence does not by itself complete a fail-closed
149
+ path. A semantic duplicate — the same business request submitted under a new ID
150
+ — cannot be detected by the server.
151
+
152
+ Adding `--lose-response` simulates a response lost after the mailbox write. A
153
+ repeated call appends nothing and returns `indeterminate`. Real kill-based
154
+ verification lives in the tests.
155
+
156
+ ## Operator recovery
157
+
158
+ The recovery API is not exposed as an MCP tool. Call it from a trusted
159
+ operational path. Decide only **after stopping existing workers and checking the
160
+ state of provider requests already sent.** `workers_stopped=True` is the
161
+ caller's assertion, not a mechanism that blocks a remote effect.
162
+
163
+ The `effect-ledger` console does the reading and the recording. It does not do
164
+ the checking — no command here talks to your provider.
165
+
166
+ ```console
167
+ $ effect-ledger --db effects.sqlite --scope account-1 list
168
+ STATE VER ATT EFFECT OPERATION ID
169
+ indeterminate 2 1 payment.charge:v1 charge-1
170
+
171
+ $ effect-ledger --db effects.sqlite --scope account-1 show charge-1
172
+ { "request": { "amount": 4200, "card": "tok_x" }, "state": "indeterminate", "version": 2, ... }
173
+
174
+ # Now go read the provider's own records for that request. Then, and only then:
175
+ $ effect-ledger --db effects.sqlite --scope account-1 resolve charge-1 \
176
+ --complete --result-json '{"charge_id": "ch_77"}' \
177
+ --expected-version 2 --decision-id operator-charge-1 \
178
+ --reason "Stripe shows ch_77; workers drained" --workers-stopped
179
+ ```
180
+
181
+ `--expected-version` is typed in on purpose. Filling it in from the store would
182
+ make the decision refer to the row as it is at that instant, which is not what
183
+ the operator looked at; passing it by hand is what makes a decision refuse to
184
+ land on a state that changed while you were investigating. `--db` also takes a
185
+ `postgresql://` DSN.
186
+
187
+ ```python
188
+ from effect_ledger import EffectExecutor
189
+
190
+ executor = EffectExecutor("/tmp/effects.sqlite", scope="local-mailbox")
191
+ record = executor.get("message-1")
192
+ if record is None:
193
+ raise LookupError("Unknown operation")
194
+
195
+ # Run only after actually confirming mailbox message_id=1 and that the
196
+ # previous server has stopped.
197
+ executor.resolve(
198
+ "message-1", expected_version=record.version,
199
+ decision_id="operator-confirmed-message-1",
200
+ action="complete", result={"message_id": 1},
201
+ reason="Mailbox confirms message 1; previous server stopped",
202
+ workers_stopped=True,
203
+ )
204
+ ```
205
+
206
+ `action="retry"` takes no result and permits exactly one more attempt. It runs
207
+ only when the host calls execute again with the same ID, effect and original
208
+ request. `complete` requires a confirmed result, and an explicit `None` is
209
+ allowed. The decision ID and its arguments must also be preserved before the
210
+ call.
211
+
212
+ Recovery checks the version and stores the decision and the transition in one
213
+ transaction. Replaying an identical decision returns the current state only. The
214
+ same decision ID with different contents, or a new decision against a stale
215
+ version, is rejected. Decision contents, reason and time remain in the
216
+ `decisions` table. A late result cannot overwrite a changed version, but it
217
+ cannot cancel an external request that already went out either.
218
+
219
+ An `OperationConflict` from `execute()` does not mean the effect failed. It is
220
+ raised before execution when the request binding differs, but a changed claim
221
+ version can also raise it **after the external effect succeeded, at the moment
222
+ the result is stored.** Look up and adjudicate the same operation; never retry
223
+ under a new ID on the strength of the exception alone.
224
+
225
+ ## Store and deployment scope
226
+
227
+ SQLite acquires the claim atomically with `BEGIN IMMEDIATE` and commits it
228
+ before the effect with `synchronous=FULL`. No database lock is held across a
229
+ network call. The scope is processes on one host sharing the same local-disk
230
+ database. It is not an implementation for network filesystems or multiple hosts.
231
+ Losing the database, restoring a stale backup or deleting the ledger breaks the
232
+ guarantee. No automatic expiry or deletion is implemented.
233
+
234
+ The ledger carries a schema version and is upgraded in place when it is opened.
235
+ One written by a newer release is refused at startup rather than misread, so a
236
+ downgrade stops there instead of failing on every read, including the operator's
237
+ own commands.
238
+
239
+ For multiple hosts, inject `PostgresOperationStore(dsn)` from `[postgres]`.
240
+ Hosts using the same database and scope share the claim. It serializes short
241
+ per-scope transactions and holds no lock during an external call. Connection
242
+ pooling, schema migration and database failover are not included. The ledger's
243
+ distributed claim and LangGraph thread scheduling are separate concerns;
244
+ serializing the same thread remains the host's responsibility.
245
+
246
+ Scope is not a substitute for authentication. The examples are for stdio on a
247
+ single trusted host. An HTTP deployment must arrange authentication,
248
+ authorization and per-account routing separately.
249
+
250
+ ## Verification
251
+
252
+ ```bash
253
+ uv run --all-extras python -m unittest discover -s tests -p 'test_operation*.py' -v
254
+ uv run --all-extras python -m unittest discover -s tests -p test_mcp_server.py -v
255
+ uv run --all-extras python -m unittest discover -s tests -p 'test_langgraph*.py' -v
256
+ uv run --all-extras python -m unittest discover -s tests -p test_recovery_agent_example.py -v
257
+ # With a dedicated PostgreSQL test database, run the full contract and kill tests:
258
+ EFFECT_LEDGER_TEST_DSN=postgresql://postgres@localhost/effect_ledger_test \
259
+ uv run --all-extras python -m unittest discover -s tests -v
260
+ ```
261
+
262
+ - A separate HTTP provider commits an effect to its own database, and the worker
263
+ process is killed immediately afterwards.
264
+ - After the new process calls again and an operator confirms completion, the
265
+ provider effect remains a single occurrence.
266
+ - Four independent processes calling concurrently acquire exactly one claim.
267
+ - An MCP stdio connection is genuinely restarted to verify result replay,
268
+ conflict, unresolved state and recovery.
269
+
270
+ CI runs this suite on Python 3.10–3.13 against a real PostgreSQL service, and
271
+ fails the build if any test reports as skipped.
272
+
273
+ The design rationale is preserved in order under `probes/`. Each file runs as-is
274
+ and imports no package code. Design notes and implementation plans are in
275
+ `docs/superpowers/`.
276
+
277
+ The early `EffectLedger` middleware and `MixedEffectDetector` have been removed.
278
+ The middleware sat outside the tool and could not cut a replay of the tool body,
279
+ and the detector was a workaround that warned about that limitation through
280
+ static analysis (`probes/probe_i~l`). `ExecutionBoundary` commits the claim
281
+ before the effect, so an interrupt inside a tool does not release the claim —
282
+ the hazard the detector warned about is gone, and so is the detector. Both
283
+ modules remain in git history.
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "effect-ledger"
3
+ version = "0.1.0"
4
+ description = "Composable effect recovery with atomic SQLite/PostgreSQL stores, host-owned identity and optional LangGraph/MCP adapters."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ keywords = [
9
+ "langchain",
10
+ "langgraph",
11
+ "mcp",
12
+ "postgresql",
13
+ "agents",
14
+ "durable-execution",
15
+ "idempotency",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = []
24
+
25
+ [[project.authors]]
26
+ name = "donggyun112"
27
+ email = "ssddgg99@daum.net"
28
+
29
+ [project.scripts]
30
+ effect-ledger = "effect_ledger.cli:main"
31
+
32
+ [project.urls]
33
+ Source = "https://github.com/donggyun112/effect-ledger"
34
+
35
+ [project.optional-dependencies]
36
+ postgres = ["psycopg[binary]>=3.2,<4"]
37
+ langchain = [
38
+ "langchain>=1.4.0",
39
+ "langgraph>=1.2.11",
40
+ "langgraph-checkpoint-sqlite>=3,<4",
41
+ ]
42
+ mcp = ["mcp>=1.28,<2"]
43
+
44
+ [dependency-groups]
45
+ dev = ["ruff==0.16.6"]
46
+
47
+ [tool.ruff]
48
+ extend-exclude = ["probes"]
49
+
50
+ [tool.ruff.lint]
51
+ select = [
52
+ "F",
53
+ "E9",
54
+ "I",
55
+ "UP",
56
+ "B",
57
+ "RUF100",
58
+ ]
59
+
60
+ [build-system]
61
+ requires = ["uv_build>=0.12.6,<0.13.0"]
62
+ build-backend = "uv_build"