@verityinc/sdk
v0.1.0
Published
TypeScript SDK for the Verity Durable Execution Ledger — exactly-once effect protection for agentic systems
Downloads
85
Maintainers
Readme
@verityinc/sdk
Exactly-once effect protection for AI agents and autonomous workflows.
Verity is a durable execution ledger that prevents duplicate side effects (API calls, payments, emails, database writes) — even if your agent crashes, retries, or runs concurrently with other agents. It enforces exactly-once semantics per effect key using time-bound leases and fencing tokens, with optional crash recovery via observe().
npm install @verityinc/sdkQuick Start
import { VerityClient } from '@verityinc/sdk';
const verity = new VerityClient({
baseUrl: 'https://api.useverity.com/v1',
apiKey: 'vt_live_xxxxxx',
namespace: 'payments',
});
// Protect a side effect — Verity guarantees it runs exactly once
const charge = await verity.protect('charge-order-456', {
act: () => stripe.charges.create({ amount: 5000, currency: 'usd' }),
});If an agent crashes mid-flight, the next attempt either returns the cached result (if already committed) or uses observe() to detect the completed action in the external system and safely commit it — preventing duplicate charges.
How It Works
Every call to protect() follows the lease → observe → act → commit pattern:
- Lease — acquire exclusive, time-bound access to the effect
- Observe (optional) — check the external system for an already-completed action (crash recovery)
- Act — execute the real-world side effect
- Commit — record the result in Verity's ledger
protect() performs these steps under the hood:
Agent A Verity Stripe
│ │ │
├── requestLease ───────────────►│ │
│◄── granted (fenceToken: 1) ───┤ │
│ │ │
├── stripe.charges.create ──────────────────────────────────────►
│◄── { id: "ch_xxx" } ─────────────────────────────────────────┤
│ │ │
├── commit(result) ─────────────►│ │
│◄── accepted ──────────────────┤ │If Agent A crashes after charging but before committing, Agent B picks up the expired lease. If you provided observe(), it checks Stripe to see if the charge already went through and commits the existing result — no duplicate. Without observe(), the SDK re-runs act(), so use downstream idempotency keys as a fallback.
When Should I Provide observe()?
- Provide it when a retry could have already executed the real-world action (e.g., agent crashed after calling Stripe but before committing).
observe()checks the external system so the retrier doesn't re-execute. - Skip it if the downstream system supports idempotency keys (e.g., Stripe's
Idempotency-Keyheader, a database unique constraint). In that case, re-executingact()is inherently safe. - Best practice: use both — downstream idempotency keys as your primary defense,
observe()as a safety net that avoids unnecessary calls.
Standalone Protection
For one-off effects where you control the idempotency key:
const result = await verity.protect('send-welcome-email:user_42', {
act: () => emailService.send({ to: '[email protected]', template: 'welcome' }),
});With Observe (Crash Recovery)
const charge = await verity.protect('charge-order-789', {
// Called only when a prior agent's lease expired (potential crash)
observe: async () => {
const existing = await stripe.charges.list({ metadata: { orderId: '789' } });
return existing.data.length > 0 ? existing.data[0] : null;
},
act: () => stripe.charges.create({
amount: 5000,
currency: 'usd',
metadata: { orderId: '789' },
}),
});Key Suffix for Cardinality
When the same action targets multiple entities:
// Send emails to multiple recipients — each gets its own effect
for (const recipient of recipients) {
await verity.protect('send-notification', {
act: () => emailService.send({ to: recipient }),
}, {
keySuffix: recipient, // effectKey = "send-notification:[email protected]"
});
}Workflow Mode (Multi-Effect Orchestration)
For workflows with multiple steps that need to be tracked together:
const run = verity.workflow('refund_flow').case('order_123').run();
// Each effect gets a deterministic key: "order_123:notify_customer"
await run.protect('notify_customer', {
observe: async () => {
const sent = await emailService.checkSent('order_123');
return sent ? { emailId: sent.id } : null;
},
act: () => emailService.send({ to: customer.email, template: 'refund_initiated' }),
});
// effectKey = "order_123:refund_payment"
await run.protect('refund_payment', {
act: () => stripe.refunds.create({ charge: chargeId }),
});Key behavior: if run_001 crashes after notify_customer succeeds but before refund_payment, then run_002 retries the same workflow — notify_customer returns the cached result instantly, and only refund_payment executes.
Per-Effect Namespace Override
A single workflow can span multiple namespaces. This matters because namespace is the kill-switch boundary — freezing "payments" won't block "notifications", even if both belong to the same workflow.
const run = verity.workflow('refund_flow').case('order_123').run();
await run.protect('notify_customer', {
act: () => emailService.send({ to: customer.email, template: 'refund' }),
}, { namespace: 'notifications' });
await run.protect('refund_payment', {
act: () => stripe.refunds.create({ charge: chargeId }),
}, { namespace: 'payments' });
await run.protect('release_inventory', {
act: () => inventoryService.releaseHold('order_123'),
}, { namespace: 'inventory' });The namespace resolution order is:
- Per-effect —
run.protect('step', opts, { namespace: 'payments' }) - Per-run —
.run({ namespace: 'custom' }) - Default — falls back to
workflowName
Namespace ≠ Workflow. Namespace is an operational boundary (freeze/unfreeze, scoped API keys). Workflow name is a logical grouping (Explorer UI, traceability). They compose independently — one workflow can touch many namespaces, and one namespace can serve many workflows.
Effect Key Construction
| Mode | Key derived from | Example |
|---|---|---|
| verity.protect(key, ...) | You provide it | "charge-order-456" |
| run.protect(name, ...) | caseId:effectName | "order_123:refund_payment" |
| run.protect(name, ..., { keySuffix }) | caseId:effectName:suffix | "order_123:email.send:[email protected]" |
In workflow mode, the runId is not part of the key — this is intentional. It ensures idempotency across retry runs for the same case.
Configuration
const verity = new VerityClient({
// Required
baseUrl: 'https://api.useverity.com/v1',
apiKey: 'vt_live_xxxxxx',
// Optional
namespace: 'payments', // default namespace for all calls
agentId: 'worker-1', // identifies this agent in audit logs
autoRenew: true, // auto-renew leases in background (default: true)
renewAtFraction: 0.65, // renew at 65% of lease duration (default: 0.65)
requestTimeoutMs: 20_000, // HTTP timeout per request (default: 20s)
// Conflict retry (when another agent holds the lease)
conflictRetry: {
enabled: true, // default: true
maxAttempts: 12, // default: 12
initialDelayMs: 500, // default: 500ms
maxDelayMs: 15_000, // default: 15s
jitter: true, // ±30% randomization (default: true)
},
// Custom logger (defaults to console.warn/error)
logger: {
warn: (msg, ...args) => pino.warn(msg, ...args),
error: (msg, ...args) => pino.error(msg, ...args),
debug: (msg, ...args) => pino.debug(msg, ...args), // optional
},
});Error Handling
The SDK provides structured error classes so you can handle each case precisely:
import {
VerityError,
LeaseConflictError,
EffectPreviouslyFailedError,
CommitUncertainError,
VerityValidationError,
} from '@verityinc/sdk';
try {
await verity.protect('charge-order-456', { act: () => charge() });
} catch (err) {
if (err instanceof CommitUncertainError) {
// The real-world action SUCCEEDED but Verity couldn't confirm the commit.
// DO NOT RETRY protect() — you'll risk a duplicate side effect.
//
// Instead:
// 1. HALT the workflow immediately
// 2. Check the effect state via Explorer UI or query API
// 3. Manually commit or reconcile if needed
console.error('Action ran but commit uncertain:', err.result);
await alertOps(err);
}
if (err instanceof LeaseConflictError) {
// Another agent is processing this effect (retries exhausted)
console.log('Effect is being handled by another agent');
}
if (err instanceof EffectPreviouslyFailedError) {
// This effect failed on a prior attempt — admin reset required
console.log('Prior failure:', err.cachedError);
}
if (err instanceof VerityValidationError) {
// Payload too large (>64KB) or not JSON-serializable
console.log('Fix your input:', err.message);
}
}Error Hierarchy
VerityError (base — catch all Verity errors)
├── VerityApiError — non-2xx response from API
├── VerityConfigError — missing baseUrl, apiKey, etc.
├── VerityValidationError — payload not serializable or >64KB
├── LeaseConflictError — 409: another agent holds the lease
├── EffectPreviouslyFailedError — cached failure, needs admin reset
└── CommitUncertainError — action succeeded, commit unconfirmedLow-Level API
For power users who want manual control over the lease lifecycle:
// 1. Request a lease
const lease = await verity.requestLease('payments', {
effectKey: 'charge-order-456',
leaseDurationMs: 30_000,
});
if (lease.status === 'granted') {
// 2. Execute your action
const result = await stripe.charges.create({ amount: 5000 });
// 3. Commit the result
await verity.commit('payments', {
effectKey: 'charge-order-456',
fenceToken: lease.fenceToken,
leaseToken: lease.leaseToken,
result,
});
}
if (lease.status === 'cached_completed') {
// Already done — use lease.cachedResult
}Auto Lease Renewal
By default, the SDK automatically renews leases in the background while act() runs. This prevents lease expiry during long-running actions.
- Renewal fires at 65% of the lease duration (configurable via
renewAtFraction) - Schedules based on the server-returned expiry (not a fixed interval)
- Non-overlapping: won't fire a second renewal while one is in flight
- Stops automatically on commit/fail or if the lease is lost (409/404)
- Timers are
unref()'d so they won't keep your Node.js process alive
Disable with autoRenew: false if you prefer manual control.
Requirements
- Node.js >= 18 (uses native
fetchandcrypto.randomUUID()) - TypeScript >= 5.0 (optional, but types are included)
- Zero runtime dependencies
License
MIT
