holdpoint 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.
Files changed (67) hide show
  1. holdpoint-0.1.0/.gitignore +36 -0
  2. holdpoint-0.1.0/CHANGELOG.md +42 -0
  3. holdpoint-0.1.0/LICENSE +105 -0
  4. holdpoint-0.1.0/PKG-INFO +225 -0
  5. holdpoint-0.1.0/README.md +199 -0
  6. holdpoint-0.1.0/docs/api.md +227 -0
  7. holdpoint-0.1.0/docs/concepts.md +180 -0
  8. holdpoint-0.1.0/docs/faq.md +165 -0
  9. holdpoint-0.1.0/docs/quickstart.md +114 -0
  10. holdpoint-0.1.0/docs/recipes.md +275 -0
  11. holdpoint-0.1.0/pyproject.toml +189 -0
  12. holdpoint-0.1.0/src/holdpoint/__init__.py +85 -0
  13. holdpoint-0.1.0/src/holdpoint/_internal/__init__.py +1 -0
  14. holdpoint-0.1.0/src/holdpoint/_internal/transitions.py +27 -0
  15. holdpoint-0.1.0/src/holdpoint/clock.py +72 -0
  16. holdpoint-0.1.0/src/holdpoint/errors.py +276 -0
  17. holdpoint-0.1.0/src/holdpoint/hold.py +1574 -0
  18. holdpoint-0.1.0/src/holdpoint/models.py +222 -0
  19. holdpoint-0.1.0/src/holdpoint/policy.py +129 -0
  20. holdpoint-0.1.0/src/holdpoint/py.typed +0 -0
  21. holdpoint-0.1.0/src/holdpoint/serialization.py +363 -0
  22. holdpoint-0.1.0/src/holdpoint/sqlite_store.py +457 -0
  23. holdpoint-0.1.0/src/holdpoint/store.py +272 -0
  24. holdpoint-0.1.0/tests/__init__.py +0 -0
  25. holdpoint-0.1.0/tests/chaos/__init__.py +0 -0
  26. holdpoint-0.1.0/tests/chaos/test_crash_points.py +227 -0
  27. holdpoint-0.1.0/tests/chaos/test_lost_races.py +216 -0
  28. holdpoint-0.1.0/tests/compat/__init__.py +0 -0
  29. holdpoint-0.1.0/tests/compat/test_schema_migration.py +129 -0
  30. holdpoint-0.1.0/tests/concurrency/__init__.py +0 -0
  31. holdpoint-0.1.0/tests/concurrency/procs_helper.py +54 -0
  32. holdpoint-0.1.0/tests/concurrency/test_async_concurrency.py +84 -0
  33. holdpoint-0.1.0/tests/concurrency/test_processes.py +56 -0
  34. holdpoint-0.1.0/tests/concurrency/test_races.py +161 -0
  35. holdpoint-0.1.0/tests/concurrency/test_sqlite_contention.py +103 -0
  36. holdpoint-0.1.0/tests/conftest.py +56 -0
  37. holdpoint-0.1.0/tests/docs/__init__.py +0 -0
  38. holdpoint-0.1.0/tests/docs/test_documentation.py +82 -0
  39. holdpoint-0.1.0/tests/integration/__init__.py +0 -0
  40. holdpoint-0.1.0/tests/integration/test_realistic_run.py +190 -0
  41. holdpoint-0.1.0/tests/property/__init__.py +0 -0
  42. holdpoint-0.1.0/tests/property/conftest.py +17 -0
  43. holdpoint-0.1.0/tests/property/test_codec_properties.py +133 -0
  44. holdpoint-0.1.0/tests/property/test_lifecycle_properties.py +241 -0
  45. holdpoint-0.1.0/tests/support.py +160 -0
  46. holdpoint-0.1.0/tests/unit/__init__.py +0 -0
  47. holdpoint-0.1.0/tests/unit/test_approve.py +154 -0
  48. holdpoint-0.1.0/tests/unit/test_async_api.py +374 -0
  49. holdpoint-0.1.0/tests/unit/test_audit_contract.py +317 -0
  50. holdpoint-0.1.0/tests/unit/test_contract_pins.py +147 -0
  51. holdpoint-0.1.0/tests/unit/test_dedup.py +81 -0
  52. holdpoint-0.1.0/tests/unit/test_edges.py +222 -0
  53. holdpoint-0.1.0/tests/unit/test_guard.py +121 -0
  54. holdpoint-0.1.0/tests/unit/test_guard_signatures.py +114 -0
  55. holdpoint-0.1.0/tests/unit/test_hooks.py +127 -0
  56. holdpoint-0.1.0/tests/unit/test_lifecycle.py +132 -0
  57. holdpoint-0.1.0/tests/unit/test_mutation_pins.py +1011 -0
  58. holdpoint-0.1.0/tests/unit/test_patch.py +159 -0
  59. holdpoint-0.1.0/tests/unit/test_recovery.py +270 -0
  60. holdpoint-0.1.0/tests/unit/test_redaction.py +98 -0
  61. holdpoint-0.1.0/tests/unit/test_review_fixes.py +126 -0
  62. holdpoint-0.1.0/tests/unit/test_rules.py +171 -0
  63. holdpoint-0.1.0/tests/unit/test_serialization.py +168 -0
  64. holdpoint-0.1.0/tests/unit/test_sqlite_store.py +166 -0
  65. holdpoint-0.1.0/tests/unit/test_stores_contract.py +224 -0
  66. holdpoint-0.1.0/tests/unit/test_ttl.py +299 -0
  67. holdpoint-0.1.0/tests/unit/test_waiting.py +95 -0
@@ -0,0 +1,36 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ wheels/
9
+
10
+ # Environments
11
+ .venv/
12
+ venv/
13
+ .python-version
14
+
15
+ # Tooling caches
16
+ .pytest_cache/
17
+ .hypothesis/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ coverage.xml
23
+ htmlcov/
24
+ mutants/
25
+ .mutmut-cache
26
+ .benchmarks/
27
+
28
+ # OS / editors
29
+ .DS_Store
30
+ .idea/
31
+ .vscode/
32
+
33
+ # Local scratch databases
34
+ *.db
35
+ *.db-wal
36
+ *.db-shm
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html) read strictly
6
+ even pre-1.0: `0.x` minor and patch releases extend or fix but do not break
7
+ the public API; anything breaking waits for a major version.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] - 2026-08-23
12
+
13
+ First release.
14
+
15
+ ### Added
16
+
17
+ - `@hold.guard(...)`: turn `def` / `async def` calls into stored, reviewable
18
+ actions; full signature support (positional-only, `*args`, `**kwargs`,
19
+ defaults captured at submit); typed end to end (`ParamSpec`, `Action[R]`).
20
+ - Lifecycle `pending → approved → executed / rejected / expired / failed`
21
+ with compare-and-set transitions; exactly-once execution with a documented
22
+ crash-ambiguity policy (`recover(interrupted="fail" | "retry")`).
23
+ - Argument `patch` at approval, validated against the live signature, with
24
+ `BindingError` repair flow for stale queued calls.
25
+ - TTLs (`"90s"`.."7d"`, numbers, `timedelta`) with `expire` /
26
+ `auto_approve` / `auto_reject` policies; explicit `expire_due()` sweep, no
27
+ background threads.
28
+ - Auto-rules (first opinion wins, audited as `rule:<name>`); dedup keys with
29
+ live-scope uniqueness and loud payload-conflict detection; hooks
30
+ (`on_pending` / `on_approved` / `on_rejected` / `on_expired`),
31
+ best-effort by design.
32
+ - Append-only audit log, committed atomically with each state change;
33
+ redaction hooks for secrets; suspicious-key warning.
34
+ - `SQLiteStore` (WAL, busy-timeout, per-thread connections, schema
35
+ versioning with forward migrations) and `MemoryStore`; public `Store`
36
+ protocol for custom backends.
37
+ - Async twins for every operation (`approve_async`, ...).
38
+ - Zero runtime dependencies; `py.typed`; passes `mypy --strict` and pyright
39
+ strict.
40
+
41
+ [Unreleased]: https://github.com/USERNAME/holdpoint/compare/v0.1.0...HEAD
42
+ [0.1.0]: https://github.com/USERNAME/holdpoint/releases/tag/v0.1.0
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Saveliy
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.5
2
+ Name: holdpoint
3
+ Version: 0.1.0
4
+ Summary: A durable approval queue for agent actions: pause, review, patch, then run exactly once.
5
+ Project-URL: Homepage, https://github.com/allasava-ye/holdpoint
6
+ Project-URL: Documentation, https://github.com/allasava-ye/holdpoint/tree/main/docs
7
+ Project-URL: Repository, https://github.com/allasava-ye/holdpoint
8
+ Project-URL: Issues, https://github.com/allasava-ye/holdpoint/issues
9
+ Project-URL: Changelog, https://github.com/allasava-ye/holdpoint/blob/main/CHANGELOG.md
10
+ Author: Saveliy
11
+ License-Expression: FSL-1.1-ALv2
12
+ License-File: LICENSE
13
+ Keywords: agents,approval,audit,human-in-the-loop,llm,safety,sqlite
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+
27
+ # holdpoint
28
+
29
+ [![CI](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml/badge.svg)](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/holdpoint)](https://pypi.org/project/holdpoint/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/holdpoint)](https://pypi.org/project/holdpoint/)
32
+
33
+ > A hold point is a stage where work stops until someone authorized releases it.
34
+ > `holdpoint` gives your agent one.
35
+
36
+ ## The problem
37
+
38
+ Your agent drafts an email to a customer, and the draft is good — most of the
39
+ time. The one time it is not, `smtp.send()` has already run. Every team that
40
+ ships agents ends up writing the same layer by hand: park the risky call
41
+ somewhere, let a human look at it, run it after approval. Hand-rolled versions
42
+ tend to share the same bugs: the action runs twice when two reviewers click at
43
+ once, approved work vanishes when the process restarts, and nobody can say
44
+ afterwards who approved what.
45
+
46
+ `holdpoint` is that layer as a library: one primitive — a deferred function
47
+ call waiting for a decision — with the concurrency, durability, and audit
48
+ questions answered once. Zero runtime dependencies; state lives in a SQLite
49
+ file you own.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install holdpoint
55
+ ```
56
+
57
+ Python 3.10+. Nothing else.
58
+
59
+ ## Example
60
+
61
+ ```python
62
+ from holdpoint import HoldPoint, SQLiteStore
63
+
64
+ hold = HoldPoint(store=SQLiteStore("approvals.db"))
65
+
66
+ @hold.guard(kind="send_email", ttl="24h")
67
+ def send_email(to: str, subject: str) -> str:
68
+ print(f"sending {subject!r} to {to}")
69
+ return "sent"
70
+
71
+ pending = send_email(to="ceo@corp.com", subject="Proposal") # queued, NOT sent
72
+ print(pending.status.value) # "pending"
73
+
74
+ for item in hold.pending(): # your dashboard, CLI, or bot
75
+ result = hold.approve(item.id, actor="sava", patch={"subject": "Shorter"})
76
+ print(result.value) # "sent"
77
+ ```
78
+
79
+ Calling the function stores the call. Approving executes it — once — with the
80
+ reviewer's edits applied. Everything lands in an append-only audit trail in
81
+ the same database file.
82
+
83
+ ## When you don't need this
84
+
85
+ Honesty first: `holdpoint` is a narrow tool, and often the wrong one.
86
+
87
+ - **Your agent runs on LangGraph, Pydantic AI, or the OpenAI Agents SDK.**
88
+ Use their native mechanisms (`interrupt()`, `requires_approval=True`,
89
+ `needs_approval=True`). They pause the whole agent loop and resume it with
90
+ the human's answer, which composes better inside those frameworks than an
91
+ external queue does.
92
+ - **You want Slack/email approval UX out of the box.** That is a product, not
93
+ a library. [HumanLayer](https://humanlayer.dev) sells exactly it.
94
+ `holdpoint` gives you hooks and a queue; the notification channel is your
95
+ code.
96
+ - **You need approvals across multiple services or machines.** Use
97
+ [Temporal](https://temporal.io). Its signals + durable workflows are
98
+ strictly stronger than a SQLite file, at the cost of running a cluster.
99
+ - **The action is cheap to undo.** A soft-deleted row does not need a human
100
+ gate. Add approval only where mistakes are expensive and irreversible.
101
+
102
+ `holdpoint` fits when you have a Python process, a handful of scary function
103
+ calls, and no appetite for new infrastructure.
104
+
105
+ ## Features
106
+
107
+ **Guard any function.** `@hold.guard(kind=...)` works on `def` and
108
+ `async def`, with positional-only params, `*args`, `**kwargs`, and defaults
109
+ (captured at submit time, so the reviewer sees the complete call). Full type
110
+ inference: the IDE knows `send_email(...)` returns `Action[str]`.
111
+
112
+ **Patch before running.** The reviewer is not a yes/no button. `approve(id,
113
+ actor="sava", patch={"subject": "Shorter"})` edits arguments before
114
+ execution; unknown keys and unserializable values are rejected with exact
115
+ messages, and the audit records both the original payload and the patch.
116
+
117
+ ```python
118
+ hold.approve(item.id, actor="sava", patch={"amount": 90})
119
+ ```
120
+
121
+ **Exactly-once execution.** Approval is a compare-and-set in the store. Fifty
122
+ concurrent approvals of one action execute it once; the other forty-nine wait
123
+ and return the same stored result. See [Guarantees](#guarantees) for the
124
+ precise claim.
125
+
126
+ **Crash recovery.** If the process dies between "approved" and "executed",
127
+ `hold.recover()` at the next start finishes the job — or, if the crash
128
+ happened mid-execution, surfaces the ambiguity instead of guessing.
129
+
130
+ ```python
131
+ report = hold.recover() # call at startup, before serving traffic
132
+ ```
133
+
134
+ **TTLs with policies.** `ttl="24h"` plus `on_expire="expire" | "auto_approve"
135
+ | "auto_reject"`. No background threads: call `hold.expire_due()` on your own
136
+ cadence; overdue actions are un-approvable regardless.
137
+
138
+ **Auto-approval rules.** A predicate can wave routine actions through so the
139
+ human only sees the interesting ones. Rules run the same machinery and leave
140
+ the same audit trail (`actor="rule:<name>"`).
141
+
142
+ ```python
143
+ def small_amounts(kind, payload):
144
+ if kind == "wire" and payload["amount"] < 100:
145
+ return "approve"
146
+ return None
147
+
148
+ hold = HoldPoint(store=..., rules=[small_amounts])
149
+ ```
150
+
151
+ **Deduplication.** `dedup_key=lambda p: p["email"]` collapses repeated
152
+ proposals of the same action while one is live; the same key with a
153
+ *different* payload is a loud conflict, never a silent overwrite.
154
+
155
+ **Immutable audit.** Who submitted, who decided, what was patched, what ran,
156
+ what it returned or raised — append-only, enforced by database triggers, with
157
+ secrets redacted via `redact=("password",)`.
158
+
159
+ **Hooks.** `@hold.on_pending` (and `on_approved` / `on_rejected` /
160
+ `on_expired`) is where you wire Telegram, Slack, or anything else.
161
+ Notifications are best-effort by design; the queue is the source of truth.
162
+
163
+ **Know where things run.** The process that calls `approve()` executes the
164
+ function — your review bot needs the same imports, credentials, and network
165
+ access as the agent would. Hooks run synchronously in the thread that
166
+ caused the event; keep them fast or hand off to your own worker. Details in
167
+ [docs/concepts.md](docs/concepts.md#where-things-run).
168
+
169
+ **Async.** `await send_email(...)`, `await hold.approve_async(...)`. Sync and
170
+ async guards share one queue and one semantics.
171
+
172
+ ## Guarantees
173
+
174
+ What is promised, precisely:
175
+
176
+ - **At most one execution at a time, ever.** Concurrent approvals — threads,
177
+ event loops, separate processes on one machine — collapse to one execution
178
+ via an atomic claim; the rest share its result.
179
+ - **Exactly one execution if the process survives the call.** The normal
180
+ case.
181
+ - **After a crash:** an approved action whose execution never started is
182
+ completed by `recover()` safely. An action that died *mid-execution* is
183
+ ambiguous by nature — the default marks it `failed` with an explicit "side
184
+ effect unknown" error for a human to investigate; opting into
185
+ `recover(interrupted="retry")` chooses at-least-once, knowingly.
186
+ - **Every state change commits atomically with its audit entry.** A crash
187
+ cannot separate what happened from the record of it.
188
+
189
+ What is *not* promised: coordination across machines (single shared SQLite
190
+ file, one host), guaranteed hook delivery (best-effort; poll the queue for
191
+ truth), retries of failed actions (a failed action stays failed until a human
192
+ re-submits), and protection from `os._exit()` between a side effect and its
193
+ recording — that gap is physics, and `holdpoint` chooses to expose it rather
194
+ than pretend.
195
+
196
+ ## Compared honestly
197
+
198
+ | | holdpoint | LangGraph interrupt | HumanLayer | Temporal |
199
+ |---|---|---|---|---|
200
+ | Standalone (no framework/service) | yes | no | no (SaaS) | no (cluster) |
201
+ | Runtime dependencies | 0 | many | 6 | server + SDK |
202
+ | Durable queue you own | SQLite file | checkpointer | their cloud | their cluster |
203
+ | Edit args before run | yes | via resume value | feedback only | via signal |
204
+ | Exactly-once execute | yes (see above) | n/a | no claim | yes |
205
+ | Auto-rules / TTL / dedup | yes | build it | partial | build it |
206
+ | Immutable audit log | yes | no | cloud logs | full history (better) |
207
+ | Human notification UX | **your code** | **your code** | **built-in, better** | your code |
208
+ | Multi-node / distributed | **no** | via Postgres | yes | **yes, much better** |
209
+ | Pauses the whole agent loop in-place | **no** | **yes, better in-framework** | yes | yes |
210
+
211
+ The bold cells in the last rows are where `holdpoint` loses. If those rows
212
+ are your requirements, take the other column's tool.
213
+
214
+ ## Status
215
+
216
+ v0.1.x. The public API (everything importable from `holdpoint`) follows
217
+ semver: breaking changes only with a major-version bump; `0.x` minor bumps
218
+ may extend but not break. The SQLite schema is versioned and migrates
219
+ forward automatically; downgrade is refused explicitly.
220
+
221
+ ## License
222
+
223
+ [FSL-1.1-ALv2](LICENSE) (Functional Source License): free for any use except
224
+ competing commercial hosting, and each release becomes Apache-2.0 two years
225
+ after publication. Contributions require the short [CLA](CLA.md).
@@ -0,0 +1,199 @@
1
+ # holdpoint
2
+
3
+ [![CI](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml/badge.svg)](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/holdpoint)](https://pypi.org/project/holdpoint/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/holdpoint)](https://pypi.org/project/holdpoint/)
6
+
7
+ > A hold point is a stage where work stops until someone authorized releases it.
8
+ > `holdpoint` gives your agent one.
9
+
10
+ ## The problem
11
+
12
+ Your agent drafts an email to a customer, and the draft is good — most of the
13
+ time. The one time it is not, `smtp.send()` has already run. Every team that
14
+ ships agents ends up writing the same layer by hand: park the risky call
15
+ somewhere, let a human look at it, run it after approval. Hand-rolled versions
16
+ tend to share the same bugs: the action runs twice when two reviewers click at
17
+ once, approved work vanishes when the process restarts, and nobody can say
18
+ afterwards who approved what.
19
+
20
+ `holdpoint` is that layer as a library: one primitive — a deferred function
21
+ call waiting for a decision — with the concurrency, durability, and audit
22
+ questions answered once. Zero runtime dependencies; state lives in a SQLite
23
+ file you own.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install holdpoint
29
+ ```
30
+
31
+ Python 3.10+. Nothing else.
32
+
33
+ ## Example
34
+
35
+ ```python
36
+ from holdpoint import HoldPoint, SQLiteStore
37
+
38
+ hold = HoldPoint(store=SQLiteStore("approvals.db"))
39
+
40
+ @hold.guard(kind="send_email", ttl="24h")
41
+ def send_email(to: str, subject: str) -> str:
42
+ print(f"sending {subject!r} to {to}")
43
+ return "sent"
44
+
45
+ pending = send_email(to="ceo@corp.com", subject="Proposal") # queued, NOT sent
46
+ print(pending.status.value) # "pending"
47
+
48
+ for item in hold.pending(): # your dashboard, CLI, or bot
49
+ result = hold.approve(item.id, actor="sava", patch={"subject": "Shorter"})
50
+ print(result.value) # "sent"
51
+ ```
52
+
53
+ Calling the function stores the call. Approving executes it — once — with the
54
+ reviewer's edits applied. Everything lands in an append-only audit trail in
55
+ the same database file.
56
+
57
+ ## When you don't need this
58
+
59
+ Honesty first: `holdpoint` is a narrow tool, and often the wrong one.
60
+
61
+ - **Your agent runs on LangGraph, Pydantic AI, or the OpenAI Agents SDK.**
62
+ Use their native mechanisms (`interrupt()`, `requires_approval=True`,
63
+ `needs_approval=True`). They pause the whole agent loop and resume it with
64
+ the human's answer, which composes better inside those frameworks than an
65
+ external queue does.
66
+ - **You want Slack/email approval UX out of the box.** That is a product, not
67
+ a library. [HumanLayer](https://humanlayer.dev) sells exactly it.
68
+ `holdpoint` gives you hooks and a queue; the notification channel is your
69
+ code.
70
+ - **You need approvals across multiple services or machines.** Use
71
+ [Temporal](https://temporal.io). Its signals + durable workflows are
72
+ strictly stronger than a SQLite file, at the cost of running a cluster.
73
+ - **The action is cheap to undo.** A soft-deleted row does not need a human
74
+ gate. Add approval only where mistakes are expensive and irreversible.
75
+
76
+ `holdpoint` fits when you have a Python process, a handful of scary function
77
+ calls, and no appetite for new infrastructure.
78
+
79
+ ## Features
80
+
81
+ **Guard any function.** `@hold.guard(kind=...)` works on `def` and
82
+ `async def`, with positional-only params, `*args`, `**kwargs`, and defaults
83
+ (captured at submit time, so the reviewer sees the complete call). Full type
84
+ inference: the IDE knows `send_email(...)` returns `Action[str]`.
85
+
86
+ **Patch before running.** The reviewer is not a yes/no button. `approve(id,
87
+ actor="sava", patch={"subject": "Shorter"})` edits arguments before
88
+ execution; unknown keys and unserializable values are rejected with exact
89
+ messages, and the audit records both the original payload and the patch.
90
+
91
+ ```python
92
+ hold.approve(item.id, actor="sava", patch={"amount": 90})
93
+ ```
94
+
95
+ **Exactly-once execution.** Approval is a compare-and-set in the store. Fifty
96
+ concurrent approvals of one action execute it once; the other forty-nine wait
97
+ and return the same stored result. See [Guarantees](#guarantees) for the
98
+ precise claim.
99
+
100
+ **Crash recovery.** If the process dies between "approved" and "executed",
101
+ `hold.recover()` at the next start finishes the job — or, if the crash
102
+ happened mid-execution, surfaces the ambiguity instead of guessing.
103
+
104
+ ```python
105
+ report = hold.recover() # call at startup, before serving traffic
106
+ ```
107
+
108
+ **TTLs with policies.** `ttl="24h"` plus `on_expire="expire" | "auto_approve"
109
+ | "auto_reject"`. No background threads: call `hold.expire_due()` on your own
110
+ cadence; overdue actions are un-approvable regardless.
111
+
112
+ **Auto-approval rules.** A predicate can wave routine actions through so the
113
+ human only sees the interesting ones. Rules run the same machinery and leave
114
+ the same audit trail (`actor="rule:<name>"`).
115
+
116
+ ```python
117
+ def small_amounts(kind, payload):
118
+ if kind == "wire" and payload["amount"] < 100:
119
+ return "approve"
120
+ return None
121
+
122
+ hold = HoldPoint(store=..., rules=[small_amounts])
123
+ ```
124
+
125
+ **Deduplication.** `dedup_key=lambda p: p["email"]` collapses repeated
126
+ proposals of the same action while one is live; the same key with a
127
+ *different* payload is a loud conflict, never a silent overwrite.
128
+
129
+ **Immutable audit.** Who submitted, who decided, what was patched, what ran,
130
+ what it returned or raised — append-only, enforced by database triggers, with
131
+ secrets redacted via `redact=("password",)`.
132
+
133
+ **Hooks.** `@hold.on_pending` (and `on_approved` / `on_rejected` /
134
+ `on_expired`) is where you wire Telegram, Slack, or anything else.
135
+ Notifications are best-effort by design; the queue is the source of truth.
136
+
137
+ **Know where things run.** The process that calls `approve()` executes the
138
+ function — your review bot needs the same imports, credentials, and network
139
+ access as the agent would. Hooks run synchronously in the thread that
140
+ caused the event; keep them fast or hand off to your own worker. Details in
141
+ [docs/concepts.md](docs/concepts.md#where-things-run).
142
+
143
+ **Async.** `await send_email(...)`, `await hold.approve_async(...)`. Sync and
144
+ async guards share one queue and one semantics.
145
+
146
+ ## Guarantees
147
+
148
+ What is promised, precisely:
149
+
150
+ - **At most one execution at a time, ever.** Concurrent approvals — threads,
151
+ event loops, separate processes on one machine — collapse to one execution
152
+ via an atomic claim; the rest share its result.
153
+ - **Exactly one execution if the process survives the call.** The normal
154
+ case.
155
+ - **After a crash:** an approved action whose execution never started is
156
+ completed by `recover()` safely. An action that died *mid-execution* is
157
+ ambiguous by nature — the default marks it `failed` with an explicit "side
158
+ effect unknown" error for a human to investigate; opting into
159
+ `recover(interrupted="retry")` chooses at-least-once, knowingly.
160
+ - **Every state change commits atomically with its audit entry.** A crash
161
+ cannot separate what happened from the record of it.
162
+
163
+ What is *not* promised: coordination across machines (single shared SQLite
164
+ file, one host), guaranteed hook delivery (best-effort; poll the queue for
165
+ truth), retries of failed actions (a failed action stays failed until a human
166
+ re-submits), and protection from `os._exit()` between a side effect and its
167
+ recording — that gap is physics, and `holdpoint` chooses to expose it rather
168
+ than pretend.
169
+
170
+ ## Compared honestly
171
+
172
+ | | holdpoint | LangGraph interrupt | HumanLayer | Temporal |
173
+ |---|---|---|---|---|
174
+ | Standalone (no framework/service) | yes | no | no (SaaS) | no (cluster) |
175
+ | Runtime dependencies | 0 | many | 6 | server + SDK |
176
+ | Durable queue you own | SQLite file | checkpointer | their cloud | their cluster |
177
+ | Edit args before run | yes | via resume value | feedback only | via signal |
178
+ | Exactly-once execute | yes (see above) | n/a | no claim | yes |
179
+ | Auto-rules / TTL / dedup | yes | build it | partial | build it |
180
+ | Immutable audit log | yes | no | cloud logs | full history (better) |
181
+ | Human notification UX | **your code** | **your code** | **built-in, better** | your code |
182
+ | Multi-node / distributed | **no** | via Postgres | yes | **yes, much better** |
183
+ | Pauses the whole agent loop in-place | **no** | **yes, better in-framework** | yes | yes |
184
+
185
+ The bold cells in the last rows are where `holdpoint` loses. If those rows
186
+ are your requirements, take the other column's tool.
187
+
188
+ ## Status
189
+
190
+ v0.1.x. The public API (everything importable from `holdpoint`) follows
191
+ semver: breaking changes only with a major-version bump; `0.x` minor bumps
192
+ may extend but not break. The SQLite schema is versioned and migrates
193
+ forward automatically; downgrade is refused explicitly.
194
+
195
+ ## License
196
+
197
+ [FSL-1.1-ALv2](LICENSE) (Functional Source License): free for any use except
198
+ competing commercial hosting, and each release becomes Apache-2.0 two years
199
+ after publication. Contributions require the short [CLA](CLA.md).