docs/solutions/design-patterns/evidence-gate-llm-extracted-values-bypass-classes.md
WorldMonitor's consumer-price extractor (Firecrawl/Exa structured extraction over retailer pages) went through three failure generations in one week (#6182):
price nullable+required. Together they handed the extractor a sanctioned null escape hatch, and fleet-wide missing-price failures surged the very next scheduled run — the extractor nulled prices that were literally printed on the page (Carrefour BR prints R$ 7,90 above an out-of-stock notice; prod extracted null).consumer-prices-core/src/adapters/price-evidence.ts).The reusable pattern is the third design — plus the bypass classes an 8-persona + cross-model review found in the first draft of the prover itself.
1. Behavioral rules belong in the prompt; safety belongs in a deterministic gate. Let the extractor report what it sees ("a printed price wins"), and verify acceptance mechanically: the value's digits must appear in the rendered content captured by the same call (ExtractResult.pageContent). A fabricated value has no source digits and dies at the gate regardless of prompt wording.
2. Ship the evidence in the same provider call that produced the value. Firecrawl: request formats: ['extract', 'markdown'] in one render. Exa /contents: request text alongside the structured summary. Evidence fetched separately can come from a different render and prove nothing.
3. The prover is itself an attack surface — adversarially test it before trusting it. Three independent reviewers (two model families) found these bypass classes in a first-draft digit matcher, each reproduced live:
49 / .79 / AED) will stitch a price out of unrelated numbers — priceEvidenceOnPage(49.79, '49 in stock, rated 4.79 by 1200 users') verified. Guard both halves: the whole must not be the integer part of a different decimal ((?!\d|[.,]\d)), the fraction must not be lifted out of another number ((?<!\d)[.,]).(?!\d)) lets the page's own size token certify a quantity-as-price fabrication — 455 "verified" by 455g, 1.5 by 1.5L, 4.6 by save 4.6%. Reject unit/percent-suffixed matches explicitly.1,234 / 1.234) crossed with both decimal separators accepts non-numbers like 1.234.56. Pair comma-grouped wholes only with dot decimals and vice versa.4. The abstain path must be observable, never silent. When the provider returns no rendered content the gate cannot run. Passing through is a defensible compatibility choice, but if 'no-content' is byte-identical to 'verified' downstream, a provider quietly dropping content from its responses reverts the whole fleet to unguarded acceptance while every dashboard stays green. Log the abstention per occurrence and persist the verdict (priceEvidence: 'verified' | 'no-content') in the stored payload so an audit — or a later health rule — can see the gate's live coverage.
5. Presence is not attribution — keep the other gates. The evidence check proves the digits exist somewhere in the content; a carousel price or "was" price also passes. Title plausibility, currency, and size/validator checks carry attribution. The evidence gate's single job is making values-with-no-source impossible.
Prompt-only anti-fabrication is a bistable failure: tighten the wording and the model nulls real values (a coverage collapse that looks like source rot); loosen it and fabrications flow into the dataset (worse than missing data in a price index). A deterministic evidence gate breaks the bistability — the prompt can be permissive because acceptance requires proof. But a naive prover silently converts "proof" into "coincidence": on digit-rich commerce pages (counts, ratings, sizes, carousels), an unguarded matcher verifies almost any plausible fabrication. The bypass classes above came from a reviewed, tested first draft — they are the default state of a digit matcher, not an exotic edge.
Rejected (fabrication-shaped) vs accepted, from consumer-prices-core/src/adapters/price-evidence.test.ts:
// Digit-stealing: fabricated 49.79 must not verify from count + rating furniture
expect(priceEvidenceOnPage(49.79, '49 in stock, rated 4.79 by 1200 users')).toBe('unverified');
// Unit collision: the page's size token is not price evidence
expect(priceEvidenceOnPage(455, 'Tesco Bread 455g loaf')).toBe('unverified');
// Locale-inconsistent separators are not numbers
expect(priceEvidenceOnPage(1234.56, 'ref 1.234.56 item')).toBe('unverified');
// Legitimate split render (whole and fraction in separate DOM nodes) still passes
expect(priceEvidenceOnPage(49.79, 'Jumbo Pack 68 Diapers\n\n49\n\n.79\n\nAED')).toBe('verified');
// OOS page with a printed price is a real observation (price + inStock=false)
expect(priceEvidenceOnPage(7.9, 'Leite Integral 1 Litro\n\nR$ 7,90\n\nOps! sem estoque')).toBe('verified');
Observable abstention (consumer-prices-core/src/adapters/search.ts): the gate's 'no-content' outcome warn-logs [search:price-evidence] … evidence gate skipped and stamps priceEvidence into the persisted rawPayload, so verified and unchecked acceptances are distinguishable forever after.