Verity
/Docs

Workflows

Workflows let you group multiple protected effects into a named business process. Each workflow has cases (business entities) and runs (execution attempts). If a run fails partway through, a new run picks up where the last one left off — completed effects return cached results, and only pending effects execute.

Verity is not a workflow engine. It doesn't schedule, orchestrate, or route work. Your code controls the sequence. Verity protects each step and provides the audit trail and recovery semantics.

Mental Model

Workflow: "refund_flow"

  ├── Case: "order_123"
  │     ├── Run: "run_001"  (crashed after step 2)
  │     │     ├── validate_order     ✅ committed
  │     │     ├── process_refund     ✅ committed
  │     │     └── notify_customer    ❌ (agent crashed)
  │     │
  │     └── Run: "run_002"  (recovery)
  │           ├── validate_order     ↩️ cached (skipped)
  │           ├── process_refund     ↩️ cached (skipped)
  │           └── notify_customer    ✅ committed (executed)

  └── Case: "order_456"
        └── Run: "run_001"
              ├── validate_order     ✅ committed
              ├── process_refund     ✅ committed
              └── notify_customer    ✅ committed

Key principles:

  • Cases are business-scoped — one case per order, customer, or intent
  • Runs are execution attempts — retry the same case with a new run
  • Effect keys are deterministic"${caseId}:${effectName}", same across runs
  • Completed effects are cached — a re-run skips them instantly

Basic Usage

TypeScript

import { VerityClient } from '@verityinc/sdk';

const verity = new VerityClient({
  baseUrl: 'https://api.useverity.io/v1',
  apiKey: process.env.VERITY_API_KEY!,
});

async function processRefund(orderId: string) {
  // Create a run for this case (order)
  const run = verity.workflow('refund_flow').case(orderId).run();

  // Step 1: Validate
  const order = await run.protect('validate_order', {
    act: () => orderService.validate(orderId),
  });

  // Step 2: Refund (with observe for crash recovery)
  const refund = await run.protect('process_refund', {
    observe: async () => {
      const existing = await stripe.refunds.list({ charge: order.chargeId, limit: 1 });
      return existing.data.length > 0 ? existing.data[0] : null;
    },
    act: () => stripe.refunds.create({ charge: order.chargeId }),
  });

  // Step 3: Notify
  await run.protect('notify_customer', {
    act: () => emailService.send({
      to: order.customerEmail,
      template: 'refund_complete',
      data: { refundId: refund.id, amount: order.amount },
    }),
  });

  return { orderId, refundId: refund.id };
}

Python

from verity import VerityClient

verity = VerityClient(
    base_url="https://api.useverity.io/v1",
    api_key=os.environ["VERITY_API_KEY"],
)

async def process_refund(order_id: str):
    run = verity.workflow("refund_flow").case(order_id).run()

    order = await run.protect("validate_order", act=validate_order)
    refund = await run.protect(
        "process_refund",
        observe=check_existing_refund,
        act=execute_refund,
    )
    await run.protect("notify_customer", act=send_email)

    return {"order_id": order_id, "refund_id": refund["id"]}

Effect Key Derivation

Within a workflow, the SDK derives effect keys automatically:

effectKey = `${caseId}:${effectName}`

// Examples:
// run.protect("validate_order", ...) → "order_123:validate_order"
// run.protect("process_refund", ...) → "order_123:process_refund"
// run.protect("notify", ..., { keySuffix: "alice@ex.com" }) → "order_123:notify:alice@ex.com"

This means the same effect in different runs maps to the same key. If run_001 already committed process_refund, run_002 calling run.protect('process_refund', ...) gets the cached result immediately.

Crash Recovery

When an agent crashes mid-workflow, here's what happens on re-run:

  1. Completed effects return cached results. The lease request comes back as cached_completed with the original result. No re-execution.
  2. Failed effects throw EffectPreviouslyFailedError. An admin must reset the effect in Explorer before the workflow can continue.
  3. Expired effects trigger observe(). If the prior agent crashed after acting but before committing, the observe function checks the external system to determine what actually happened.
  4. Pending effects execute normally. Effects that were never attempted proceed with act() as usual.
This means your workflow code doesn't need recovery logic. Just re-run the same sequence of protect() calls. Verity handles deduplication and recovery automatically based on each effect's state.

Cardinality with keySuffix

When the same logical action applies to multiple entities (e.g., notifying multiple recipients), use keySuffix to create distinct effect keys:

// TypeScript
for (const recipient of recipients) {
  await run.protect('notify', {
    act: () => emailService.send({ to: recipient }),
  }, {
    keySuffix: recipient,
    // effectKey = "order_123:notify:alice@example.com"
    // effectKey = "order_123:notify:bob@example.com"
  });
}
# Python
for recipient in recipients:
    await run.protect(
        "notify",
        act=lambda r=recipient: email_service.send(to=r),
        key_suffix=recipient,
    )

Cross-Namespace Workflows

A single workflow can span multiple namespaces. Override the namespace per-effect:

const run = verity.workflow('onboarding_flow').case('tenant_abc').run();

// This effect lives in the "billing" namespace
await run.protect('setup_billing', {
  act: () => billingService.createAccount(tenantId),
}, { namespace: 'billing' });

// This effect lives in the "provisioning" namespace
await run.protect('provision_infra', {
  act: () => cloudProvider.createResources(tenantId),
}, { namespace: 'provisioning' });

// This effect uses the default namespace (workflowName = "onboarding_flow")
await run.protect('send_welcome', {
  act: () => emailService.sendWelcome(tenantId),
});

Run IDs and Tracing

Every run gets a unique ID (auto-generated or explicit). Run IDs appear in the audit trail and Explorer UI, making it easy to trace which execution attempt produced each result.

// Auto-generated run ID
const run1 = verity.workflow('refund_flow').case('order_123').run();
console.log(run1.runId); // "run_1708444800000_a7f3bc2e"

// Explicit run ID for tracing
const run2 = verity.workflow('refund_flow').case('order_123').run({
  runId: 'attempt-3',
});

Viewing Workflows in Explorer

The Explorer UI shows workflows as cases on the Cases page:

  • Timeline view — cases grouped by date, showing status and progress
  • Run tracking — how many runs a case took (e.g., "2 runs" means one recovery)
  • Effect details — drill into each effect to see the full audit trail
  • Error hints — failed cases show a brief error description inline

Best Practices

Use meaningful case IDs

Case IDs should map to your business entities. Good: order_48392, tenant_abc, invoice_2024_003. Bad: uuid-v4 (hard to find in Explorer).

Use meaningful effect names

Effect names should describe the action: validate_order, process_refund, provision_vm. These appear in the Explorer and help operators understand what happened.

Always add observe() for costly actions

For actions that cost money, create infrastructure, or send notifications — always provide an observe function. It's your safety net for crash recovery.

Keep workflows idempotent-safe from the start

Don't rely on Verity as a crutch for poorly designed workflows. Design each step to be retryable from the start, with Verity as the coordination layer that prevents duplicates.

What's Next?

  • Error Handling — understand every error type, especially CommitUncertainError
  • Explorer UI — view workflow cases, runs, and audit trails
  • Core Concepts — deeper dive into leases, fence tokens, and the observe/act pattern