/** * Redis-backed `ConfirmationStore` — restart-durable pending confirmations. * * The in-memory `createInMemoryConfirmationStore` (see `./persistence.ts`) * is suitable for tests and the quickstart, but a process restart loses * every pending confirmation. Adopters running the adapter loop in a * production process need restart durability so user-held tokens * (REQUEST_CONFIRMATION flows that block for hours) survive deploys. * * This module ships a Redis-backed implementation that uses the same * minimal `RedisLedgerClient` surface (`get`/`set`) as the rest of the * audit + ledger stack. The store is generic over `H` (the conversation- * history shape) — the caller supplies a serializer for their provider's * history type. * * # Wire format * * Each pending confirmation is JSON-encoded under the key * `${prefix}:confirm:${token}`. The TTL on the key matches the * adopter-supplied `ttlSeconds` from `put()`. * * # Get-and-delete (take semantics) * * `take()` is single-use: a successful take deletes the key. Two * concurrent `take(token)` calls race — Redis `GET` + `DEL` is not atomic * without scripting, so at most one caller receives the pending * confirmation. Production adopters needing strict at-most-once semantics * across replicas should wire a Lua script; the JS implementation here * accepts a tiny race window where two `take` calls might both return * the same pending (the LATER one's adjudication then fails on * `REPLAY_SUPPRESSED` via the kernel's ledger — fail-closed by design). * * # Replay safety * * Confirmation tokens are NOT inputs to the kernel's adjudication — * they're externally-held opaque references. The kernel's * `confirmationReceipt` (which IS adjudicated against) is reconstructed * from the persisted envelope, not from the token. So storing them in * Redis does not change replay determinism. */ import type { Capability, IntentEnvelope } from "@adjudicate/core"; import type { BurnStore, ConfirmationStore, MemoryStore } from "./persistence.js"; /** * Minimal Redis surface required by the Redis confirmation store. Same * shape as `@adjudicate/audit`'s `RedisLedgerClient` so adopters can * reuse the same connection. */ export interface ConfirmationRedisClient { set(key: string, value: string, options?: { NX?: boolean; EX?: number; }): Promise; get(key: string): Promise; del(key: string): Promise; } export interface CreateRedisConfirmationStoreOptions { readonly client: ConfirmationRedisClient; /** * Adopter-supplied key namespacer. Adjudicate convention: wrap the * raw suffix into a tenant/env-namespaced key (e.g., * `${APP_ENV}:adjudicate:${suffix}`). Defaults to identity. */ readonly keyFor?: (suffix: string) => string; /** * Serializer for the assistant history snapshot. Required because `H` * is the provider's opaque history shape — Anthropic's MessageParam[] * and OpenAI's ChatCompletionMessageParam[] are both JSON-serializable * by default. Adopters who use richer types (Date, BigInt) should plug * in a custom serializer. * * Default: `JSON.stringify` / `JSON.parse`. */ readonly serializeHistory?: (h: H) => string; readonly deserializeHistory?: (s: string) => H; } /** * Redis-backed `ConfirmationStore`. Wraps the minimal `set/get/del` * surface. `put` writes with EX (TTL); `take` does GET then DEL. * * Adopters share the underlying Redis client across the ledger, the * kill switch, and the confirmation store — one connection per role * (read/write vs subscribe) is the typical production wiring. */ export declare function createRedisConfirmationStore(opts: CreateRedisConfirmationStoreOptions): ConfirmationStore; export interface CreateRedisMemoryStoreOptions { readonly client: ConfirmationRedisClient; readonly keyFor?: (sessionId: string) => string; readonly serialize?: (m: M) => string; readonly deserialize?: (s: string) => M; readonly defaultTtlSeconds?: number; } /** Redis-backed MemoryStore. Non-destructive get; SET EX put; non-atomic merge. */ export declare function createRedisMemoryStore(opts: CreateRedisMemoryStoreOptions): MemoryStore; /** * The Lua claim-and-burn script — the HEADLINE atomicity fix (022). * * A single-use authorization store cannot use a non-atomic GET-then-DEL: two * concurrent `burn(nonce)` calls would both `GET` the same pending grant before * either `DEL`s it, so BOTH redeem (double-spend). Redis evaluates a Lua script * ATOMICALLY (single-threaded, no interleaving with other commands), so reading * the value AND deleting it inside one `EVAL` is one indivisible step: the FIRST * burn returns the value and removes the key; every concurrent/subsequent burn * sees the key already gone and returns `nil`. At-most-once holds across * replicas WITHOUT relying on the kernel-ledger `REPLAY_SUPPRESSED` backstop. * * local v = redis.call('GET', KEYS[1]) * if v then redis.call('DEL', KEYS[1]) end * return v * * Returns the stored JSON string on the winning burn, `nil` (→ `null`) on every * other. Expiry is enforced by Redis itself: an EX-expired key is already gone, * so `GET` returns `nil` and the burn fails closed. */ export declare const BURN_CLAIM_AND_BURN_LUA: string; /** * Minimal Redis surface for the single-use burn store. `set` claims the grant * (SET NX EX, first-writer-wins); `eval` runs the atomic Lua claim-and-burn. * * `eval` is REQUIRED (not optional like defer-park's `evalIncrCheck`): a * single-use authorization store has NO safe non-atomic fallback — a GET+DEL * fallback would reintroduce the double-spend race the store exists to close. * Adopters wire their client's Lua `EVAL`: * - node-redis v4: `client.eval(script, { keys, arguments })` * - ioredis: `client.eval(script, keys.length, ...keys, ...args)` * Either way `evalGetDel(script, key)` must execute `script` with `KEYS[1] = * key` atomically and return the script's result (the stored string or `null`). */ export interface BurnRedisClient { set(key: string, value: string, options?: { NX?: boolean; EX?: number; }): Promise; /** * Run the atomic Lua claim-and-burn (`BURN_CLAIM_AND_BURN_LUA`) against a * single key. Returns the stored string on the winning burn, `null` * otherwise. MUST be the Redis `EVAL` path (atomic), never a JS GET-then-DEL. */ evalGetDel(script: string, key: string): Promise; } export interface CreateRedisBurnStoreOptions { readonly client: BurnRedisClient; /** * Key namespacer — Adjudicate convention wraps the raw suffix into a * tenant/env-namespaced key (e.g. `${APP_ENV}:adjudicate:${suffix}`). * Defaults to identity. */ readonly keyFor?: (suffix: string) => string; readonly serialize?: (record: R) => string; readonly deserialize?: (raw: string) => R; } /** * Redis-backed single-use `BurnStore` (022 T2). `mint` claims the grant with * `SET NX EX` (first-writer-wins, mirroring `Ledger.recordExecution` and * `defer-resume`'s `acquired !== "OK"` suppression); `burn` runs the ATOMIC Lua * `EVAL` get-and-delete (`BURN_CLAIM_AND_BURN_LUA`) so two concurrent burns of * the same nonce cannot both win — closing the non-atomic GET+DEL double-spend * race the production confirmation store documents (`persistence-redis.ts` * `take`). Fail-closed (§D #6): a burn miss, an EX-expired key (gone in Redis), * a malformed blob, or a store/IO error yields NO redemption (`null` / * rejection), never a fail-open grant. */ export declare function createRedisBurnStore(opts: CreateRedisBurnStoreOptions): BurnStore; /** * Nonce reconciliation (022 T3, Redis-side) — re-derive the nonce-bound * `intentHash` for the envelope a caller presents and constant-time-compare it * against the `intentHash` the burned `Capability` was minted against, BEFORE * honoring the burn (plan §3). Reuses the kernel's canonical recipe via * `reconcileNonceHash` (which re-derives via `deriveIntentHash` over the * untouched `intentHashInput` pre-image and compares via `timingSafeHexEqual`, * constant-time, fail-closed). A mutated nonce (or any tampered hashed field) * re-derives a different hash → `false`, so the burned grant is NOT honored. * * Belt-and-suspenders: also constant-time-compares the capability's bound * `intentHash` against the envelope's stored `intentHash` (both already * verified strings) via `timingSafeHexEqual`, so a capability detached onto a * DIFFERENT envelope (same nonce, drifted stored hash) is rejected without an * early-exit oracle. Returns `false` — never throws — on any mismatch (§D #6). */ export declare function reconcileBurnedCapability(capability: Capability, envelope: IntentEnvelope): boolean; //# sourceMappingURL=persistence-redis.d.ts.map