Skip to main content

The problem

Your lead-scoring prompt returns this:
{
  "tier": "hot",
  "score": 25,
  "reason": "",
  "qualified": true
}
The JSON is valid and the fields have the right types. The values disagree: a hot lead should have a stronger score, a qualified lead should meet your threshold, and the decision needs a reason. If this reaches your CRM, the system looks correct while sales works the wrong queue.

The contract

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

export const leadSchema = z.object({
  tier: z.enum(["hot", "warm", "cold"]),
  score: z.number().min(0).max(100),
  reason: z.string(),
  qualified: z.boolean(),
});

export const leadContract = defineContract({
  name: "lead-scoring",
  schema: leadSchema,
  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}; lower the tier or raise score to at least 70`,
    },
    {
      name: "cold_requires_low_score",
      description: "Cold leads must have a score below 30",
      fields: ["tier", "score"],
      check: (lead) =>
        lead.tier !== "cold" || lead.score < 30
          || `tier is "cold" but score is ${lead.score}; raise the tier or lower score below 30`,
    },
    {
      name: "reason_required",
      description: "Every scoring decision needs a non-empty reason",
      fields: ["reason"],
      check: (lead) =>
        lead.reason.trim().length > 0 || "reason must explain the scoring decision",
    },
    {
      name: "qualified_requires_score",
      description: "Qualified leads must have a score above 50",
      fields: ["qualified", "score"],
      check: (lead) =>
        !lead.qualified || lead.score > 50
          || `qualified is true but score is ${lead.score}; qualified leads require score above 50`,
    },
  ],
});
Rules should cover the values that decide routing, priority, ownership, or side effects. Keep them synchronous and make the failure message useful enough for a retry.

Run the model

Call your provider inside accept(). Include attempt.instructions in the prompt and add attempt.repairs on every attempt.
const result = await leadContract.accept(async (attempt) => {
  const response = await callYourLLM({
    messages: [
      {
        role: "user",
        content: [
          "Score this lead as JSON.",
          attempt.instructions,
          `Lead: ${leadSummary}`,
        ].join("\n\n"),
      },
      ...attempt.repairs,
    ],
  });

  return response.text;
});
On a failing first attempt, Boundary sends the model the exact schema and rule violations. A second attempt can repair the output without your app accepting the bad first value.

Accept or reject

if (result.ok) {
  await crm.updateLead(leadId, result.data);
  return { status: "accepted", lead: result.data };
}

const lastAttempt = result.error.attempts.at(-1);

logger.warn("lead scoring rejected", {
  leadId,
  category: lastAttempt?.category,
  ruleIssues: lastAttempt?.ruleIssues,
  issues: lastAttempt?.issues,
});

await reviewQueue.add({
  leadId,
  reason: result.error.message,
  attempts: result.error.attempts,
});

return { status: "needs_review" };
Do not write rejected cleaned data to the CRM as a fallback. If the contract rejects, the workflow should fail closed, retry with more context, or route to review.

When to use this pattern

  • Lead scoring and qualification
  • CRM enrichment before ownership assignment
  • Sales routing and prioritization
  • Any classification that changes queues, SLAs, or customer state