Skip to main content
Schemas say what data looks like. Rules say whether it can be trusted. A rule is a named check with a description, a function that returns true or a failure message, and an optional list of fields it touches. Rules run after schema validation; if any rule fails, Boundary turns the failure into a repair message and asks the model to try again.

The Rule type

interface Rule<T> {
  name: string;                         // stable machine key, unique per contract
  description?: string;                 // positive statement of the invariant
  check: (data: T) => boolean | string; // return true to pass, false/string to fail
  message?: string;                     // fallback failure text when check returns false
  fields?: string[];                    // fields this rule reads (auto-inferred when omitted)
}
FieldRequiredNotes
nameyesStable machine key. ≤128 chars, unique within the contract. Joins to per-rule failure counts on the dashboard, so don’t rename it casually. Use snake_case.
descriptionnoHuman-readable invariant in the positive (“Hot leads must have a score ≥ 70”), ≤1000 chars. Shown in dashboards, diffs, and code review.
checkyesSynchronous, deterministic, cheap. Returns true to pass, false to fail with message, or a string to fail with that string as the failure message (overrides message).
messagenoStatic fallback when check returns plain false. If check returns a string, that string wins.
fieldsnoField names this rule reads. ≤64 entries, each ≤128 chars. Auto-inferred from the check function source when omitted — supply explicitly if you minify or your check delegates to a helper.

A canonical rule

import { defineContract } from "@withboundary/contract";

defineContract({
  name: "lead-scoring",
  schema,
  rules: [
    {
      name: "hot_requires_high_score",
      description: "Hot leads must have a score of at least 70",
      fields: ["tier", "score"],
      check: (lead) =>
        lead.tier !== "hot" || lead.score >= 70
          || `tier is "hot" but score is ${lead.score} (minimum 70 for hot)`,
    },
  ],
});
A few things this snippet bakes in:
  • The name is snake_case and reads like a machine key. The dashboard joins on it.
  • The description is a positive statement of the invariant, not the error text.
  • The check short-circuits the not-applicable case (lead.tier !== "hot"), then asserts the invariant (lead.score >= 70). When neither holds, it returns a dynamic failure string — context like the actual score and the threshold makes the model’s repair faster.
  • fields identifies the output fields the rule reads. Boundary can infer simple cases, but explicit fields make docs, logs, and dashboard filters easier to read.

What check may return

Return valueOutcome
trueRule passes for this attempt.
falseRule fails; uses rule.message as the failure text (or “rule <name> failed” if message is also unset).
stringRule fails; the returned string becomes the failure text and is sent to the model verbatim.
Returning a tailored string is almost always better than returning false with a static message — the closer the failure text is to “here’s what’s wrong, here’s what to do,” the better the repair on the next attempt.

Sync, deterministic, cheap

check is not async. Rules run inline during validation. If you need an async check (database lookup, external API), do it before or after the contract — never inside.
Same input, same output. No randomness, no clocks, no external state. Determinism is what makes rules unit-testable and what lets the dashboard report stable per-rule failure counts.
Rules run on every attempt — up to maxAttempts times per contract call. Keep them in-memory. No DB queries, no fetch, no heavy parsing.

Domain examples

Lead scoring

rules: [
  {
    name: "hot_requires_high_score",
    description: "Hot leads must have a score of at least 70",
    fields: ["tier", "score"],
    check: (lead) =>
      lead.tier !== "hot" || lead.score >= 70
        || `tier is "hot" but score is ${lead.score} (minimum 70 for hot)`,
  },
  {
    name: "reason_required",
    description: "Every scoring decision must have a non-empty reason",
    fields: ["reason"],
    check: (lead) => lead.reason.trim().length > 0 || "reason cannot be empty",
  },
]

Finance

rules: [
  {
    name: "invoice_math_consistent",
    description: "Subtotal plus tax must equal total within a cent",
    fields: ["subtotal", "tax", "total"],
    check: (invoice) =>
      Math.abs(invoice.subtotal + invoice.tax - invoice.total) < 0.01
        || `subtotal + tax != total (${invoice.subtotal} + ${invoice.tax} = ${invoice.subtotal + invoice.tax}, got ${invoice.total})`,
  },
  {
    name: "currency_matches_account",
    description: "Invoice currency must match the account currency",
    fields: ["currency", "accountCurrency"],
    check: (invoice) =>
      invoice.currency === invoice.accountCurrency
        || `currency mismatch: got ${invoice.currency}, expected ${invoice.accountCurrency}`,
  },
]

Support ops

rules: [
  {
    name: "high_priority_requires_reason",
    description: "High-priority tickets must include an escalation reason",
    fields: ["priority", "reason"],
    check: (ticket) =>
      ticket.priority !== "high" || ticket.reason.trim().length > 0
        || "high priority requires escalation reason",
  },
]

Agents

rules: [
  {
    name: "cant_close_in_review",
    description: "Cannot close a ticket while it's in review",
    fields: ["action", "status"],
    check: (action) =>
      !(action.action === "close_ticket" && action.status === "needs_review")
        || "cannot close a ticket with status needs_review",
  },
]

Compliance

rules: [
  {
    name: "us_requires_ssn",
    description: "US users must provide an SSN",
    fields: ["country", "ssn"],
    check: (account) =>
      account.country !== "US" || account.ssn !== null
        || "US users require SSN",
  },
  {
    name: "approve_requires_identity",
    description: "Auto-approval requires verified identity",
    fields: ["approved", "identityVerified"],
    check: (account) =>
      !account.approved || account.identityVerified
        || "cannot auto-approve without verified identity",
  },
]

String messages drive repair

The string a failed rule returns becomes part of the repair prompt sent to the model. Specific, contextual messages produce specific, contextual repairs.
{
  name: "score_valid",
  check: (lead) => lead.score > 70 || "invalid",
}
The model sees: “invalid”. Not enough to fix anything.

Multiple rules

Rules are evaluated in order. All failing rules are collected — Boundary does not short-circuit on the first failure. Every violation goes back to the model in a single repair message.
rules: [
  {
    name: "hot_requires_high_score",
    fields: ["tier", "score"],
    check: (lead) => lead.tier !== "hot" || lead.score >= 70 || `score ${lead.score} too low for hot`,
  },
  {
    name: "reason_required",
    fields: ["reason"],
    check: (lead) => lead.reason.trim().length > 0 || "reason cannot be empty",
  },
  {
    name: "score_in_range",
    check: (lead) => (lead.score >= 0 && lead.score <= 100) || "score must be between 0 and 100",
  },
]
// If all three fail, the model gets all three failures at once.

Validation at construction time

defineContract validates rules when you build the contract — not lazily on the first run. You catch typos, duplicate names, and over-long descriptions at startup, not in production:
  • name is required, ≤128 chars, unique within the contract.
  • description is ≤1000 chars when provided.
  • fields is ≤64 entries, each ≤128 chars when provided.
  • check must be a function.
Anything that fails these checks throws a TypeError at defineContract time. Test it once with pnpm tsc --noEmit or your test runner; it’ll never bite you in prod.

Next steps

The Repair Loop

How violations become targeted fixes

Results

What you get back from a contract call

Observability

Named rules are useful locally and in production. Use local development logging to see exact rule failures while building, then add production observability to track top failing rules across traffic.