Skip to main content

decidio (Python)

The one-line approval gate for AI-agent actions — the agent suspends for human approval and resumes, sealing a portable Authority Receipt the customer owns. Decidio gates (proceed | route | block) + records; the agent executes its own action on resume. Decidio never executes and holds no downstream credentials. Python-first, with a TS twin (@decidio/sdk) that emits an identical request + receipt (conformance-asserted).

from decidio import guard

# one line — same surface in every runtime
create_opp = guard.protect(
    create_opp_raw,
    lambda o: {"action": "createOpportunity", "amount": o["Amount"], "scope": "Opportunity"},
)

# 0.2.0: the call returns what your function returned AND what Decidio recorded about it.
done = create_opp({"Amount": 86_000})
print(done.value)                              # your function's own return value, untouched
print(done.confirmation["evidence_tier"])      # e.g. "application_confirmed"
# Only want the value? `guard.protect_best_effort(...)` returns it bare — but then "the report
# was not recorded" becomes invisible again, which is the gap this shape exists to close.
  • proceed → runs immediately (auto-approved under a named, versioned policy rule), sealed.
  • routesuspends (DecidioSuspended): parks the call args in an agent-side store, the process may exit; resumes when a human approves and re-runs your function.
  • block → raises DecidioBlocked; your function never runs.

Setup

pip install 'decidio[signing]'
export DECIDIO_API_URL=https://decidio-api.onrender.com   # the hosted sandbox
python -m decidio init my-agent   # sign in, register the agent, mint its API token, write .env

Every later command reads .env from the same directory. First run tip: pass mode="blocking" to guard.protect(...) to watch the whole loop live (trigger → route to a human → approve with python -m decidio approvals approve <id> → your function executes). A brand-new agent matches no auto-approve rule, so every request routes to a human — deny-by-default is the product working, not a misconfiguration. Other commands: doctor (config + connectivity + token scope), receipt <id> (download the sealed Authority Receipt). One runtime dependency — cryptography, which signs the request-bound identity proof and the execution report, so it is a base dependency rather than an extra (without it a signed agent could not reach a confirming tier at all). Engine adapters are extras.

Durable resume (real approvals take minutes to days)

Without mode="blocking", a routed action suspends: it parks its call args locally and raises DecidioSuspended; the process may exit. Self-serve transport — start here: guard.worker() — a durable poll worker that re-executes parked actions on approval, exactly once. No inbound URL, no shared secrets; this is the transport for the hosted sandbox.

Operator deployments can use the signed webhook instead — Decidio POSTs a verdict to your resume URL and the handler verifies the HMAC fail-closed:

# FastAPI
@app.post("/decidio/resume")
async def decidio_resume(req: Request):
    return guard.resume.handle(await req.body(), req.headers.get("x-decidio-signature"))

Honest requirement: webhook signing uses a shared secret configured on BOTH sides — your DECIDIO_WEBHOOK_SECRET must equal the Decidio server's, and self-hosted production also allow-lists resume hosts. Against the hosted sandbox, use the worker.

Closing the last window yourself. Pass with_context=True and your action receives an ExecutionContext as its first argument — {decision_id, attempt_id, idempotency_key}:

pay_invoice = guard.protect(
    lambda ctx, inv: stripe.PaymentIntent.create(
        amount=inv["amount"], currency="usd",
        idempotency_key=ctx.idempotency_key,      # stable across every attempt at this decision
    ),
    lambda inv: {"action": "payInvoice", "amount": inv["amount"]},
    with_context=True,
)

Opt-in rather than inferred from the signature, because guessing wrong would hand a payment call a context object where it expected an invoice. describe still receives your arguments only — it describes the request, while the context describes the execution.

Either way the re-execution guarantee is: once invocation may have begun, the SDK never automatically invokes it again unless the downstream system provides an idempotency guarantee or an operator explicitly reconciles it. That holds across processes and restarts — a durable invoking marker is written to the pending store before your function is called. It is not exactly-once against an external API, which no client can offer; an action that ran and then raised becomes indeterminate and waits for reconcile() rather than being retried into a double-write.

reconcile() records an outcome; it does not undo an authority decision. A DENIED decision is refused on both outcomes — nothing ran, so there is no outcome to record. And a decision that is still invoking may be running the action right now, so releasing it takes an explicit assertion from the person who can actually check:

It refuses by returning, not by raising — check report_recorded and read diagnostic:

guard.resume.reconcile(id, outcome="not_committed", reason="...")                       # refused while invoking
guard.resume.reconcile(id, outcome="not_committed", reason="...", worker_stopped=True)  # accepted

The assertion is stamped into the ledger (reconciled.workerStopped), so the record shows that a person claimed to have checked — not merely that the decision was released.

If you wrote a custom store, see the upgrade section above — there are three changes, not two, and the third (mark_discarded) is a hard construction failure rather than a signature widening. Note also that Python raises TypeError on the old delete arity and the SDK treats a failed delete as best-effort cleanup, so a store left on the 0.2.x signature stops removing payloads silently.

indeterminate needs no such flag: the handler has already returned or raised, so nothing is in flight — only the downstream result is unknown.

Upgrading from 0.2.x to 0.3.0

0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so none of it is left to be discovered at runtime.

If you wrote a custom PendingStore, it will fail to construct. mark_discarded(decision_id, verdict, reason=None) is now REQUIRED — it writes the tombstone that makes a denied decision permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so this is refused at construction rather than deep inside a resolve. delete also takes an optional owning attempt (delete(decision_id, attempt_id=None)) so a superseded attempt cannot clean up the live one's payload, and settle takes worker_stopped. The shipped stores show the shape, and packages/conformance/vectors/ownership.json is the corpus your store should pass.

recover() now raises instead of returning [] when the ledger cannot be read (DecidioRecoveryUnavailable). [] and "I cannot tell you what you owe" are the same value to a caller and mean opposite things, so a script reading if (!(await recover()).length) treated a permissions change as proof that nothing was outstanding. Wrap the call if you poll it.

reconcile() refuses two things it used to allow. A DENIED decision cannot be reconciled on either outcome, and releasing a decision that is still invoking takes an explicit worker_stopped=True — that is the one state where the handler may be running right now, and releasing it hands one approved action to two live workers. It refuses by RETURNING, not raising: check report_recorded and read diagnostic.

The adapters return a different shape. decidio.adapters.temporal.gate and the LangGraph adapter returns ProtectedExecution{ value, decisionId, confirmation } — instead of the bare value. decidio.adapters.inngest.gate returns ProtectedExecution too — for a Python caller this is a SILENT break: result = await ...gate(...) keeps working and starts holding a dataclass. gate_interruptions is now authorize_interruptions and returns a THREE-tuple ending in confirmation_supported=False; the old name is a deprecated alias, but the arity change means unpacking into two names raises — a loud failure, chosen over silently dropping the flag. Read .value where you previously read the result.

The transport rule refuses URLs it used to accept. A gate URL must now be absolute and either https or an exact loopback host, whatever credentials the config holds. A relative or scheme-less apiUrl throws at startup — it used to pass on the reasoning that the shipped transport rejects it, which says nothing about a custom transport.

Decision ids are shape-checked. Letters, digits, dot, dash and underscore, starting with a letter or digit, at most 128 characters, never trimmed. The server enforces the same grammar (it had none before 0.3.0, which is why the SDK was the stricter of the two). Ids Decidio issues are well inside it.

/agent/confirm can answer 409. TERMINALLY_DENIED means a human refused the decision and your action appears to have run anyway; NOT_AUTHORIZED means there is no approved, sealed decision for evidence to attach to. Neither is a report failure and neither is retryable — the SDK raises DecidioHttpError with advice that says so, rather than the "only its report failed" note that fits every other confirm error.

Engine adapters (durable suspend on the engine you already run)

Thin translators onto each engine's native durable wait — pip install decidio[langgraph|temporal|openai]:

# LangGraph — true drop-in (interrupt() is contextvar-based)
create_opp = guard.protect(create_opp_raw, describe, adapter="langgraph")

# Inngest / Temporal / OpenAI Agents — pass the engine handle:
await decidio.adapters.inngest.gate(step, guard, ctx, run=lambda: create_opp_raw(o))
await decidio.adapters.temporal.gate(wf, guard, ctx, run=..., )
# Authorization-only: the OpenAI runtime executes the tool itself, so this door never sees the
# return value and has nothing to attest. It says so rather than handing back an empty confirmation.
resolved, pending, confirmation_supported = decidio.adapters.openai.authorize_interruptions(guard, run_state, describe)

Own the record — verify it yourself

Every outcome is a sealed W3C-VC (Ed25519 did:key), tamper-evident and offline-verifiable with no Decidio dependency:

pip install decidio[verify]
python -m decidio.verify --issuer <did:key:...> receipt.json

Lead with --issuer: it binds the check to YOUR workspace's DID. An unpinned run proves internal consistency only — any keyholder could have issued such a file — and says so.

What VALID means — and what it does not. A pinned VALID proves the receipt's bytes were sealed by that issuer and are unaltered since. It does not prove the approved action ran. Execution is confirmed after the seal, and a sealed record is immutable, so the receipt's own authority.result and confirmation.status are a snapshot taken at seal time — a receipt for an approved-but-never-executed action verifies VALID, correctly. The verifier prints those fields under as sealed, plus a note saying exactly this. The check is cryptographic, not semantic: it does not evaluate business rules, timestamp plausibility, revocation, or whether a later record superseded this one. For current execution status, ask the issuer.

Errors

Every Decidio error subclasses DecidioError, so one except DecidioError: catches all of them.

  • DecidioBlocked / DecidioRejected — policy blocked it / a human rejected it; your function never ran.
  • DecidioSuspended — durable mode: parked for async approval. Not a failure.
  • DecidioTimeout — blocking mode only: nobody decided in time; the decision stays open, nothing executed.
  • DecidioRateLimitError — the workspace's governed-action limit; carries limit / remaining / reset_at.
  • DecidioUnsafeArguments — your call arguments cannot survive JSON with their meaning intact, so nothing was routed and nothing ran. The message names the field and the fix. Most often: a NaN or Decimal, a datetime, a set, or an integer larger than 2^53 — a JSON reader on the other side parses that into a float and rounds it, so the human would approve a different number from the one your function received. Pass those as strings.
  • DecidioHttpError — the gate refused the call. Carries status, endpoint, the parsed body, and retryable — the field to branch on. A 401/403 is terminal (the agent token expired, or someone re-ran init for that agent and revoked it): retrying cannot help, so the message names the fix. Anything else is transient, and retrying is safe because a retry creates a new request and can never double-execute.
try:
    pay_invoice(inv)
except DecidioHttpError as e:
    if not e.retryable:
        alert_oncall(str(e))   # a fresh token is needed; no amount of retrying helps
        raise
    backoff_and_retry()

Invariants

Decidio never executes downstream / holds no downstream credentials (the only downstream touch is the opt-in, read-only read-back tier) · holds none of the parked payload · fail-closed signatures · no automatic re-invocation once invocation may have begun · deny-by-default policy · request-bound identity proof. The agent executes; Decidio gates, records, and signals.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

decidio-0.3.0.tar.gz (140.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

decidio-0.3.0-py3-none-any.whl (154.4 kB view details)

Uploaded Python 3

File details

Details for the file decidio-0.3.0.tar.gz.

File metadata

  • Download URL: decidio-0.3.0.tar.gz
  • Upload date:
  • Size: 140.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.14

File hashes

Hashes for decidio-0.3.0.tar.gz
Algorithm Hash digest
SHA256 44b1de44463b4bffb4a8b024787311d29dece133d24caf61c1f2ae470cf4fb87
MD5 31314280961e5c0d1fe80df02b1a6d66
BLAKE2b-256 87b35569d5f728615215509a706939690553f3bd2fc50950b94eb1012bc15e54

See more details on using hashes here.

File details

Details for the file decidio-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: decidio-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 154.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.14

File hashes

Hashes for decidio-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f2bd2b84e630d797ac835d85fe12677e998e72b1a6b892815ad615e8279f4e5a
MD5 ecccb03d383baf1009e6825f4f21cac4
BLAKE2b-256 2aaca26b975d234959cba1cd9fab43a83b20ff70d78071ba2c3b3081bb69658e

See more details on using hashes here.

Release history Release notifications | RSS feed

0.5.0

2 files

0.4.4

2 files

0.4.3

2 files

0.4.1

2 files

0.4.0

2 files

0.3.5

2 files

0.3.3

2 files

This release

0.3.0 This release

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.12

2 files

0.1.11

2 files

0.1.10

2 files

0.1.9

2 files

0.1.8

2 files

0.1.7

2 files

0.1.6

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page