Skip to main content
Every contract call returns a ContractResult<T> — a discriminated union on the ok field. Boundary never throws. You always get a structured result.

Success: result.ok === true

if (result.ok) {
  result.data       // T — typed, validated, all rules passed
  result.attempts   // number — total attempts made (minimum 1)
  result.raw        // string — the raw LLM output that was accepted
  result.durationMS // number — total wall-clock time including retries
}
When ok is true, data is fully typed from your Zod schema. It has passed schema validation and every rule. Safe to use directly in your application.

Failure: result.ok === false

if (!result.ok) {
  result.error.message   // string — human-readable summary
  result.error.attempts  // AttemptDetail[] — one per failed attempt
}
When ok is false, no data is returned to your application. The error object contains the full history of every attempt.

AttemptDetail

Each failed attempt records:
type AttemptDetail = {
  raw: string                   // the raw LLM output
  cleaned: unknown              // the parsed/cleaned output
  issues: string[]              // human-readable violations (schema or rule)
  ruleIssues?: RuleIssue[]      // structured per-rule failures, only set on RULE_ERROR
  category: FailureCategory     // what type of failure
}

type RuleIssue = {
  rule: { name: string; fields?: string[] }
  message: string               // the failure string from rule.check (or rule.message)
}
ruleIssues is the typed companion to issues — same content, but with the rule’s name and fields attached so you can route, count, or trigger logic per-rule on the receiving side.

Pattern matching

Basic

const result = await contract.accept(run);

if (result.ok) {
  await saveToDatabase(result.data);
} else {
  console.error("Contract failed:", result.error.message);
}

Early return

const result = await contract.accept(run);
if (!result.ok) {
  return res.status(422).json({ error: result.error.message });
}
// result.data is typed T from here
await processLead(result.data);

Logging attempts

if (!result.ok) {
  for (const attempt of result.error.attempts) {
    console.log(`Attempt failed [${attempt.category}]:`, attempt.issues);
  }
}

Handling permanent failure

When maxAttempts is exhausted without an accepted output, you decide what happens next. The full attempt history is on result.error.attempts — use it to choose between retry-with-different-prompt, escalate-to-human, or fail-fast.
const result = await leadContract.accept(scoreLead);

if (result.ok) {
  await crm.updateLead(leadId, result.data);
  return;
}

// Pick the failure category from the last attempt — it tells you where to go.
const last = result.error.attempts.at(-1)!;

switch (last.category) {
  case "REFUSAL":
    // The model won't do this no matter how many times you ask.
    // Send to a human reviewer.
    await reviewQueue.add({ leadId, reason: "model refusal", attempts: result.error.attempts });
    break;

  case "RULE_ERROR":
    // The model produced structurally valid output but kept violating
    // your domain rules. Tag it for prompt iteration.
    for (const issue of last.ruleIssues ?? []) {
      metrics.increment("contract.rule_failure", { rule: issue.rule.name });
    }
    await reviewQueue.add({ leadId, reason: "rule violations exhausted", issues: last.issues });
    break;

  case "PARSE_ERROR":
  case "TRUNCATED":
  case "NO_JSON":
    // Format issues — usually a token-budget or prompt problem. Retry once
    // with a smaller schema or a higher max_tokens setting, otherwise drop.
    metrics.increment("contract.format_failure", { category: last.category });
    return res.status(502).json({ error: "model output unparseable" });

  case "RUN_ERROR":
    // Your RunFn threw — your provider call is broken, not the model.
    throw new Error(result.error.message);

  default:
    return res.status(422).json({ error: result.error.message });
}
The structured per-rule data on ruleIssues makes per-rule alerting cheap. Pair it with a metrics sink and you get a “which rule is degrading?” view with no extra plumbing.

Failure categories

Every failed attempt is classified:
CategoryMeaning
EMPTY_RESPONSEModel returned nothing
REFUSALModel refused the task
NO_JSONResponse contained no JSON
TRUNCATEDJSON was cut off or incomplete
PARSE_ERRORMalformed JSON
VALIDATION_ERRORValid JSON but failed Zod schema
RULE_ERRORPassed schema but failed your rules
RUN_ERRORYour RunFn threw an error
The category tells you where the failure happened in the pipeline. VALIDATION_ERROR means the structure was wrong. RULE_ERROR means the structure was right but the values were wrong — this is the gap Boundary exists to close.

Why not exceptions?

ContractResult<T> is the error handling path. No try/catch needed. If you want to throw:
const result = await contract.accept(run);
if (!result.ok) throw new Error(result.error.message);
But the result type gives you more: the full attempt history, failure categories, and specific violations. Exceptions throw that away.

Next steps

Guarantees

What Boundary promises when result.ok is true

When to Use

Where Boundary fits — and where it doesn’t

Observability

Use local development logging to inspect individual rejected results while building. Use production observability when you need acceptance rates, top failing rules, and recent rejected runs in the dashboard.