Agent Idempotency

Cross-dispatch idempotency keys honored at every agent consumer boundary. Composes the v0.9.15 sealed b.middleware.idempotencyKey patterns (namespace-hashed keys, sealed result columns) into a generic agent-shaped surface:

- **instance.get(method, actorId, key)** — returns cached result envelope or null. The result blob unseals via b.cryptoField when a vault is configured. - **instance.put(method, actorId, key, result, opts?)** — serialize (b.safeJson.stringify), seal the result blob at rest via b.cryptoField (when a vault is configured — the default in a booted app; vault-less, the blob is stored as-is), persist with TTL. Refuses if the same (method, actorId, key) already has a cached entry whose requestFingerprint differs from the supplied args fingerprint (defends key-reuse-different-args attack). - **instance.invalidate(method, actorId, key)** — operator opt-out (e.g., a saga compensation that needs to allow a fresh retry). - **instance.gc({ olderThanMs })** — periodic cleanup, wires into b.scheduler.

JMAP §3.7 requires method-level idempotency ("if Email/set is retried with the same accountId+id, the server MUST return the same result"). With v0.9.22 every mutating agent method honors args.idempotencyKey and the consumer side dedupes BEFORE running — at-least-once delivery on the queue + at-most-once at the consumer = exactly-once end-to-end.

var idem = b.agent.idempotency.create({
  store: myBackingStore,
  ttlMs: b.C.TIME.hours(24),
});

var result = await agent.move({
  actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
  idempotencyKey: "jmap-req-abc",
});

// Retry returns cached result, doesn't re-bump modseq:
var result2 = await agent.move({
  actor: u, fromFolder: "INBOX", toFolder: "Archive", objectIds: [oid],
  idempotencyKey: "jmap-req-abc",
});

b.agent.idempotency.create(opts) #

stable0.9.22
{
  store:        { get, put, delete, gc },     // optional; in-memory default
  audit:        b.audit namespace,            // optional
  ttlMs:        number,                        // default 24h
  maxResultBytes: number,                      // default 1 MiB per entry
  fingerprintArgs: boolean,                    // default true
}

Create an idempotency instance for an agent. Operator supplies a backing store implementing { get, put, delete, gc }; framework ships an in-memory default for single-process deployments.

var idem = b.agent.idempotency.create({});
var existing = await idem.get("move", "u1", "jmap-req-abc");
if (existing) return existing.result;
var result = await mailAgent.move(args);
await idem.put("move", "u1", "jmap-req-abc", result, { requestFingerprint: "..." });

b.agent.idempotency.reseal(opts) #

stable0.14.12gdprsoc2
{
  store:       Object,   // { listAll(): rows[], putResealed(row) } (sync or async)
  oldRootJson: string,   // b.vault.getKeysJson() of the retired keypair
  newRootJson: string,   // b.vault.getKeysJson() of the new keypair
}

Re-seals every AAD-bound cached-result cell on an operator-supplied store from the OLD vault keypair to the NEW one, out-of-band. The in-tree vault-key rotation pipeline only walks tables inside db.enc, so an operator-supplied idempotency store is unreachable to it — after a keypair rotation its cells would otherwise be orphaned under the retired root (CWE-320). Composes the same AAD-cell re-seal the rotation pipeline uses, rebuilding each cell's AAD from the registered schema (one source of truth). Only AAD-sealed cells are touched; plain rows pass through.

await b.agent.idempotency.reseal({ store: durableStore, oldRootJson: oldKeys, newRootJson: newKeys });
// → { table: "agent_idempotency", resealed: 12 }

Last updated 2026-08-08T16:39:49.652Z by seeder.