Skip to main content

Dataoad

Open-source execution contracts and failure testing for side-effecting AI agent actions.

A timeout does not prove an action failed.

A successful tool call does not necessarily prove the intended external effect occurred.

Naive retry
calls=2
charges=2

Dataoad
execution=UNKNOWN
verification=CONFIRMED
final=CONFIRMED
calls=1
charges=1

Dataoad provides safe execution primitives for agents and other software that take real-world actions. If a charge, ticket, email, refund, or infrastructure change may have happened before its response was lost, blindly retrying can repeat the side effect.

Dataoad makes that uncertainty explicit:

CONFIRMED != FAILED != UNKNOWN

UNKNOWN is allowed to remain UNKNOWN. The default policy never automatically retries an ambiguous side-effecting action.

Dataoad is an early, local-first library and fault harness. It is not production-ready, does not make arbitrary third-party APIs idempotent, and does not guarantee exactly-once execution.

Install

Dataoad requires Python 3.11 or newer. From a repository checkout:

python -m venv .venv

Activate the environment with . .venv/bin/activate on POSIX or .venv\Scripts\Activate.ps1 in Windows PowerShell, then install the package:

python -m pip install -e .

For tests and contributor tools, install the development extra instead:

python -m pip install -e ".[dev]"

30-second example

This fake provider commits a charge and then raises TimeoutError, reproducing a lost response after mutation. Dataoad records UNKNOWN, makes no second charge, and then uses a read-only verifier to reconcile the receipt.

import asyncio

from dataoad import ActionRunner, ActionStatus, InMemoryLedger
from dataoad.testing import ChargeFault, FakePaymentProvider, PaymentVerifier


async def main() -> None:
    provider = FakePaymentProvider(charge_faults=[ChargeFault.TIMEOUT_AFTER_COMMIT])
    runner = ActionRunner(InMemoryLedger())
    request = {"customer_id": "customer_123", "amount_cents": 2_500}

    result = await runner.execute(
        "charge_customer",
        lambda: provider.charge(
            customer_id="customer_123",
            amount_cents=2_500,
            client_reference="order-123",
        ),
        request=request,
        idempotency_key="order-123",
        provider_reference="order-123",
        risk="financial",
    )

    assert result.status is ActionStatus.UNKNOWN
    assert provider.charge_calls == 1

    result = await runner.reconcile(result.receipt.action_id, PaymentVerifier(provider))
    assert result.status is ActionStatus.CONFIRMED
    assert provider.charge_calls == 1  # verification did not repeat the mutation


asyncio.run(main())

See examples/payment_timeout_after_commit.py for the complete naive-retry comparison and safety suite. The example is included in repository checkouts and source distributions; it is not installed as package data by the wheel.

For durable local receipts, give ActionRunner a file-backed SQLiteLedger instead. Reopening the same database preserves completed receipts and their idempotency claims across process restarts:

from pathlib import Path

from dataoad import ActionRunner, SQLiteLedger

runner = ActionRunner(SQLiteLedger(Path("dataoad.sqlite3")))

State semantics

The public outcome is an evidence statement, not merely a translation of an exception:

State Exact meaning Default mutation behavior
CONFIRMED There is sufficient evidence that the intended side effect happened. Do not retry.
FAILED There is sufficient evidence that the intended side effect did not happen. Retry only when policy and adapter evidence explicitly allow it.
UNKNOWN There is not enough trustworthy evidence to decide whether it happened. Never automatically retry.

An ordinary exception after the operation is invoked becomes UNKNOWN. This includes timeouts, lost connections, and malformed responses whenever mutation may already have occurred. DefinitiveFailure is different: raising it is a strong assertion by the provider adapter that the intended side effect did not happen. Use it only when that fact is known, not as a wrapper for a generic provider error.

A normal return is treated according to the evidence contract chosen by the caller:

  • Without a verifier, Dataoad preserves the simple v0.1 behavior and trusts the operation response as authoritative evidence of occurrence. The receipt is CONFIRMED even if the returned Python value is falsey.
  • With a verifier, a normal return establishes only that execution completed. Dataoad records direct external-effect evidence as UNKNOWN, always runs the verifier, and lets its bound evidence determine CONFIRMED, FAILED, or UNKNOWN.

Receipts preserve three related fields:

  • execution_status describes what direct execution established.
  • verification_status describes what independent read-back established.
  • final_status is the current public confidence statement returned as ActionResult.status.

A timeout-after-commit can therefore have execution_status=UNKNOWN, verification_status=CONFIRMED, and final_status=CONFIRMED. PENDING and COMPLETED are separate internal claim-lifecycle states; PENDING is not a fourth public outcome.

CONFIRMED means that sufficient evidence shows the intended effect happened at least once. It does not prove that the effect happened exactly once or that no duplicate exists.

Evidence is also aggregated across attempts. Once any attempt may have produced an unverified side effect, a later independent FAILED attempt cannot erase that uncertainty: the action remains UNKNOWN. A later authoritative confirmation may establish CONFIRMED; an action-wide final-state verifier may also resolve all relevant attempts explicitly.

ActionResult.value cannot be read for FAILED or UNKNOWN results. It raises UnresolvedActionError, so ambiguous work cannot look like normal success.

Retry policy

The default RetryPolicy performs one attempt and retries neither FAILED nor UNKNOWN. A retry of FAILED requires remaining capacity under max_attempts, retry_on_failed=True, and trustworthy evidence that no side effect occurred. That evidence is either:

  • a DefinitiveFailure(..., retryable=True) from the adapter; or
  • VerificationResult.absent(..., retry_safe=True) from a verifier that establishes healthy, conclusive absence and proves the original request is terminal or fenced so it cannot commit later.

VerificationResult.absent(...) defaults to retry_safe=False. A healthy read-back that shows nothing now remains UNKNOWN while the original request could still be in flight. Set retry_safe=True only when the adapter can prove terminal or fenced non-occurrence; that stronger result becomes FAILED and may authorize retry when policy also allows it.

retry_on_unknown=True is available only as an explicit escape hatch and emits a runtime warning. It can duplicate real-world side effects unless the downstream operation is independently safe.

Direct calls to RetryPolicy.should_retry(...) validate their runtime inputs before making any decision: status must be an exact ActionStatus, attempts an exact positive integer, and failure_is_retryable an exact boolean. Invalid values raise instead of falling through to a retryable branch.

The execution protocol owns this decision. An LLM or agent should not be asked to guess whether an ambiguous mutation is safe to repeat.

Idempotency semantics

An idempotency claim is scoped by (action_name, idempotency_key). request must include every input that can affect the intended side effect. Dataoad stores its canonical SHA-256 hash rather than the raw request.

The request must be an exact native Python dict representing a strict JSON object. Values may contain only None, exact bool, exact int, finite exact float, exact str, lists, and dictionaries with string keys. Python-specific representations such as tuples, enums, dataclasses, dates, datetimes, UUIDs, custom containers, and scalar subclasses are rejected instead of being silently coerced. Convert them explicitly at the call site when that conversion matches the application's intended semantics.

For one shared ledger:

  1. Same action, key, and request hash while the first claim is PENDING raises ActionInProgressError; the duplicate operation is not invoked.
  2. Same action, key, and request hash after completion replays the existing receipt with result.replayed=True; the operation is not invoked again. If that receipt is UNKNOWN and a verifier is supplied, Dataoad attempts read-only reconciliation instead.
  3. Same action and key with a different request hash raises IdempotencyConflictError before the new operation can run.
  4. Concurrent contenders are reduced to one winning claim by the ledger's atomic conditional write.
  5. The same key under a different action name is a different claim.

provider_reference is a separate, caller-supplied identity used to correlate the mutation with later provider lookup. It is not inferred from idempotency_key, and it is not the provider-generated provider_request_id returned after a successful request. Supply the exact external lookup reference before mutation whenever the receipt may be verified. Reusing a completed claim with a different provider reference is an idempotency conflict.

Provider return values are intentionally not persisted. An immediate confirmed result can expose its value, but a replay from a durable receipt may raise ResultValueUnavailableError when .value is accessed. Persist or read back business data in the system of record instead of relying on the ledger as a response store.

Caller metadata must be JSON-compatible. The top-level keys attempt_history, attempt_records, provider, provider_request_id_conflict, provider_request_ids, recovery, verification, verification_conflict, and verification_observations are reserved for Dataoad's receipt protocol and are rejected before a claim is created.

Local claim deduplication is not downstream idempotency

Dataoad's local claim deduplication prevents cooperating callers that share the same ledger and key contract from deliberately starting a second operation. Downstream idempotency is a property of the external provider, such as a provider-enforced idempotency key or conditional write.

There is still a distributed-systems gap between committing an external side effect and durably recording its outcome. If a ledger transition fails after invocation, Dataoad raises ActionPersistenceError with the action receipt and the in-process status/evidence it observed. The idempotency claim remains a safety barrier, but its exact durable state may be PENDING or already COMPLETED if the write committed before its response was lost. Inspect the ledger; never treat the error as permission to repeat the mutation. Use read-only recovery as described below when the receipt remains PENDING. Dataoad cannot turn a non-idempotent remote API into an exactly-once transaction. Use provider idempotency when available, in addition to Dataoad's local claim.

Before invoking the operation, Dataoad durably increments the attempt count. If that start_attempt() write succeeds but its acknowledgement is lost, the runner reads the claim back and continues only when the receipt proves exactly the single conditional transition it requested. It does not increment again. If that exact transition cannot be demonstrated, the operation is not invoked and ActionPersistenceError preserves the claim as a safety barrier.

Verification semantics

A verifier is a provider-specific object or callable that receives an ActionReceipt and performs read-only observation. It returns a VerificationResult:

  • VerificationResult.confirmed(...) reports positive evidence of occurrence.
  • VerificationResult.absent(...) records a healthy absence observation but remains UNKNOWN by default. Passing retry_safe=True asserts the stronger terminal/fenced non-occurrence needed for FAILED.
  • VerificationResult.unknown(...) reports missing or untrustworthy evidence.

A negative result is accepted as FAILED only when its health is HEALTHY, conclusive_absence=True, and retry_safe=True. Other negative claims are normalized to UNKNOWN. Verifier exceptions, invalid verifier responses, stale or partial reads, authentication failures, broken pagination, and failed positive controls must also remain UNKNOWN; "could not verify" is not "did not happen."

Mutation and verification must share the receipt's durable provider_reference. A decisive verifier result must echo that exact identity as verified_provider_reference; missing or mismatched binding is downgraded to UNKNOWN. Passing a verifier directly to execute(...) therefore requires provider_reference. If verification will happen only through a later reconcile(...) or recover_pending(...) call, still set the reference on the original execute(...) so it is present in the durable receipt.

Pass a verifier to execute(...) to check immediately after ambiguous execution, or call runner.reconcile(action_id, verifier) later for a completed UNKNOWN receipt. Verifier correctness is application-specific: Dataoad enforces the result contract, but cannot prove that custom evidence or a remote system of record is truthful.

The bundled fake payment verifier sets retry_safe=True only after checking a fake-provider in-flight fence as well as lookup health and absence. A production adapter needs an equivalent provider guarantee; a healthy empty snapshot alone is not enough.

Concurrent verifier observations are merged instead of using first-writer-wins. Contradictory CONFIRMED/FAILED evidence makes the durable result UNKNOWN and preserves both observations. Two confirmations with different provider request IDs remain CONFIRMED (occurrence is still known), but the receipt records provider_request_id_conflict=True and all observed IDs because they may indicate duplicate effects. Dataoad withholds a recovered or direct value when it cannot correlate that value to the retained provider ID. For verifier-recovered values, two missing IDs are never considered a match: a non-null provider_request_id must be present in both the verification and the retained receipt. This keeps occurrence CONFIRMED while refusing an uncorrelated optional value.

Stranded claim recovery

Recovery is explicit and read-only; Dataoad never leases or steals an in-flight mutation automatically.

  • If an ActionPersistenceError leaves a PENDING receipt with attempts >= 1, first ensure the original worker will not issue another mutation, then call await runner.recover_pending(action_id, verifier). A negative recovery can become FAILED only when the verifier returns absent(..., retry_safe=True) and therefore proves no late commit is possible. Other absence remains UNKNOWN.
  • If a crash occurred after claim creation but before the durable attempt count was incremented, the receipt has attempts == 0. An operator may call runner.release_unstarted(action_id). This conditionally removes only that uninvoked claim so the same idempotency key can be claimed again. It races atomically with attempt start: if invocation has begun, release is refused.

An ActionPersistenceError.receipt is the last safely observed receipt, not a promise that a failed write did not commit. Re-read the ledger before choosing between these recovery paths.

Concurrent recovery observations use the same conservative conflict merge as normal reconciliation. ActionPersistenceError also exposes observed_status, observed_metadata, and, when an immediate confirmed value was returned, value_available/observed_value. These are diagnostic evidence, not proof that the corresponding ledger transition committed.

Fault harness

From a repository checkout or an unpacked source distribution, run the bundled deterministic payment suite:

dataoad test examples/payment_timeout_after_commit.py

The CLI loads a Python file whose zero-argument run_safety_suite() function returns a dataoad.testing.SafetyReport. The bundled suite covers success, explicit pre-commit failure, replay, concurrency, key/body conflicts, faults on both sides of commit, verifier outage, an unhealthy false-negative, a verifier using the wrong external identity, and preservation of earlier UNKNOWN evidence across later attempts.

Representative output:

Timeout-after-commit comparison
  Naive retry: calls=2, charges=2, total=5000 cents
  Dataoad:    execution=UNKNOWN, verification=CONFIRMED, final=CONFIRMED, calls=1, charges=1

Action: charge_customer

Normal success                     PASS
Explicit pre-side-effect failure   PASS
Replay safety                      PASS
Concurrent duplicate safety        PASS
Same-key/body conflict             PASS
Timeout-before-commit recovery     PASS
Timeout-after-commit recovery      PASS
Unknown auto-retry protection      PASS
Verifier unavailable handling      PASS
Unhealthy false-negative handling  PASS
wrong-verification-identity        PASS
unknown-evidence-erasure           PASS

Final classification:
SAFE UNDER TESTED CONDITIONS

"Safe under tested conditions" describes only these deterministic scenarios. It is not formal verification or a universal safety claim. Each harness SafetyCheck.passed value must be an exact boolean; truthy values such as the string "false" are rejected rather than rendered as a pass. SafetyReport also rejects duck-typed or otherwise invalid check entries, so the constructor cannot bypass that invariant.

Architecture

caller / agent
      |
      v
 ActionRunner ---- atomic claim and receipt transitions ----> Ledger
      |                                                   InMemory / SQLite
      |
      +---- mutation operation ----> external system
      |
      +---- read-only Verifier ----> system of record
  • ActionRunner claims before invocation, classifies evidence, applies retry policy, and reconciles ambiguity.
  • ActionReceipt is an immutable snapshot with an optimistic version. It keeps hashes, identifiers, bounded errors, status, and explicit JSON metadata, not raw request bodies or arbitrary provider responses. Structured per-attempt records preserve execution, verification, and retry decisions.
  • InMemoryLedger provides thread-safe, process-local atomic claims for tests and ephemeral runs.
  • SQLiteLedger provides durable local receipts and cross-process claim uniqueness using SQLite transactions and constraints.
  • Verifier separates mutation execution from provider-specific read-back.
  • dataoad.testing supplies deterministic fault injection and qualified safety reports; it is not a simulator of every provider failure mode.

The core has no dependency on an agent framework or LLM provider.

Limitations and honest guarantees

Dataoad v0.1 is alpha software intended for inspection, testing, and local integration work. In particular:

  • It provides no exactly-once or production-ready guarantee.
  • Correct deduplication requires all contenders to share a ledger and use a stable action name, idempotency key, and complete semantic request.
  • InMemoryLedger loses state on exit. SQLiteLedger is local persistence, not a hosted, replicated, highly available coordination service.
  • A hard process stop can leave a PENDING claim requiring explicit application or operator recovery. v0.1 provides conditional recovery primitives but has no leases, ownership service, fencing tokens, or stale-claim worker.
  • The external mutation and ledger update cannot generally be one atomic transaction. Provider-side idempotency remains strongly recommended.
  • Reconciliation is only as sound and fresh as the custom verifier and provider read path.
  • The request hash avoids retaining a raw payload but is not encryption or an anonymization guarantee. Idempotency keys and explicit metadata are stored; do not put secrets in them. Bounded exception messages are also retained for diagnosis, so provider adapters should not include secrets in exception text.
  • Arbitrary provider values are not durable, and there are no built-in provider integrations, queues, dashboards, authentication, or multi-tenancy.

Roadmap

Likely next steps, subject to evidence from real integrations:

  1. Extend explicit PENDING recovery with durable ownership, leases, and provider-aware fencing semantics.
  2. Add adapter guidance and contract tests for provider idempotency and robust verification.
  3. Add machine-readable safety reports and broader crash/concurrency fault cases.
  4. Evaluate another transactional ledger backend when multi-host coordination is justified.
  5. Add thin framework adapters only after the core protocol is stable.

Cloud services, a dashboard, policy languages, and broad "agent platform" features are deliberately outside the v0.1 scope.

Contributing

See CONTRIBUTING.md. Contributions should preserve the central invariant: ambiguous side effects remain UNKNOWN unless trustworthy evidence proves otherwise.

License

Licensed under the Apache License 2.0. See LICENSE.

Download files

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

Source Distribution

dataoad-0.1.0.tar.gz (71.8 kB view details)

Uploaded Source

Built Distribution

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

dataoad-0.1.0-py3-none-any.whl (44.9 kB view details)

Uploaded Python 3

File details

Details for the file dataoad-0.1.0.tar.gz.

File metadata

  • Download URL: dataoad-0.1.0.tar.gz
  • Upload date:
  • Size: 71.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dataoad-0.1.0.tar.gz
Algorithm Hash digest
SHA256 cbc51bac055d80132c86fc10fece8b7d472c5dc9e216e4566dc8efb7cec260bc
MD5 a9297558335790dd58efccce9eba50d7
BLAKE2b-256 7468429c81a151a59745c3d6a2f25c24b2483ab1462f5da25f7bdc20db6a61a2

See more details on using hashes here.

Provenance

The following attestation bundles were made for dataoad-0.1.0.tar.gz:

Publisher: publish-v0.1.0.yml on getdatoad/datoad

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dataoad-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: dataoad-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 44.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for dataoad-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 17959f88a1de961a2b2984257a2c00820cbf1a74d66b29305419b9fd22c999d5
MD5 f9b6d03f5ba5416856d7593fde463a59
BLAKE2b-256 405352d117400618e91605457249802dbedbc4dab0ff7765dc598eaab273c684

See more details on using hashes here.

Provenance

The following attestation bundles were made for dataoad-0.1.0-py3-none-any.whl:

Publisher: publish-v0.1.0.yml on getdatoad/datoad

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

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