Metadata-Version: 2.5
Name: holdpoint
Version: 0.1.0
Summary: A durable approval queue for agent actions: pause, review, patch, then run exactly once.
Project-URL: Homepage, https://github.com/allasava-ye/holdpoint
Project-URL: Documentation, https://github.com/allasava-ye/holdpoint/tree/main/docs
Project-URL: Repository, https://github.com/allasava-ye/holdpoint
Project-URL: Issues, https://github.com/allasava-ye/holdpoint/issues
Project-URL: Changelog, https://github.com/allasava-ye/holdpoint/blob/main/CHANGELOG.md
Author: Saveliy
License-Expression: FSL-1.1-ALv2
License-File: LICENSE
Keywords: agents,approval,audit,human-in-the-loop,llm,safety,sqlite
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# holdpoint

[![CI](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml/badge.svg)](https://github.com/allasava-ye/holdpoint/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/holdpoint)](https://pypi.org/project/holdpoint/)
[![Python](https://img.shields.io/pypi/pyversions/holdpoint)](https://pypi.org/project/holdpoint/)

> A hold point is a stage where work stops until someone authorized releases it.
> `holdpoint` gives your agent one.

## The problem

Your agent drafts an email to a customer, and the draft is good — most of the
time. The one time it is not, `smtp.send()` has already run. Every team that
ships agents ends up writing the same layer by hand: park the risky call
somewhere, let a human look at it, run it after approval. Hand-rolled versions
tend to share the same bugs: the action runs twice when two reviewers click at
once, approved work vanishes when the process restarts, and nobody can say
afterwards who approved what.

`holdpoint` is that layer as a library: one primitive — a deferred function
call waiting for a decision — with the concurrency, durability, and audit
questions answered once. Zero runtime dependencies; state lives in a SQLite
file you own.

## Install

```bash
pip install holdpoint
```

Python 3.10+. Nothing else.

## Example

```python
from holdpoint import HoldPoint, SQLiteStore

hold = HoldPoint(store=SQLiteStore("approvals.db"))

@hold.guard(kind="send_email", ttl="24h")
def send_email(to: str, subject: str) -> str:
    print(f"sending {subject!r} to {to}")
    return "sent"

pending = send_email(to="ceo@corp.com", subject="Proposal")  # queued, NOT sent
print(pending.status.value)                                  # "pending"

for item in hold.pending():                                  # your dashboard, CLI, or bot
    result = hold.approve(item.id, actor="sava", patch={"subject": "Shorter"})
    print(result.value)                                      # "sent"
```

Calling the function stores the call. Approving executes it — once — with the
reviewer's edits applied. Everything lands in an append-only audit trail in
the same database file.

## When you don't need this

Honesty first: `holdpoint` is a narrow tool, and often the wrong one.

- **Your agent runs on LangGraph, Pydantic AI, or the OpenAI Agents SDK.**
  Use their native mechanisms (`interrupt()`, `requires_approval=True`,
  `needs_approval=True`). They pause the whole agent loop and resume it with
  the human's answer, which composes better inside those frameworks than an
  external queue does.
- **You want Slack/email approval UX out of the box.** That is a product, not
  a library. [HumanLayer](https://humanlayer.dev) sells exactly it.
  `holdpoint` gives you hooks and a queue; the notification channel is your
  code.
- **You need approvals across multiple services or machines.** Use
  [Temporal](https://temporal.io). Its signals + durable workflows are
  strictly stronger than a SQLite file, at the cost of running a cluster.
- **The action is cheap to undo.** A soft-deleted row does not need a human
  gate. Add approval only where mistakes are expensive and irreversible.

`holdpoint` fits when you have a Python process, a handful of scary function
calls, and no appetite for new infrastructure.

## Features

**Guard any function.** `@hold.guard(kind=...)` works on `def` and
`async def`, with positional-only params, `*args`, `**kwargs`, and defaults
(captured at submit time, so the reviewer sees the complete call). Full type
inference: the IDE knows `send_email(...)` returns `Action[str]`.

**Patch before running.** The reviewer is not a yes/no button. `approve(id,
actor="sava", patch={"subject": "Shorter"})` edits arguments before
execution; unknown keys and unserializable values are rejected with exact
messages, and the audit records both the original payload and the patch.

```python
hold.approve(item.id, actor="sava", patch={"amount": 90})
```

**Exactly-once execution.** Approval is a compare-and-set in the store. Fifty
concurrent approvals of one action execute it once; the other forty-nine wait
and return the same stored result. See [Guarantees](#guarantees) for the
precise claim.

**Crash recovery.** If the process dies between "approved" and "executed",
`hold.recover()` at the next start finishes the job — or, if the crash
happened mid-execution, surfaces the ambiguity instead of guessing.

```python
report = hold.recover()          # call at startup, before serving traffic
```

**TTLs with policies.** `ttl="24h"` plus `on_expire="expire" | "auto_approve"
| "auto_reject"`. No background threads: call `hold.expire_due()` on your own
cadence; overdue actions are un-approvable regardless.

**Auto-approval rules.** A predicate can wave routine actions through so the
human only sees the interesting ones. Rules run the same machinery and leave
the same audit trail (`actor="rule:<name>"`).

```python
def small_amounts(kind, payload):
    if kind == "wire" and payload["amount"] < 100:
        return "approve"
    return None

hold = HoldPoint(store=..., rules=[small_amounts])
```

**Deduplication.** `dedup_key=lambda p: p["email"]` collapses repeated
proposals of the same action while one is live; the same key with a
*different* payload is a loud conflict, never a silent overwrite.

**Immutable audit.** Who submitted, who decided, what was patched, what ran,
what it returned or raised — append-only, enforced by database triggers, with
secrets redacted via `redact=("password",)`.

**Hooks.** `@hold.on_pending` (and `on_approved` / `on_rejected` /
`on_expired`) is where you wire Telegram, Slack, or anything else.
Notifications are best-effort by design; the queue is the source of truth.

**Know where things run.** The process that calls `approve()` executes the
function — your review bot needs the same imports, credentials, and network
access as the agent would. Hooks run synchronously in the thread that
caused the event; keep them fast or hand off to your own worker. Details in
[docs/concepts.md](docs/concepts.md#where-things-run).

**Async.** `await send_email(...)`, `await hold.approve_async(...)`. Sync and
async guards share one queue and one semantics.

## Guarantees

What is promised, precisely:

- **At most one execution at a time, ever.** Concurrent approvals — threads,
  event loops, separate processes on one machine — collapse to one execution
  via an atomic claim; the rest share its result.
- **Exactly one execution if the process survives the call.** The normal
  case.
- **After a crash:** an approved action whose execution never started is
  completed by `recover()` safely. An action that died *mid-execution* is
  ambiguous by nature — the default marks it `failed` with an explicit "side
  effect unknown" error for a human to investigate; opting into
  `recover(interrupted="retry")` chooses at-least-once, knowingly.
- **Every state change commits atomically with its audit entry.** A crash
  cannot separate what happened from the record of it.

What is *not* promised: coordination across machines (single shared SQLite
file, one host), guaranteed hook delivery (best-effort; poll the queue for
truth), retries of failed actions (a failed action stays failed until a human
re-submits), and protection from `os._exit()` between a side effect and its
recording — that gap is physics, and `holdpoint` chooses to expose it rather
than pretend.

## Compared honestly

| | holdpoint | LangGraph interrupt | HumanLayer | Temporal |
|---|---|---|---|---|
| Standalone (no framework/service) | yes | no | no (SaaS) | no (cluster) |
| Runtime dependencies | 0 | many | 6 | server + SDK |
| Durable queue you own | SQLite file | checkpointer | their cloud | their cluster |
| Edit args before run | yes | via resume value | feedback only | via signal |
| Exactly-once execute | yes (see above) | n/a | no claim | yes |
| Auto-rules / TTL / dedup | yes | build it | partial | build it |
| Immutable audit log | yes | no | cloud logs | full history (better) |
| Human notification UX | **your code** | **your code** | **built-in, better** | your code |
| Multi-node / distributed | **no** | via Postgres | yes | **yes, much better** |
| Pauses the whole agent loop in-place | **no** | **yes, better in-framework** | yes | yes |

The bold cells in the last rows are where `holdpoint` loses. If those rows
are your requirements, take the other column's tool.

## Status

v0.1.x. The public API (everything importable from `holdpoint`) follows
semver: breaking changes only with a major-version bump; `0.x` minor bumps
may extend but not break. The SQLite schema is versioned and migrates
forward automatically; downgrade is refused explicitly.

## License

[FSL-1.1-ALv2](LICENSE) (Functional Source License): free for any use except
competing commercial hosting, and each release becomes Apache-2.0 two years
after publication. Contributions require the short [CLA](CLA.md).
