Skip to main content
When an LLM output fails validation, Boundary doesn’t just retry blindly. It extracts the specific violations, classifies the failure, and sends targeted repair instructions back to the model.

How it works

Attempt 1 → LLM returns output
           → Boundary validates (schema + rules)
           → Fails: "tier is hot but score is 25"
           → Failure classified as RULE_ERROR
           → Repair message generated from violation

Attempt 2 → LLM receives original prompt + repair context
           → Returns corrected output
           → Boundary validates again
           → All rules pass → ACCEPTED
This is not blind retry. Each repair message is targeted to the specific failure. The model knows exactly what went wrong and what to fix.

The attempt.repairs field

Inside your RunFn, attempt.repairs is an array of plain { role, content } JSON objects. Most chat-style providers accept this shape directly or with a small mapping step.
type Message = { role: string; content: string }
type RepairList = Message[]
On a retry, attempt.repairs looks like this:
[
  { "role": "user", "content": "tier is \"hot\" but score is 25 (minimum 70 for hot)" }
]
Repair messages are always role: "user" — they carry the failure text from your rule’s check (or the schema/parse error) so the model sees exactly what to fix on the next attempt.
const result = await contract.accept(async (attempt) => {
  // attempt.repairs is empty on first try
  // on retries, it contains targeted fix instructions
  const response = await callYourLLM({
    messages: [
      {
        role: "user",
        content: [
          "Score this lead as JSON.",
          attempt.instructions,
          leadSummary,
        ].join("\n\n"),
      },
      ...attempt.repairs,  // violations from prior failure
    ],
  });

  return response.text;
});
On the first attempt, repairs is empty. On subsequent attempts, it contains the failure messages from the previous attempt — one entry per rule or schema violation that fired.

Retry configuration

By default, Boundary retries up to 3 attempts with no delay between them.
const contract = defineContract({
  schema,
  rules,
  retry: {
    maxAttempts: 3,              // default: 3
    backoff: "none",             // "none" | "linear" | "exponential"
    baseMs: 200,                 // delay base in milliseconds
  },
});
StrategyDelay pattern
"none"No delay between retries (default)
"linear"baseMs * attemptNumber
"exponential"baseMs * 2^attemptNumber

Failure categories

Every failed attempt is classified into one of 8 categories. Each category has a default repair strategy:
CategoryMeaningDefault repair
EMPTY_RESPONSEModel returned nothingRe-prompt with schema instructions
REFUSALModel refused the taskRe-prompt emphasizing the task is valid
NO_JSONResponse contained no JSONAsk for JSON output specifically
TRUNCATEDJSON was cut offAsk for complete, shorter response
PARSE_ERRORMalformed JSONSend the parse error details
VALIDATION_ERRORValid JSON, failed schemaSend schema violations
RULE_ERRORPassed schema, failed rulesSend rule violations (your domain logic)
RUN_ERRORYour RunFn threw an errorNo retry by default

Custom repair overrides

You can override or disable repair for specific categories:
const contract = defineContract({
  schema,
  rules,
  repairs: {
    // Don't retry on refusal — the model won't change its mind
    REFUSAL: false,

    // Custom repair for validation errors
    VALIDATION_ERROR: (detail) => [
      {
        role: "user",
        content: `Your output didn't match the schema. Issues: ${detail.issues.join(", ")}. Please fix.`,
      },
    ],
  },
});
Setting a category to false stops retrying for that failure type. Providing a function lets you craft custom repair messages.

When repair fails

If maxAttempts is exhausted without a valid output, the contract returns { ok: false, error }. The full per-attempt history is on error.attempts — including the failure category, the raw model output, and the typed ruleIssues on RULE_ERROR attempts.
if (!result.ok) {
  console.log(`Failed after ${result.error.attempts.length} attempts`);
  for (const [i, attempt] of result.error.attempts.entries()) {
    console.log(`  attempt ${i + 1} [${attempt.category}]:`);
    for (const ruleIssue of attempt.ruleIssues ?? []) {
      // Per-rule attribution — name maps to the dashboard's rule_key.
      console.log(`    ${ruleIssue.rule.name}: ${ruleIssue.message}`);
    }
  }
}
Rejected data stays out of the success path. The contract never returns a partial or “best-effort” success value — either every rule passes or you get the structured error. If a particular contract is hitting maxAttempts regularly, that’s a signal: the rule may be too strict, the prompt may be missing context, or the model may be wrong for the job. Local logs can show this while you build; the hosted SDK can aggregate it across production traffic. See Results for handling patterns.

Next steps

Results

The ContractResult type

Guarantees

What Boundary promises

Observability

Start with local development logging to see repairs and failed attempts in your console. Add production observability when you need aggregate retry and rule-failure trends across real traffic.