Skip to content

Failure Model: Errors as Values ​

Status: Stable Last Updated: 2026-03-29


Current Contract Note: This page reflects the current Core v5 error model. lastError remains the sole current error surface; accumulated system.errors and SystemDelta.appendErrors are removed.

The Core Principle ​

In Manifesto, errors are values, not exceptions.

Errors are values. They live in Snapshot. They never throw.

This is not a stylistic choice—it is an architectural requirement that follows from determinism and explainability.

That rule stays the same across both public runtime paths:

  • direct action submission through @manifesto-ai/sdk
  • the optional approval/history runtime

The runtime entry differs, but failures still become state and runtime records rather than control-flow escapes.


Why Errors as Values? ​

The Problem with Exceptions ​

Traditional exception-based error handling:

typescript
try {
  await doSomething();
} catch (error) {
  handleError(error);
}

Problems:

  • Control flow is non-local: Execution jumps unpredictably
  • Hard to trace: Exception path is not captured in normal flow
  • Difficult to serialize: Stack traces and exception state are runtime-specific
  • Cannot be inspected without catching: Errors are invisible until they explode
  • Breaks determinism: Same input can produce different control paths based on environment

The Manifesto Approach ​

Errors are values in Snapshot:

json
{
  "system": {
    "lastError": {
      "code": "VALIDATION_ERROR",
      "message": "Title cannot be empty",
      "source": { "actionId": "addTodo", "nodePath": "flow.steps[0]" },
      "timestamp": 1704067200000
    }
  }
}

Benefits:

  1. Traceability: Errors are in Snapshot, visible at any time
  2. Locality: Error handling is just reading Snapshot
  3. Serializability: Errors survive serialization
  4. Explainability: Trace shows when/where error occurred
  5. Determinism: Same input → same error → same state

Error Structure ​

ErrorValue Specification ​

typescript
type ErrorValue = {
  /** Error code (machine-readable) */
  readonly code: string;

  /** Human-readable message */
  readonly message: string;

  /** Where the error occurred */
  readonly source: {
    readonly actionId: string;
    readonly nodePath: string;
  };

  /** When the error occurred (Unix timestamp) */
  readonly timestamp: number;

  /** Additional context (optional) */
  readonly context?: Record<string, unknown>;
};

System State ​

Errors are stored in the system portion of Snapshot:

typescript
type SystemState = {
  /** Current status */
  readonly status: 'idle' | 'computing' | 'pending' | 'error';

  /** Last error (null if none) */
  readonly lastError: ErrorValue | null;

  /** Pending requirements waiting for Host */
  readonly pendingRequirements: readonly Requirement[];

  /** Current action being processed (if any) */
  readonly currentAction: string | null;
};

Key fields:

  • status: Current computation state (includes 'error')
  • lastError: Current error state

How Errors Work ​

Flow-Level Errors ​

In Flow, errors are declared using the fail node:

json
{
  "kind": "if",
  "cond": {
    "kind": "lte",
    "left": { "kind": "len", "arg": { "kind": "get", "path": "input.title" } },
    "right": { "kind": "lit", "value": 0 }
  },
  "then": {
    "kind": "fail",
    "code": "EMPTY_TITLE",
    "message": { "kind": "lit", "value": "Title cannot be empty" }
  }
}

When Core encounters a fail node:

  1. Core creates an ErrorValue
  2. Core emits a SystemDelta:
    • { lastError: errorValue, status: 'error' }
  3. The Host/Core boundary applies that transition with applySystemDelta()
  4. Core terminates computation
  5. Core returns with status: 'error'

There is no throw. There is no catch. Just deterministic system transitions.

Effect-Level Errors ​

Effect handlers (implemented by Host) must return errors as patches, not throw:

typescript
// WRONG: Throwing
async function fetchUserHandler(params) {
  const response = await fetch(`/users/${params.id}`);
  if (!response.ok) throw new Error('Not found');  // DON'T DO THIS
  return { ok: true, patches: [] };
}

// RIGHT: Errors as patches
async function fetchUserHandler(params) {
  try {
    const response = await fetch(`/users/${params.id}`);
    if (!response.ok) {
      return [
        { op: 'set', path: 'user', value: null },
        { op: 'set', path: 'fetchError', value: `HTTP ${response.status}` }
      ];
    }
    const user = await response.json();
    return [
      { op: 'set', path: 'user', value: user },
      { op: 'set', path: 'fetchError', value: null }
    ];
  } catch (e) {
    return [
      { op: 'set', path: 'user', value: null },
      { op: 'set', path: 'fetchError', value: e.message }
    ];
  }
}

Effect handler contract:

  1. Accept (type: string, params: Record<string, unknown>)
  2. Return Patch[] (success case) or Patch[] with error info (failure case)
  3. Never throw. Errors are expressed as Patches.

Why this matters:

  • Host controls the compute loop and can decide what to do with errors
  • Errors are visible in Snapshot (no hidden failure state)
  • Next compute cycle can check for errors and react
  • Deterministic: Same failure → same patches → same resulting state

Error Handling Patterns ​

Pattern 1: Validation Errors ​

Check before acting:

json
{
  "kind": "seq",
  "steps": [
    {
      "kind": "if",
      "cond": {
        "kind": "not",
        "arg": { "kind": "get", "path": "isValid" }
      },
      "then": {
        "kind": "fail",
        "code": "VALIDATION_ERROR",
        "message": { "kind": "get", "path": "validationMessage" }
      }
    },
    {
      "kind": "patch",
      "op": "set",
      "path": "user",
      "value": { "kind": "get", "path": "input.user" }
    }
  ]
}

Pattern 2: Effect Error Recovery ​

Check Snapshot after effect execution:

json
{
  "kind": "seq",
  "steps": [
    {
      "kind": "effect",
      "type": "api:syncTodo",
      "params": { "id": { "kind": "get", "path": "input.todoId" } }
    }
  ]
}

After Host fulfills the effect and calls compute() again, Flow can check for errors:

json
{
  "kind": "if",
  "cond": { "kind": "get", "path": "syncError" },
  "then": {
    "kind": "seq",
    "steps": [
      {
        "kind": "patch",
        "op": "set",
        "path": "syncStatus",
        "value": { "kind": "lit", "value": "error" }
      },
      {
        "kind": "patch",
        "op": "set",
        "path": "ui.showRetryDialog",
        "value": { "kind": "lit", "value": true }
      }
    ]
  },
  "else": {
    "kind": "patch",
    "op": "set",
    "path": "syncStatus",
    "value": { "kind": "lit", "value": "synced" }
  }
}

Pattern 3: Retry Scheduling ​

Track retry intent in domain state; system error fields are transitioned through SystemDelta, not patch paths:

json
{
  "kind": "seq",
  "steps": [
    {
      "kind": "patch",
      "op": "set",
      "path": "retryCount",
      "value": {
        "kind": "add",
        "left": { "kind": "get", "path": "retryCount" },
        "right": { "kind": "lit", "value": 1 }
      }
    },
    {
      "kind": "patch",
      "op": "set",
      "path": "ui.showRetryDialog",
      "value": { "kind": "lit", "value": false }
    },
    {
      "kind": "effect",
      "type": "api:retry",
      "params": {}
    }
  ]
}

Pattern 4: Error Reporting to User ​

Map errors to UI state:

json
{
  "kind": "if",
  "cond": { "kind": "get", "path": "system.lastError" },
  "then": {
    "kind": "patch",
    "op": "set",
    "path": "ui.errorModal",
    "value": {
      "kind": "merge",
      "objects": [
        { "kind": "lit", "value": { "visible": true } },
        {
          "kind": "lit",
          "value": {
            "title": { "kind": "get", "path": "system.lastError.code" },
            "message": { "kind": "get", "path": "system.lastError.message" }
          }
        }
      ]
    }
  }
}

Forbidden Patterns ​

FORBIDDEN: Throwing in Core ​

typescript
// NEVER DO THIS
function evaluateExpr(expr: ExprNode, snapshot: Snapshot): unknown {
  if (expr.kind === 'divide' && expr.right === 0) {
    throw new Error('Division by zero');  // ❌ WRONG
  }
}

Correct approach:

typescript
// DO THIS
function evaluateExpr(expr: ExprNode, snapshot: Snapshot): unknown {
  if (expr.kind === 'divide' && expr.right === 0) {
    return null;  // ✅ Errors are values, division by zero returns null
  }
}

Rule: Core MUST be total (always return a value, never throw).

FORBIDDEN: Boolean Success Flags ​

typescript
// NEVER DO THIS
type EffectResult = {
  success: boolean;
  data?: unknown;
  error?: string;
}

Why forbidden:

  • Forces consumers to check success flag
  • Ambiguous: What if both data and error are present?
  • Not serializable to Snapshot naturally

Correct approach:

typescript
// DO THIS
type EffectHandler = (
  type: string,
  params: Record<string, unknown>
) => Promise<Patch[]>;

// Handler returns patches directly
return [
  { op: 'set', path: 'result', value: result },
  { op: 'set', path: 'error', value: null }
];
// OR
return [
  { op: 'set', path: 'result', value: null },
  { op: 'set', path: 'error', value: errorMessage }
];

FORBIDDEN: Try/Catch for Business Logic ​

typescript
// NEVER DO THIS
try {
  const result = await api.call();
  return { ok: true, patches: [] };
} catch (error) {
  // Handle business error with exception ❌
  if (error.code === 'NOT_FOUND') {
    return { ok: false, error: 'Not found' };
  }
  throw error;  // Re-throw system error
}

Why forbidden:

  • Mixes system errors (actual exceptions) with business errors (expected failures)
  • Exception-based control flow is non-local
  • Breaks serializability

Correct approach:

typescript
// DO THIS
const response = await api.call();
if (response.status === 404) {
  return [
    { op: 'set', path: 'found', value: false },
    { op: 'set', path: 'notFoundReason', value: 'Resource does not exist' }
  ];
}
if (!response.ok) {
  return [
    { op: 'set', path: 'error', value: `HTTP ${response.status}` }
  ];
}
return [
  { op: 'set', path: 'result', value: await response.json() },
  { op: 'set', path: 'found', value: true }
];

FORBIDDEN: Swallowed Errors ​

typescript
// NEVER DO THIS
try {
  await someOperation();
} catch (error) {
  console.log('Error occurred, but continuing...');  // ❌ Silent failure
  return { ok: true, patches: [] };
}

Why forbidden:

  • Hides failures from Snapshot
  • Breaks accountability
  • Makes debugging impossible

Correct approach:

typescript
// DO THIS
async function handler(type, params, context) {
  try {
    await someOperation();
    return [
      { op: 'set', path: 'operationStatus', value: 'success' }
    ];
  } catch (error) {
    return [
      { op: 'set', path: 'operationStatus', value: 'error' },
      { op: 'set', path: 'errorMessage', value: error.message },
      { op: 'set', path: 'errorTimestamp', value: context.requirement.createdAt }
    ];
  }
}

Error Categories ​

1. Validation Errors ​

When: User input fails validation

Where: Flow-level (using fail node)

Example:

json
{
  "kind": "fail",
  "code": "INVALID_EMAIL",
  "message": { "kind": "lit", "value": "Email format is invalid" }
}

Handling: Display validation message to user, prevent submission

2. Effect Execution Errors ​

When: External operation fails (network, database, etc.)

Where: Effect handler (returns error patches)

Example:

typescript
return [
  { op: 'set', path: 'syncStatus', value: 'error' },
  { op: 'set', path: 'errorMessage', value: 'Network timeout' }
];

Handling: Retry logic, fallback behavior, user notification

3. System Errors ​

When: Unexpected runtime failures (out of memory, network unreachable, etc.)

Where: Host-level (catch-all)

Example:

typescript
try {
  const context = {
    runtime: { time: { timestamp: 0 }, random: { seed: "seed" } },
    external: {},
  };
  const result = await core.compute(schema, snapshot, intent, context);
  // ...
} catch (error) {
  // System error - should be rare
  console.error('System error:', error);
  // Apply system transition to snapshot
  snapshot = core.applySystemDelta(snapshot, {
    lastError: {
      code: 'SYSTEM_ERROR',
      message: error.message,
      source: { actionId: '', nodePath: '' },
      timestamp: context.runtime.time.timestamp
    },
    status: 'error'
  });
}

Handling: Crash recovery, user notification, retry with exponential backoff

4. Schema Validation Errors ​

When: Schema is invalid or malformed

Where: Schema validation (at domain setup)

Example:

typescript
const validation = core.validate(schema);
if (!validation.valid) {
  console.error('Schema errors:', validation.errors);
  // Cannot proceed with invalid schema
}

Handling: Developer error, must fix schema


Error Recovery Strategies ​

Strategy 1: Automatic Retry ​

typescript
async function processIntentWithRetry(
  core: ManifestoCore,
  schema: DomainSchema,
  snapshot: Snapshot,
  intent: Intent,
  context: Context,
  maxRetries = 3
): Promise<Snapshot> {
  let current = snapshot;
  let attempts = 0;

  while (attempts < maxRetries) {
    const result = await processIntent(core, schema, current, intent, context);

    if (result.system.status !== 'error') {
      return result;  // Success
    }

    // Check if error is retryable
    const error = result.system.lastError;
    if (error && isRetryable(error.code)) {
      attempts++;
      await sleep(Math.pow(2, attempts) * 1000);  // Exponential backoff
      current = core.apply(schema, result, [
        { op: 'set', path: 'retryAttempt', value: attempts }
      ]);
      current = core.applySystemDelta(current, {
        lastError: null,
        status: 'idle'
      });
    } else {
      return result;  // Non-retryable error
    }
  }

  return current;
}

function isRetryable(code: string): boolean {
  return ['NETWORK_TIMEOUT', 'RATE_LIMIT', 'TEMPORARY_FAILURE'].includes(code);
}

Strategy 2: Graceful Degradation ​

typescript
async function fetchWithFallback(params: unknown): Promise<Patch[]> {
  try {
    const result = await api.fetchPrimary(params);
    return [
      { op: 'set', path: 'result', value: result },
      { op: 'set', path: 'source', value: 'primary' }
    ];
  } catch (primaryError) {
    try {
      const result = await api.fetchFallback(params);
      return [
        { op: 'set', path: 'result', value: result },
        { op: 'set', path: 'source', value: 'fallback' },
        { op: 'set', path: 'fallbackReason', value: primaryError.message }
      ];
    } catch (fallbackError) {
      return [
        { op: 'set', path: 'result', value: null },
        { op: 'set', path: 'error', value: 'All sources failed' }
      ];
    }
  }
}

Strategy 3: Error Accumulation ​

typescript
// Collect multiple errors without stopping
type BatchResult = {
  succeeded: number;
  failed: number;
  errors: ErrorValue[];
};

async function batchProcess(items: unknown[], context: EffectContext): Promise<Patch[]> {
  const results: BatchResult = {
    succeeded: 0,
    failed: 0,
    errors: []
  };

  for (const item of items) {
    try {
      await processItem(item);
      results.succeeded++;
    } catch (error) {
      results.failed++;
      results.errors.push({
        code: 'ITEM_FAILED',
        message: error.message,
        context: { item },
        source: { actionId: 'batchProcess', nodePath: '' },
        timestamp: context.requirement.createdAt
      });
    }
  }

  return [
    { op: 'set', path: 'batchResult', value: results }
  ];
}

Testing Error Scenarios ​

Testing Flow Errors ​

typescript
test('fails when title is empty', () => {
  const schema = buildSchema();
  const context = {
    runtime: { time: { timestamp: 0 }, random: { seed: "seed" } },
    external: {},
  };
  const snapshot = createSnapshot({}, schema.hash, context);
  const intent = {
    type: 'addTodo',
    input: { title: '' },
    intentId: 'i_1'
  };

  const result = await core.compute(schema, snapshot, intent, context);
  const patched = core.apply(schema, snapshot, result.patches);
  const namespaced = core.applyNamespaceDeltas(patched, result.namespaceDelta ?? []);
  const next = core.applySystemDelta(namespaced, result.systemDelta);

  expect(result.status).toBe('error');
  expect(next.system.lastError).toMatchObject({
    code: 'EMPTY_TITLE'
  });
});

Testing Effect Errors ​

typescript
test('handles network failure gracefully', async () => {
  const schema = buildSchema();
  const context = {
    runtime: { time: { timestamp: 0 }, random: { seed: "seed" } },
    external: {},
  };
  let snapshot = createSnapshot({}, schema.hash, context);

  // Register failing effect handler
  host.registerEffect('api:fetch', async () => {
    return [
      { op: 'set', path: 'fetchError', value: 'Network timeout' }
    ];
  });

  snapshot = await host.execute(schema, snapshot, {
    type: 'fetchData',
    intentId: 'i_1'
  });

  expect(snapshot.state.fetchError).toBe('Network timeout');
});

Common Questions ​

Q: Should I use error codes or error types? ​

A: Use string error codes. They're serializable, human-readable, and easy to check.

Good codes:

  • VALIDATION_ERROR
  • NETWORK_TIMEOUT
  • NOT_FOUND
  • UNAUTHORIZED

Avoid:

  • Exception classes (not serializable)
  • Numeric codes (not self-documenting)
  • Generic codes like ERROR (too vague)

Q: How do I get stack traces for debugging? ​

A: Stack traces are environment-specific and break serializability.

Instead:

  • Use trace from ComputeResult (shows computation path)
  • Include source in ErrorValue (shows where error occurred)
  • Log errors with context at Host level if needed

Q: What if an effect handler throws unexpectedly? ​

A: Wrap all effect handler calls in try/catch at Host level:

typescript
async function executeEffect(req: Requirement): Promise<Patch[]> {
  const handler = effectHandlers[req.type];
  if (!handler) {
    snapshot = core.applySystemDelta(snapshot, {
      lastError: {
        code: 'UNKNOWN_EFFECT',
        message: `No handler for: ${req.type}`,
        source: { actionId: req.actionId, nodePath: '' },
        timestamp: req.createdAt
      },
      status: 'error'
    });
    return [];
  }

  try {
    return await handler(req.type, req.params, {
      snapshot,
      requirement: req,
    });
  } catch (error) {
    // Handler threw unexpectedly - convert to system transition
    snapshot = core.applySystemDelta(snapshot, {
      lastError: {
        code: 'EFFECT_HANDLER_ERROR',
        message: error.message,
        source: { actionId: req.actionId, nodePath: '' },
        timestamp: req.createdAt,
        context: { effectType: req.type }
      },
      status: 'error'
    });
    return [];
  }
}

Q: How do I distinguish between business errors and system errors? ​

A: Use error codes consistently:

Business errors (expected failures):

  • VALIDATION_ERROR
  • NOT_FOUND
  • ALREADY_EXISTS
  • INSUFFICIENT_PERMISSIONS

System errors (unexpected failures):

  • SYSTEM_ERROR
  • NETWORK_ERROR
  • DATABASE_ERROR
  • UNKNOWN_ERROR

Business errors should be handled in Flow logic. System errors typically trigger retries or escalation.



Summary ​

Core principle: Errors are values, not exceptions.

Key rules:

  1. Core never throws—errors are recorded in Snapshot
  2. Effect handlers return patches, not exceptions
  3. Errors are serializable and traceable
  4. Error handling is just reading Snapshot state

What you get:

  • Deterministic error behavior
  • Full error traceability
  • Serializable error state
  • Testable without mocks
  • Explicit error handling

The pattern:

typescript
// Core declares error
{ kind: 'fail', code: 'ERROR_CODE', message: '...' }

// Or effect handler returns error
return [
  { op: 'set', path: 'error', value: errorMessage }
];

// Flow checks for error
if (snapshot.state.error) {
  // Handle error
}

Errors are first-class values in the system, not exceptional cases.


End of Failure Model Documentation