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.
- holdpoint-0.1.0/.gitignore +36 -0
- holdpoint-0.1.0/CHANGELOG.md +42 -0
- holdpoint-0.1.0/LICENSE +105 -0
- holdpoint-0.1.0/PKG-INFO +225 -0
- holdpoint-0.1.0/README.md +199 -0
- holdpoint-0.1.0/docs/api.md +227 -0
- holdpoint-0.1.0/docs/concepts.md +180 -0
- holdpoint-0.1.0/docs/faq.md +165 -0
- holdpoint-0.1.0/docs/quickstart.md +114 -0
- holdpoint-0.1.0/docs/recipes.md +275 -0
- holdpoint-0.1.0/pyproject.toml +189 -0
- holdpoint-0.1.0/src/holdpoint/__init__.py +85 -0
- holdpoint-0.1.0/src/holdpoint/_internal/__init__.py +1 -0
- holdpoint-0.1.0/src/holdpoint/_internal/transitions.py +27 -0
- holdpoint-0.1.0/src/holdpoint/clock.py +72 -0
- holdpoint-0.1.0/src/holdpoint/errors.py +276 -0
- holdpoint-0.1.0/src/holdpoint/hold.py +1574 -0
- holdpoint-0.1.0/src/holdpoint/models.py +222 -0
- holdpoint-0.1.0/src/holdpoint/policy.py +129 -0
- holdpoint-0.1.0/src/holdpoint/py.typed +0 -0
- holdpoint-0.1.0/src/holdpoint/serialization.py +363 -0
- holdpoint-0.1.0/src/holdpoint/sqlite_store.py +457 -0
- holdpoint-0.1.0/src/holdpoint/store.py +272 -0
- holdpoint-0.1.0/tests/__init__.py +0 -0
- holdpoint-0.1.0/tests/chaos/__init__.py +0 -0
- holdpoint-0.1.0/tests/chaos/test_crash_points.py +227 -0
- holdpoint-0.1.0/tests/chaos/test_lost_races.py +216 -0
- holdpoint-0.1.0/tests/compat/__init__.py +0 -0
- holdpoint-0.1.0/tests/compat/test_schema_migration.py +129 -0
- holdpoint-0.1.0/tests/concurrency/__init__.py +0 -0
- holdpoint-0.1.0/tests/concurrency/procs_helper.py +54 -0
- holdpoint-0.1.0/tests/concurrency/test_async_concurrency.py +84 -0
- holdpoint-0.1.0/tests/concurrency/test_processes.py +56 -0
- holdpoint-0.1.0/tests/concurrency/test_races.py +161 -0
- holdpoint-0.1.0/tests/concurrency/test_sqlite_contention.py +103 -0
- holdpoint-0.1.0/tests/conftest.py +56 -0
- holdpoint-0.1.0/tests/docs/__init__.py +0 -0
- holdpoint-0.1.0/tests/docs/test_documentation.py +82 -0
- holdpoint-0.1.0/tests/integration/__init__.py +0 -0
- holdpoint-0.1.0/tests/integration/test_realistic_run.py +190 -0
- holdpoint-0.1.0/tests/property/__init__.py +0 -0
- holdpoint-0.1.0/tests/property/conftest.py +17 -0
- holdpoint-0.1.0/tests/property/test_codec_properties.py +133 -0
- holdpoint-0.1.0/tests/property/test_lifecycle_properties.py +241 -0
- holdpoint-0.1.0/tests/support.py +160 -0
- holdpoint-0.1.0/tests/unit/__init__.py +0 -0
- holdpoint-0.1.0/tests/unit/test_approve.py +154 -0
- holdpoint-0.1.0/tests/unit/test_async_api.py +374 -0
- holdpoint-0.1.0/tests/unit/test_audit_contract.py +317 -0
- holdpoint-0.1.0/tests/unit/test_contract_pins.py +147 -0
- holdpoint-0.1.0/tests/unit/test_dedup.py +81 -0
- holdpoint-0.1.0/tests/unit/test_edges.py +222 -0
- holdpoint-0.1.0/tests/unit/test_guard.py +121 -0
- holdpoint-0.1.0/tests/unit/test_guard_signatures.py +114 -0
- holdpoint-0.1.0/tests/unit/test_hooks.py +127 -0
- holdpoint-0.1.0/tests/unit/test_lifecycle.py +132 -0
- holdpoint-0.1.0/tests/unit/test_mutation_pins.py +1011 -0
- holdpoint-0.1.0/tests/unit/test_patch.py +159 -0
- holdpoint-0.1.0/tests/unit/test_recovery.py +270 -0
- holdpoint-0.1.0/tests/unit/test_redaction.py +98 -0
- holdpoint-0.1.0/tests/unit/test_review_fixes.py +126 -0
- holdpoint-0.1.0/tests/unit/test_rules.py +171 -0
- holdpoint-0.1.0/tests/unit/test_serialization.py +168 -0
- holdpoint-0.1.0/tests/unit/test_sqlite_store.py +166 -0
- holdpoint-0.1.0/tests/unit/test_stores_contract.py +224 -0
- holdpoint-0.1.0/tests/unit/test_ttl.py +299 -0
- 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
|
holdpoint-0.1.0/LICENSE
ADDED
|
@@ -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.
|
holdpoint-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/holdpoint/)
|
|
31
|
+
[](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
|
+
[](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/holdpoint/)
|
|
5
|
+
[](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).
|