CLAUDE LABJP
SCIENCE — Claude Science launches in beta, a workbench that unifies research tools and produces auditable artifactsMODEL — Fast mode for Claude Opus 4.7 retires on July 24; migrate to Opus 4.8 fast modeCODE — Claude Code v2.1.195 adds a toggle to disable mouse clicks in fullscreen modeCODE — Hyphenated hook matchers now match exactly instead of substring-matchingAGENT — Claude Science pairs a coordinating agent with specialists and a reviewer that checks citations and mathCLOUD — Claude is generally available in Microsoft Foundry on Azure with Azure-native accessSCIENCE — Claude Science launches in beta, a workbench that unifies research tools and produces auditable artifactsMODEL — Fast mode for Claude Opus 4.7 retires on July 24; migrate to Opus 4.8 fast modeCODE — Claude Code v2.1.195 adds a toggle to disable mouse clicks in fullscreen modeCODE — Hyphenated hook matchers now match exactly instead of substring-matchingAGENT — Claude Science pairs a coordinating agent with specialists and a reviewer that checks citations and mathCLOUD — Claude is generally available in Microsoft Foundry on Azure with Azure-native access
Articles/API & SDK
API & SDK/2026-07-01Advanced

When Claude API Document Extraction Is Confidently Wrong — Field Notes on Catching Silent Errors with Invariants

In structured extraction from invoices and contracts, the real danger isn't a crash — it's a value that's silently wrong while the schema validates and confidence reads high. Field notes on invariants, two-pass extraction, and tracking field-level error rates.

Claude API97Document ProcessingStructured ExtractionData ValidationTypeScript19Production22Cost Optimization8

Premium Article

Let me start with the moment that scared me most while running an invoice pipeline. Validation passed. The Zod schema parsed cleanly, and the model reported a confidence of 0.95. Yet total was off by an order of magnitude. No crash, no exception. I only caught it at aggregation time, when one vendor's monthly total looked suspiciously small, and traced it back from there.

The real enemy in structured extraction isn't the failure that stops. A failure that stops lands in your logs and gets picked up on retry. What's dangerous is the extraction where everything is formally correct and only the value is quietly wrong. Here are the design choices that worked when I ran document extraction on Claude API in production — with working code for catching the silent errors.

Why schema validation isn't enough

Schema validation only guarantees shape. It confirms that total is a number and issueDate is a string, but it says nothing about whether those values match the document.

Silent errors tend to collapse into three patterns. First, misread digits and commas: reading 1,250,000 as 1250.00, or letting a ¥ sign and a decimal point shift the magnitude. Second, swapped fields: putting dueDate into the issueDate slot, or picking up subtotal and total in reverse. Third, "plausible completion": filling in a tax rate that the document never stated with a generic value.

All three come back with high confidence. A model's self-reported confidence reflects "how sure I am about my own reading," not "agreement with the truth." Conflate the two and you get the worst kind of incident: the highest-confidence field is the most dangerous one.

Invariants — make the document check its own arithmetic

Even with no external ground truth, a document carries relationships you can use to check itself. On an invoice, the line items sum to the subtotal, and the subtotal plus tax equals the total. For dates, the issue date precedes the due date. Encode these as invariants and verify them mechanically right after extraction.

Zod's superRefine lets you fold type checks and invariant checks into a single schema.

// src/schema/invoice.ts
import { z } from "zod";
 
const Money = z.number().finite().nonnegative();
 
const LineItem = z.object({
  description: z.string().min(1),
  quantity: z.number().positive().optional(),
  unitPrice: Money.optional(),
  amount: Money,
});
 
// Tolerance: allow rounding drift up to 1 currency unit
const EPS = 1;
 
export const InvoiceSchema = z
  .object({
    invoiceNumber: z.string().optional(),
    issueDate: z.string().optional(),   // expect ISO 8601
    dueDate: z.string().optional(),
    currency: z.string().default("JPY"),
    lineItems: z.array(LineItem).min(1),
    subtotal: Money.optional(),
    tax: Money.optional(),
    total: Money,
  })
  .superRefine((inv, ctx) => {
    // Invariant 1: sum(lineItems) ~= subtotal
    if (inv.subtotal !== undefined) {
      const sum = inv.lineItems.reduce((s, li) => s + li.amount, 0);
      if (Math.abs(sum - inv.subtotal) > EPS) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ["subtotal"],
          message: `lineItems sum (${sum}) != subtotal (${inv.subtotal})`,
        });
      }
    }
 
    // Invariant 2: subtotal + tax ~= total
    if (inv.subtotal !== undefined && inv.tax !== undefined) {
      const expected = inv.subtotal + inv.tax;
      if (Math.abs(expected - inv.total) > EPS) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ["total"],
          message: `subtotal+tax (${expected}) != total (${inv.total})`,
        });
      }
    }
 
    // Invariant 3: issueDate <= dueDate
    if (inv.issueDate && inv.dueDate) {
      if (new Date(inv.issueDate) > new Date(inv.dueDate)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ["dueDate"],
          message: "issueDate is later than dueDate",
        });
      }
    }
 
    // Invariant 4: each line's quantity * unitPrice ~= amount
    inv.lineItems.forEach((li, i) => {
      if (li.quantity !== undefined && li.unitPrice !== undefined) {
        const expected = li.quantity * li.unitPrice;
        if (Math.abs(expected - li.amount) > EPS) {
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            path: ["lineItems", i, "amount"],
            message: `quantity*unitPrice (${expected}) != amount (${li.amount})`,
          });
        }
      }
    });
  });
 
export type Invoice = z.infer<typeof InvoiceSchema>;

The key is not to swallow invariant violations in an exception, but to keep a structured record of which field (path) contradicted what. Keep the path so you can feed it straight into downstream routing and re-extraction. Take results with safeParse and carry error.issues forward.

// src/validate.ts
import { InvoiceSchema, type Invoice } from "./schema/invoice";
 
export type FieldFault = { path: string; message: string };
 
export function validateInvoice(raw: unknown): {
  ok: boolean;
  data?: Invoice;
  faults: FieldFault[];
} {
  const result = InvoiceSchema.safeParse(raw);
  if (result.success) return { ok: true, data: result.data, faults: [] };
 
  const faults: FieldFault[] = result.error.issues.map((iss) => ({
    path: iss.path.join("."),
    message: iss.message,
  }));
  return { ok: false, faults };
}

At this point, most misread digits and swapped fields surface as "the shape is fine but the arithmetic doesn't add up." If total is off by an order of magnitude, the subtotal-plus-tax check will almost always catch it.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
The patterns where a schema-valid extraction goes quietly wrong, and how to make the document check its own arithmetic with invariants
A two-pass design: extract everything with a cheap model, then re-extract only the fields that failed invariants with a stronger model
Why you should track field-level error rate instead of document-level success, and how to route work to human review
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

API & SDK2026-06-20
Putting Cloudflare AI Gateway in Front of Claude Made the Numbers I Needed Disappear — Field Notes on Instrumentation
After putting Cloudflare AI Gateway in front of Claude API, here is where I actually got stung — cost attribution, semantic-cache false hits, fallback quietly lowering quality, and budgets that don't really stop anything — with the code I used to fix each.
API & SDK2026-05-04
Claude API on Bun in Production: Migration Decisions and Implementation Patterns That Actually Survive Real Traffic
A practical guide to running Claude API services on Bun in production. Covers migration triggers from Node.js, built-in SQLite/WebSocket usage, streaming optimization, and the pitfalls that only surface after deployment — with working code and measured numbers.
API & SDK2026-04-25
Claude API × Convex: Reactive AI Apps — Data Flow, Streaming, and Agent Patterns
How to combine Convex's reactive database with the Claude API to build chat and agent applications that hold up in production. Covers schema design, the Action/Mutation/Query boundary, streaming, tool-call state, and the cold-start pitfalls nobody warns you about.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →