batpak 0.9.0

Event sourcing with causal graphs and caller-defined gates. Sync API, no async runtime.
Documentation

batpak

The Free Battery Factory makes batteries for software boundaries. batpak is the core battery: an embedded, sync-first append-only journal with typed payloads, Blake3 hash-chained ancestry, verifiable receipts, deterministic replay, and derived projections.

The family around it — syncbat, netbat, and hostbat — wires the journal into larger Rust hosts through explicit terminals and circuits. See the repository README for the full family map, scale-out model, and host path.

Use it when you need a tamper-evident, replayable record of what happened: agent action audit trails, local-first app logs, compliance evidence, event-sourced application state.

use batpak::prelude::*;

#[derive(serde::Serialize, serde::Deserialize, EventPayload)]
#[batpak(category = 0xF, type_id = 1)]
struct ThingHappened {
    value: i64,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;

    // The Store owns this directory and nothing else.
    let store = Store::open(StoreConfig::new(dir.path()))?;

    // A Coordinate names where events belong: an entity within a scope.
    let coord = Coordinate::new("entity:a", "scope:1")?;

    // Append source truth. The receipt is verifiable evidence of what
    // was accepted.
    let receipt = store.append_typed(&coord, &ThingHappened { value: 42 })?;

    // Accepted events are immutable.
    let fetched = store.get(receipt.event_id)?;
    println!("stored {} at sequence {}", fetched.event.header.event_id, receipt.sequence);

    store.close()?;
    Ok(())
}

Why not SQLite with an events table?

SQLite gives you durable rows. batpak gives you durable rows plus proof: every event is hash-bound to its per-entity ancestor with Blake3, every accepted write returns a receipt you can verify later, and projections are derived views rebuilt from the log by construction — read models cannot silently drift from source truth.

When batpak is the wrong tool: ad-hoc SQL over relational data; many writers on one mutable data_dir; raw write throughput over verifiable history; or automatic Raft replication inside the core crate. Scale with multiple journals and explicit host circuits instead — one writer per data_dir, by design.

Verifiability defaults

The safe behavior is the default; the weaker behavior is an explicit opt-out.

  • Open fails closed on an undecodable registry. EventPayloadValidation defaults to FailFast: a duplicate-kind collision or an incomplete upcast chain refuses Store::open (relax to Warn/Silent deliberately).
  • Signing policy. SigningPolicy::Optional (default) permits a keyless store; SigningPolicy::Required refuses to open without a signing key so an unsigned receipt can never be accepted. A configured signer that cannot build its cover fails the append closed unless StoreConfig::with_signing_downgrade_allowed(true).
  • Tamper checks on demand. Store::verify_chain() recomputes the full blake3 chain and returns a ChainVerificationReport; opt into ChainVerification::Recompute to run it at open and fail closed on tamper.
  • Observable truncation. Store::walk_ancestors_outcome() returns an AncestorWalk whose AncestryBoundary distinguishes a complete walk to genesis from a lineage truncated at a missing parent (e.g. a retention drop).

Payload encryption and crypto-shred

Off by default, opt-in behind the non-default payload-encryption cargo feature. Enable it and call StoreConfig::with_payload_encryption(granularity) to seal every payload at rest under a per-scope 256-bit XChaCha20-Poly1305 key (a pure-Rust AEAD; key and nonce bytes come from the OS CSPRNG, and key material zeroizes on drop and never appears in Debug/Display). A default build writes plaintext and pulls no crypto dependency.

KeyScopeGranularity chooses which events share a key — and therefore what a single erasure destroys: PerEntity (the default, one key per entity across all kinds), PerCategory (one key per event-kind category), PerTypeId (one key per full kind), or PerEvent (one key per individual event, the finest).

Store::shred_scope(selector) crypto-shreds a scope: it destroys that scope's KEY and flushes the keyset durable, making every payload sealed under it permanently unrecoverable. It destroys only the key, never any event frame — the ciphertext and its Blake3 chain identity survive on disk, so verify_chain, receipts, and signatures stay intact (identity is taken over the stored ciphertext, not the plaintext). A later read of a shredded payload reports StoreError::PayloadShredded (or a ReadDisposition::Shredded value via Store::get_shreddable) — never corruption and never the raw ciphertext. Erasure is exactly this explicit op; tombstone/retention compaction never auto-destroys a key.

Threat model — keys at rest. The keyset lives inside the store's own data directory, next to the ciphertext it protects. What crypto-shred buys: once a scope's key is destroyed and that destruction is flushed, the scope's payloads are unrecoverable even to an operator with full disk access — deletion becomes cryptographically effective rather than a best-effort overwrite. What it does not buy: it does not protect a disk image captured before the shred (the key was still present then), and a stolen live data directory yields both key and ciphertext. Holding the keyset out of the data directory — a separate volume, an OS keyring, or an external KMS — is a deployment concern, outside the core mechanism. batpak only ever observes "the key for scope X was destroyed"; the layer above maps that erasure to its own policy.

Cargo features

All non-default; a default build enables none of them and pulls none of their dependencies.

  • payload-encryption — the crypto-shred surface above (StoreConfig::with_payload_encryption / Store::shred_scope), backed by a pure-Rust XChaCha20-Poly1305 AEAD and an OS CSPRNG.
  • startup-registry-check — runs verify_registry() automatically before main, via one process-wide constructor, so a release binary that registers EventPayload types but never opens a Store still aborts on a kind collision. The always-on, portable path is the explicit verify_registry() call (no constructor, no extra dependency); this feature only automates it.

Trust

Judge the evidence, not the 0.x version number: a deep test surface (integration, property, crash-recovery, cold-start), deterministic concurrency proofs with loom, property-based tests over hash-chain integrity and canonical encoding, crash-recovery and fault-injection suites, mutation testing on critical seams, and a catalog of named invariants enforced by an executable integrity gate. See the root README and 03_INVARIANTS.md for the current counts.

Docs

Full documentation lives in the repository: the README for the evaluator path, the cookbook for task-shaped recipes, and MODEL / INVARIANTS / CONFORMANCE for the mental model and the guarantees.

bp records.