What this is
Most claims should never need a human at all. I built ClaimsDock around one thesis: a router that decides what a plain-language question actually needs, grounding so every recommendation traces to a real policy line rather than an invented one, honest uncertainty tagged with a reason instead of a bare confidence score, and a human gate so nothing becomes a real action without explicit approval. I applied it here to medical claims specifically — a domain that sits at the intersection of health, insurance, and finance — but the same skeleton could re-skin for other operational review work.
I wrote the functional spec (project-spec.txt) and revised it before writing any code — architecture, taxonomies, and rules first, interface second. That document stayed the domain authority the whole build; this page tracks what actually got built against it.
How it's built
The visual design didn't exist when the backend work started — I ran the two in parallel, verifying logic against a plain placeholder UI, then wired the real Figma design in once it landed rather than waiting on it to begin anything.
A real Figma design — three visual styles × light/dark, six combinations total — wired into working React across the full worklist, Claims Card, and Anchor panel.
Next.js API routes: a chunked, batched evaluation pipeline, a tool-use router, and a retrieval layer for grounded answers.
Claude and Kimi K2.6, tested head-to-head and routed by task — Kimi runs the live Pipeline in production; both are available to Anchor via a Settings toggle.
Live on Vercel, with a daily cron pre-warming the weekly Pipeline cache so the first real visitor isn't the one who pays for a cold run.
How I approached this build
-
Spec first, code second
I resolved every architectural question in the spec document before writing a single file — the two-call pipeline, the retrieval mechanism, the persistence split. The clearest example: collapsing the Evaluation Pipeline to two batched calls total came from deciding, on paper, that severity, status, and the recommended action were calculations rather than judgment calls, before any pipeline code existed (Phase 1).
-
Real, not simulated
Real chunked retrieval over a purpose-written corpus, real jsdom-backed persistence tests, a real deployed environment surfacing real bugs no shortcut would have caught. The retrieval layer's own live smoke test is the clearest case — testing against genuine questions, not fixtures, is what actually found the ranking problems worth fixing (Phase 4).
-
Every fix checked against what already passed
The Deny-guardrail regression suite exists because a fix silently broke a previously-passing case — one round's own correction broke a claim that had passed cleanly earlier the same session. After that, nothing shipped without checking it against the cases that already worked (Phase 13).
The 14 phases, condensed
The full order lives in CLAUDE.md. Each phase below: what it built, the most interesting things that happened in it, and a link to that phase's complete, unabridged notes further down this same page.
-
Phase 1 — Claims schema & seed generator
DoneI read the functional spec end to end before writing any code, then built the claim schema and a 20-claim authored test set against it.
- Two calls, not one or three: I moved severity, status, and the recommended-action mapping into deterministic code — since the spec already called them calculations, not judgment calls — which is what let the whole pipeline collapse to two batched API calls per run.
- A fraud pattern needed its own dataset: "3x this provider's average" isn't something one claim can carry alone, so I built a small provider-history table just to give the volume-spike signal something real to compare against.
- Only submission dates are generated: everything else is hand-authored for plausibility; a seeded generator keyed to the ISO week produces a varied-but-deterministic spread of claim ages.
- The validator is scenario-aware: it exempts each claim's own deliberately-missing field from being flagged, without silencing real checks anywhere else.
- I left two real unknowns open on purpose: the embedding provider and the batching strategy past 20 claims both got flagged rather than guessed at — resolved later, in Phases 4 and 13.
-
Phase 2 — Author the three RAG corpora
DoneThree condensed reference documents for the retrieval layer to draw on: coverage policy, fraud indicators, and regulatory deadlines.
- I fact-checked a secondhand claim before building on it: a companion thread said five fraud-category names came from a specific FinCEN advisory. I fetched the real advisory directly rather than trust the relay — the volume-spike pattern held up, the category-name attribution didn't, and I corrected it everywhere it had already propagated.
- Real sourcing over convenient sourcing: the corrected terms are still real, just grounded in OIG enforcement, DOJ settlements, and CMS's own coding-integrity program instead.
- ERISA's deadlines held up, plus one real addition: checked directly against DOL/EBSA guidance — accurate as specced, with a notified-extension provision worth folding in that the original draft had missed.
-
Phase 3 — Deterministic calculation layer
DoneFive pure functions, no API calls: severity, SLA math, coverage math, the action-lookup table, and status — the load-bearing decision from Phase 1 turned into real, tested code.
- A judgment question I answered by scoping it correctly: "disputed medical necessity" isn't something to extract from prose today or hand-code as a boolean — it's a real model judgment Phase 5 has to make, so I built the function to accept it as a parameter rather than pre-baking an answer.
- Network status and deductible balance live in separate systems on purpose: looking them up rather than parsing them off the claim form is the domain-accurate design, matching how real claims processing actually works.
- A generated document instead of a hand-maintained one: the coverage-policy corpus doc now renders from the same constants the calculation itself uses, so the numbers Anchor cites can't quietly drift from the numbers actually computed.
- Caught while reviewing this layer against the spec: the status taxonomy's original rule ("derived from whether evidence is non-empty") was actually wrong, not just ambiguous — fixed to a category-based rule, and two genuine missing statuses (Denied, Additional Info Requested) got added after passing a real non-redundancy test.
-
Phase 4 — RAG retrieval layer
DoneLocal, self-hosted embeddings over a hosted provider, closing the one open decision left from Phase 1 — then a real smoke test against genuine questions, not fixtures, is what actually proved retrieval worked.
- The chunk built to answer a question ranked 5th of 6 for that exact question: raw markdown table syntax embeds poorly, since sentence-embedding models are trained overwhelmingly on prose, not table markup.
- Flattening the table barely moved the needle: the real cause was mean-pooling diluting a longer chunk's vector toward its average content — a genuine, well-documented limitation of pure embedding search, not a bug in this corpus.
- I added hybrid scoring (70% embedding, 30% keyword overlap) to recover it: the standard industry answer to exactly this weakness, and it worked — the ERISA chunk went from absent in the top 6 to 2nd place.
- I decided imperfect ranking was an acceptable risk, not a blocker: Anchor feeds several top-k chunks to the model for real synthesis, not just the top-ranked one, so a small ranking margin doesn't produce a wrong answer.
-
Phase 5 — Evaluation Pipeline
DoneThe two batched calls specced back in Phase 1, for real: Call 1 gathers evidence and a proposed category; Call 2, shown only that evidence, judges confidence and drafts the recommendation independently.
- A real reliability bug, not a hypothetical one: a batched call could come back missing claims from an otherwise schema-valid response, since structured output constrains shape but not count. Fixed by explicitly enumerating every expected ID and retrying on an incomplete batch.
- A deterministic safety net overrides the model, not the other way around: whether a required field is actually null is a confirmed fact, so a detected material gap overrides Call 1's category if the two disagree.
- 20/20 categories matched ground truth on the first full live run — including a claim the model itself correctly flagged as already past its SLA deadline.
-
Phase 6 — Interactive Router & Anchor
DoneReal tool-use across four tools — Lookup, Analysis, Recommendation, Reference Lookup — tested against an adversarial question set, not just the happy path.
- A real bug from the first live run: a question needing two tools in sequence had its second tool call silently dropped, since the round-trip only handled one dispatch round. Fixed as a bounded loop instead of assuming one call always suffices.
- Never a silent guess, never a blocking question: for a genuinely ambiguous query, I designed the Router to show its best-guess results now and name the interpretation plus an alternative in the same answer.
- Analysis and Recommendation reuse the already-computed Pipeline result by default — free and instant — only paying for a fresh model call on an explicit re-check request.
- Correctly declined an out-of-scope "deny this claim" request and named what it could actually do instead, rather than treating the request as if it had an execute path.
-
Phase 7 — Human gate & persistence
DoneTwo guardrails at the moment a human's action is submitted, both deliberately soft — neither one ever blocks a human from proceeding with their own choice.
- I designed both guardrails to be soft, not just permissive by omission: a system that could override a human's Approve or Deny would contradict the whole platform's own premise.
- A real coverage-math bug caught by cross-checking, not by review: CMS-1500's charge field is a per-unit rate needing multiplication by units; UB-04's is already a line total. Mixing them up understated a real claim by $420.
- The justification-quality check caught more than tone: live testing showed it rejecting a vague-but-serious-sounding excuse for being unverifiable, and once catching a stated reason that named the wrong fraud category against a claim's actual evidence — even though the chosen action itself was correct.
- I chose real jsdom-backed localStorage tests over a hand-rolled fake — a preference for testing the real thing wherever practical that held for the rest of this build.
-
Phase 8 — Placeholder UI
DoneA plain, unstyled worklist and card view, built to verify Phases 1–7's real logic end to end before any visual design existed to wire against. Superseded by the real UI once the Figma work landed, rather than kept running in parallel.
-
Phase 9 — Real UI
DoneFive design directions narrowed to three genuinely different styles — Ledger, Clinical, Field, each in light and dark — wired into working React, token-driven end to end.
- I checked contrast computationally instead of eyeballing it: caught three real WCAG AAA failures (one dropping as low as 4.77:1) and fixed them at the primitive-token level, so the fix propagated everywhere at once.
- A flicker bug traced to React's own commit timing: a fade hook was starting its exit sequence inside a useEffect, which only runs after commit — leaving one real blank paint on the exact render a modal closed. Fixed by deriving the transition synchronously during render instead.
- A popover clipped by an ancestor's overflow rule it never set explicitly — CSS resolves an unset
overflow-ytoautoonceoverflow-xis set, clipping the popover along with the scroll region. Fixed by portaling it to the document body. - I shipped the bulk-action UI intentionally ahead of its own backend: the checkbox bar and select-all state were built and visually complete well before Phase 13 gave them anything real to call.
-
Phase 10 — Workflow visualization
DoneA standalone, interactive D3 diagram of the entire system, built deliberately out of numeric order as an early architecture sanity check — then rewritten late in the build once it had drifted badly out of date.
- A real interaction bug, not a rendering one: a node needing two clicks to select turned out to be d3's pan-to-zoom and each node's own drag behavior reacting to the same mousedown — fixed by excluding node gestures from the canvas pan, not by tuning a threshold.
- The same fraud-category sourcing correction from Phase 2 applied here too — the diagram's own node copy over-attributed the category names before the real advisory was checked directly.
- I rewrote it node-by-node late in the build rather than patching it: by the time I revisited it, the diagram had no chunking architecture, no Kimi story, and a false "one-shot Router" claim — effectively all of Phases 8 through 13 were unrepresented, so I treated it as a full rewrite against the current codebase rather than a touch-up.
-
Phase 11 — Real API wiring & full prompt review
DoneFive passes: redacting a claim ID that spelled out its own fraud sub-type, wiring the Pipeline and Router to real routes, wiring Anchor's panel, and a full end-to-end review of every live prompt.
- The internal claim ID was leaking the answer: IDs like "FRD-UPCODE-01" told a model exactly what fraud type to find. Replaced with an opaque display number used consistently everywhere a model ever sees a claim.
- Prompt engineering beat parameter tuning, concretely: a batch completing reliably only half the time wasn't fixed by more tokens or higher reasoning effort (which made it worse) — reframing the task's own structure in the prompt is what actually worked, five clean runs out of five afterward.
- The same Turbopack path bug bit twice, in two different subsystems (the RAG corpus loader, then the Pipeline route) —
__dirnamedoesn't point where it used to once Turbopack relocates a compiled route, a real, repeatable gotcha now documented rather than rediscovered. - A live cache/pending duplication bug: an Anchor answer and its own citation card could show different data for the same claim, since the Pipeline's cache wasn't actually shared across Turbopack's separately bundled routes.
- I reworked Anchor's own recommend-action tool once I saw it risking a second, contradicting recommendation — now it explains and supports the one recommendation already computed, never recomputes a competing one.
-
Phase 12 — Kimi / provider flexibility
DoneReal, repeated head-to-head testing between Kimi and Claude on the identical claim set, ending in an actual routing decision for production — not a feasibility check.
- I decided raw accuracy was the wrong metric and designed a sharper one: a "silent miss" — a wrong "clean" call paired with a confident tier, which auto-resolves with nobody ever reviewing it. That distinction, not the accuracy number, is what I actually based the routing decision on.
- Kimi's own reasoning mode broke structured output outright — floods the response with near-blank whitespace and never completes the JSON object. Running with reasoning explicitly disabled is what works.
- A real, opposite tradeoff, not a win for either model: Claude averaged 92% accuracy with a 5% silent-miss rate but only finished cleanly 4 of 5 runs; Kimi averaged 82% with a 13% silent-miss rate but finished cleanly every single time.
- I made the cost-driven call explicitly, in writing: Kimi runs the live Pipeline in production for this public, no-real-stakes prototype — documented as a different call than an actual production system with real patients and dollars would be permitted to make.
- I caught a grading bug in my own comparison harness that had been silently under-scoring both providers for the entire testing history, and fixed it before trusting the final numbers.
-
Phase 13 — Anchor debugging, Human Gate, scale-up
DoneThe largest phase of this build by far: wiring both providers into Anchor, building the real four-field Deny form and its guardrail through eleven documented live rounds, chunked batching for the claim set's growth from 20 to 132, and a long tail of bugs only a real running app ever surfaced.
- Eleven rounds to get one guardrail field right — a wrong-document error, a fabricated citation, a genuine corpus gap, a retrieval-recall gap, two of my own rules contradicting each other on different claims — before the fix turned out to be removing the field from judgment entirely, since Deny only ever follows one fixed path with one fixed correct citation. Full story in Full Build Notes.
- A regression suite caught a real regression on its first run: built directly from the lesson that no fix had ever been checked against a case that already passed — and it immediately caught round seven's own fix silently breaking a claim that had passed earlier the same session.
- Chunking took three real attempts, not one: a naive claim-category balance made token variance worse before a continuous-cursor distribution (verified by a unit test before ever spending another live call) actually fixed it.
- A specialty/procedure-mismatch bug found by chance turned out to be a real pattern: a systematic audit found 7 linked claim pairs, not just the one, all pairing a provider with a surgical procedure outside their specialty.
- Five real bugs in one pass, none caught by typecheck, lint, or the unit suite — an empty-selection crash, a server-side localStorage reference, a stale queue-state bug, and two more — the clearest case in this build for why a real click-through isn't optional verification.
- A structural fix replaced a fourth prompt patch: rather than keep chasing non-determinism, a one-way "Auto-fill" bypass now lets the guardrail's own suggestion, once accepted, skip re-judgment entirely — matching the guardrail's own "never blocks a human" principle instead of fighting it.
-
Phase 14 — Vercel deployment
DoneMost of Phases 5–13 had never actually been committed to git — caught and pushed honestly as real, current work rather than staged as a fictional history. The deploy itself then surfaced three real bugs no local environment had ever shown.
- A typo cost an hour before I found it:
IMI_API_KEYinstead ofKIMI_API_KEYsurfaced as an OpenAI-SDK credential error, since Kimi's client silently falls back to checkingOPENAI_API_KEY. - A native binary two fixes deep:
onnxruntime's shared library wasn't reaching the deployed function — one config fix wasn't enough; an explicit file-tracing include for the exact routes that needed it was. - A read-only filesystem surfaced right after that: the embedding library's default cache directory lives inside
node_modules, which Vercel makes read-only at runtime — redirected to/tmp. - The loading screen's own cycling bug only ever showed up in production: a Suspense fallback held open for a genuine ~90-second cold run is exactly the case where its DOM node can get torn down and recreated mid-wait, silently resetting an in-memory timer. Rebuilt to derive state from real elapsed time instead.
- Pre-warming closed the last gap: a daily cron hits the Pipeline route so only a narrow weekly window can ever hit a cold run, not every visitor after one.
- A typo cost an hour before I found it:
Full build notes
Everything above, in full — every decision, every test, every failure, re-filed under the same 14 phases rather than left in strict chronological order. This is reference material: skim the condensed section above, come back here for the parts worth reading in depth.
Phase 1 — Claims schema & seed generator
Architecture & build-process planning
project-spec.txt · CLAUDE.md
Before any code: read the full functional spec end to end and worked through what it left open. Settled the evaluation pipeline down to two batched API calls total per run (not one call per claim, not three per claim) by moving severity, status, and the category-to-action mapping out of the model's hands entirely and into deterministic code — the spec was already explicit that those are calculations, not judgment calls, so the pipeline redesign just followed that logic all the way through. Confirmed the router as a real tool-use judgment step, not string-matching. Chose real chunked-embedding retrieval over full-context stuffing for the three RAG corpora specifically so the project demonstrates actual retrieval mechanics, not a shortcut past them — scoped to an in-memory vector store rather than a hosted database, since the corpora are small by design. Resolved a real multi-user question along the way: pipeline output (evidence, confidence, recommendation) is cached per ISO week and shared, since it depends only on claim data; adjuster actions are private, client-side, per browser — which is also what guarantees a new visitor always sees the full, untouched queue.
All of this is now written into project-spec.txt (amended in place, with each decision dated inline) and a new CLAUDE.md that sets the stack, file structure, and the 14-phase build order. Two open items carried forward on purpose rather than forced to a premature decision: which embedding provider to use for retrieval, and the batching strategy once the claim set grows past 20.
Claims schema & seed generator
src/lib/claims/ · scripts/validate-claims.ts
Repo scaffolded (Next.js 16, TypeScript, App Router) and pushed to GitHub. On top of it: a full claim schema (types.ts) with genuinely separate CMS-1500 and UB-04 shapes, field names mirroring the real box numbers rather than generic ones, and 20 authored claim records covering all 15 scenarios from the spec's test-set design — exactly 4 clean, 3 ambiguous, 3 missing-data, 3 complex-coverage-math, and 7 fraudulent, including the linked professional/facility pairs and the documentation-mismatch pattern that only shows up by comparing two linked claims against each other.
Content was authored for domain plausibility, not just schema conformance: age- and sex-appropriate procedures, diagnosis/procedure pairings that read as a real encounter rather than a random field-filler, and a shared provider identity reused across three of the fraud claims to produce the volume-spike pattern — which needed its own small dataset (provider-history.json) stating a trailing 6-month average against a current-month count, since "3.2x this provider's average" isn't something a single claim can carry on its own.
Only submitted_date is generated rather than authored, per the spec's own instruction — a small seeded PRNG (mulberry32) keyed to the current ISO week produces a deterministic-but-varied spread of claim ages (fresh, mid, near-deadline, one deliberately past its SLA deadline) that regenerates consistently within a week and shifts the next, with no reset step required.
A validation script checks the set for internal consistency — reciprocal linked-claim pairs, valid enum values, line-item totals that actually sum to the stated charge, and the exact scenario distribution the spec calls for — deliberately scenario-aware, so a claim's own _testMeta.deliberately_missing_field tag exempts its one intentional gap from being flagged, without silencing checks anywhere else. One real type-safety catch along the way: an exhaustive form-type check's defensive fallback branch was quietly unreachable as far as TypeScript's own narrowing was concerned (never), which is correct given today's two form types — cast rather than suppressed, so it stays a real guard if a third form type is ever added.
Phase 2 — Author the three RAG corpora
Author the three RAG corpora
content/corpora/
Three condensed reference documents (~500–600 words each) plus a typed manifest for the retrieval layer to consume in Phase 4: Coverage & Adjudication Policy (fully synthetic, in-house — coverage percentages, deductible logic, network rates, prior-auth rules, benefit caps), Fraud-Indicator Reference, and Regulatory Deadline Reference (ERISA's tiered deadlines plus state prompt-pay timing).
Before writing the fraud reference, a secondhand relay from a companion planning thread claimed the five billing-fraud category names (phantom billing, upcoding, unbundling, double billing, unnecessary/substandard care) came directly from FinCEN's FIN-2026-A001 advisory. Rather than build a citable document on that relay alone, fetched the advisory and NHCAA's consumer page directly. The volume-spike red flag and NHCAA's misrepresentation framing held up; the five category names did not — FinCEN's actual advisory centers on financial red flags (shell companies, ownership-change reimbursement spikes), consistent with its real mandate as a financial-crimes regulator rather than a medical-coding one. The terms themselves are real, just grounded elsewhere (OIG enforcement, DOJ False Claims Act settlements, CMS's National Correct Coding Initiative for unbundling specifically) — I corrected the attribution in the corpus and in project-spec.txt at the time. The same wrong attribution had already made its way into the workflow-visualization diagram's own node copy, though, and needed a second, separate correction once that diagram was revisited later (Phase 10) — a sourcing error, once caught, is often already further along than the one place it was first noticed. ERISA's own tiered deadlines (72hr/15-day/30-day) were checked the same way and held up exactly as specified, against DOL/EBSA guidance and the eCFR text — with one real addition worth folding in: each non-urgent tier gets a single notified extension (up to 15 or 30 more days), which wasn't in the original spec draft.
Reference material
Phase 3 — Deterministic calculation layer
Deterministic calculation layer
src/lib/rules/ · 31 tests, 6 files, all passing
Five pure functions, no API calls: severity.ts (dollar band × SLA-window escalation, medical-necessity bump, breach override), sla.ts (percent-of-deadline-window math), coverage.ts (deductible/network-aware coverage math, verified against the spec's own worked example — $1,140 billed → $912 covered, exactly), action-lookup.ts (the category+confidence → action table), and status.ts. Vitest installed as the test runner.
Planning this one surfaced a real design question worth recording: is "disputed medical necessity" — an input severity needs — something to extract from messy claim prose, or something the deterministic layer should just be handed as a clean field? Concluded it's neither exactly — it's a judgment the Pipeline's Analysis call (Phase 5) has to make by reasoning over the claim, the same way it judges the unnecessary/substandard-care fraud category. Rather than pre-bake that judgment as a boolean now, computeSeverity() takes it as an explicit parameter, sourced from _testMeta as a stand-in until Phase 5 exists.
Two structured reference datasets added alongside the rule modules — network-directory.json and member-accumulators.json — modeling something true of real claims processing: network status and deductible balance both live in separate systems (a payer's network directory, a member's benefit accumulator), never on the claim form itself, so looking them up rather than parsing them off the claim is the domain-accurate design, not a shortcut.
Separately, added CMS-1500's real Box 19 ("Additional Claim Information") and UB-04's Box 80 ("Remarks") — genuine free-text fields on both real forms — to eight of the twenty seed claims, with realistic clinical shorthand for Phase 5 to eventually read and reason over.
coverage-policy.md is now generated, not hand-maintained: coverage-constants.ts is the single source of truth, and scripts/generate-coverage-policy.ts renders the citable prose document's tables from it, so the numbers Anchor cites and the numbers the calculation actually uses can't quietly drift apart.
Status taxonomy correction
project-spec.txt · src/lib/rules/status.ts · workflow diagram
A review of the status taxonomy (project-spec.txt Section 7a) against the deterministic layer already built surfaced a real bug in the spec itself, not just an ambiguity: the original implementation note said initial status was "derived from whether Call 1's evidence is non-empty." That rule is wrong — a complex-math claim or a non-material-missing-data claim both produce non-empty evidence, yet both need to stay "Submitted, no flags," since Approve is still a reachable action for each. The correct rule is category-based: fraud, ambiguous, and material-missing-data start flagged; clean, complex-math, and non-material-missing-data start unflagged. status.ts was rewritten accordingly.
Working through the taxonomy also surfaced two genuine gaps: Denied was missing as its own status — the original 5-value list folded every closed outcome into "Resolved." Adding it back passes the same non-redundancy test that got a 6th "Overdue" status rejected earlier: denial carries real regulatory weight an approval doesn't. Additional Info Requested — a material missing-data hold was previously modeled as just a clock-pause with no visible status, implying eventual re-evaluation once the missing field arrived. This system has no real document-intake mechanism for that field to ever arrive, so calling it anything other than terminal would misrepresent what the system can do.
Status is now 7 values, up from 5 — each addition justified individually against the same non-redundancy test.
Phase 4 — RAG retrieval layer
RAG retrieval layer
src/lib/rag/ · 54 tests passing · real end-to-end smoke test against live queries
Local, self-hosted embeddings — @huggingface/transformers running Xenova/all-MiniLM-L6-v2 — over Voyage AI, closing the one embedding-provider decision left open since the RAG mechanism was first designed. Chunking splits each corpus on its own ## headings; embeddings and a plain in-memory array replace any hosted vector store, appropriately scoped to a few dozen chunks total.
The interesting part of this phase was verifying retrieval actually works, not just that it runs. A real smoke test — genuine questions against the real corpora, not synthetic fixtures — surfaced retrieval quality problems worth working through:
- The chunk containing ERISA's actual tiered-deadline table ranked 5th of 6 for "what does ERISA require for pre-service claims" — the one question it was built to answer. Root cause: the chunk was built almost entirely from raw markdown table syntax, which a sentence-embedding model embeds poorly. Fixed by embedding a flattened, plain-language version of any table while still citing and displaying the original markdown.
- That fix barely moved the ranking. The real mechanism: mean-pooled embeddings dilute a long chunk's vector toward its average content, so a short, keyword-dense chunk can systematically out-rank a longer chunk that actually contains the answer. Compounded by an actual authoring mistake: every document's pre-heading "Overview" chunk was just its provenance line, already captured structurally elsewhere, yet short and keyword-dense enough to out-rank real content — dropped from the retrievable index entirely.
- The underlying dilution problem remained, so added hybrid scoring, blending embedding similarity (70%) with a lightweight keyword-overlap signal (30%). That recovered the ERISA tiers chunk to 2nd place. Default
kbumped to 4 for margin, which costs nothing at this corpus size.
Left deliberately unresolved: retrieval ranking isn't perfect and won't be tuned further at this scale — the practical backstop is that Anchor feeds several top-k chunks to the model for actual answer synthesis, not just the single top-ranked one.
Phase 5 — Evaluation Pipeline
Evaluation pipeline
src/lib/pipeline/ · Sonnet, low effort · verified live against all 20 seed claims
The two batched calls specced back in Phase 1: Call 1 (Analysis) reads every claim — plus its linked claim for combo pairs, plus the billing provider's volume history for the spike pattern — and returns evidence and a proposed category, no tier, no recommendation. Call 2 (the isolated Confidence judge) is shown only that evidence and category, never Call 1's reasoning or the original claim again, and returns a confidence tier plus a recommendation narrative written to double as citation text for the Human Gate's mismatch-warning guardrail. Both calls use structured JSON output rather than hoping a prompt-shaped response parses cleanly, and both stuff all three RAG corpora into context whole rather than retrieving per query — the corpora are a few pages total, and real chunked retrieval is reserved for Anchor, where it's the actual thing being demonstrated.
A deterministic safety net sits between the two calls: whether a required field is actually null is a confirmed fact, not a judgment call, so missing-fields.ts checks every claim directly and overrides Call 1's category if it disagrees with a detected material gap.
The live smoke test surfaced a real reliability bug on the first two runs, not a hypothetical one: a batched call occasionally came back missing one or more claims from an otherwise well-formed, schema-valid response — structured outputs constrain each item's shape but can't express "exactly N items, covering this exact ID list," so the model can under-generate a batch without tripping any parse error. Root-caused by instrumenting both calls' raw stop_reason/usage and replaying the same inputs against a cached intermediate result. Fixed two ways: the user message now explicitly enumerates every expected claim_id and the exact count expected back, and both calls are wrapped in a completeness check that retries (up to 3 attempts) rather than trusting the first response.
End-to-end result against the real 20-claim set: 20/20 categories matched their authored ground truth, with sensible, claim-specific evidence and correctly computed severity/status/SLA for every claim — including one claim the model itself flagged as already past its SLA deadline.
Severity-on-terminal-status design
project-spec.txt Section 7b — caught reviewing this phase's own results
Reviewing the live Pipeline output surfaced a real gap: severity's deadline-proximity factor assumes a still-running decision clock, which stops being true the moment a claim is actually decided. Resolved by checking the actual regulation rather than assuming: ERISA's decision-deadline rule governs the determination only, not payment speed; state prompt-pay statutes do keep a payment clock running past approval, but none reset a fresh timer at the approval date. Since this system explicitly doesn't simulate reimbursement at all, there's no honest data to keep escalating severity against post-decision — so severity resets to Low on Resolved or Denied. Additional Info Requested is the deliberate exception: it was never actually decided, just stuck, so its severity stays frozen at the hold's start value.
Phase 6 — Interactive Router & Anchor
Interactive router & Anchor
src/lib/router/ · Sonnet, low effort · verified live against an adversarial question set
Real Claude tool-use across the four tools from Section 1: Lookup, Analysis, Recommendation, Reference Lookup. How the Router handles an ambiguous, broad query like "show me problem claims" had sat in Open Items since Section 1 was first written — resolved by extending Lookup to accept a structured filter (status/severity/category) alongside a specific claim ID, plus a small house table mapping common ambiguous phrases to a default filter. The response pattern is deliberate: always show best-guess results now and state the interpretation plus a named alternative in the same answer — never a silent guess, never a blocking clarifying question before showing anything.
Analysis and Recommendation default to re-surfacing the already-computed Pipeline result for a claim — free, instant, code-only — rather than paying for a new call every time an adjuster asks about a claim the Pipeline already evaluated. An explicit "re-check" request reruns Call 1 + Call 2 fresh for just that one claim, reusing Phase 5's own functions directly.
A ten-question adversarial set — not happy path — probed the three failure modes Section 1 names explicitly, plus the ambiguous-query fallback and a genuinely out-of-scope request ("deny this claim"). One real bug surfaced on the first live run: a question that reasonably needed two tools in sequence had its second tool call silently dropped, because the round-trip only handled a single dispatch round. Fixed as a bounded loop (up to 4 rounds). Rerun clean: correct tool chosen in every case, including refusing the out-of-scope denial request while naming what it could actually do instead.
Also gave Phase 4's real chunked retrieval layer its first actual caller — the Pipeline deliberately uses full-context stuffing instead, so Reference Lookup is where retrieval finally gets exercised for real.
Phase 7 — Human gate & persistence
Human Gate guardrail design
project-spec.txt Section 4a — design decision, ahead of this phase's code
Speced two guardrail mechanisms for the moment a human's action is submitted: a deterministic recommendation-mismatch check (no model call — compares the human's chosen action against the Pipeline's own recommendation, surfacing a soft "Are you sure?"), and a small model call that judges the quality of a Deny action's required justification against the claim's evidence and the retrieval layer's policy corpora, drafting a policy-cited replacement when the human's own text is inadequate. Both mechanisms are deliberately soft: neither blocks a human from proceeding with their original choice — a system that could override a human's Deny or Approve decision would contradict the human-gate principle the whole platform rests on.
Human gate & persistence
src/lib/humangate/ · src/lib/persistence/ · real jsdom tests · verified live
Three scope questions settled before writing anything: the guardrail check needs a live model call, so does that mean standing up the first real app/api/ route? No — a plain server-side function now, tested headlessly, with the HTTP wrapper waiting for Phase 8. localStorage only exists in a browser — real jsdom-backed tests instead of a hand-rolled fake. And the real per-ISO-week Pipeline cache: build it now, since the Pipeline is provably nondeterministic run-to-run and the audit log needs a stable snapshot to log against.
Both Section 4a guardrails built: the deterministic mismatch check, and the RAG-grounded justification-quality check, genuinely retrieving against the corpora rather than full-context stuffing. Also corrected a real architectural inconsistency while touching this code: the SLA-clock-pause logic was reading the authoring answer-key field directly — the field explicitly meant to never touch real system logic — swapped for the deterministic missing-fields scanner instead.
Section 6's coverage math got its first real caller. That surfaced a genuine bug on the first pass: CMS-1500's charge field turned out to be a per-unit rate needing multiplication by units, while UB-04's total-charge field is already a line total — mixing them up understated one claim's total by $420, caught by cross-checking the computed total against each claim's own authored total across all 20 claims.
Live verification: a flippant justification ("Eat my shorts") was correctly rejected with a policy-cited replacement. More telling: a vague-but-serious-sounding justification ("found a discrepancy the automated evidence missed") was rejected too, for being unverifiable rather than for tone. And in one case where the chosen action actually matched the recommendation, the justification check still caught that the adjuster's stated reason named the wrong fraud category against the claim's real evidence.
Human Gate extension — structured denial, reversible actions, recoupment
src/lib/humangate/ · src/lib/rules/ · test suite extended, all green
Deny's justification split from a single free-text field into four structured ones — specific reason and the plan/policy provision cited (both required), plus the internal rule or clinical standard applied and what might reverse the decision (both optional) — so the justification-quality guardrail can point at exactly which field falls short. A genuinely new terminal status, Recoupment Requested, reached only from Resolved, modeled deliberately as forward-only, not a reversal — Approve stays permanent, since unwinding a real payment has actual legal weight this system doesn't simulate. Paired with three real reversible actions — undoing a request-for-info, an escalation, or a denial — each looking up its own matching entry in that claim's audit log and reverting to that entry's prior status.
Phase 8 — Placeholder UI
Placeholder UI (8A)
app/
A plain, unstyled worklist and card view, verifying Phases 1–7 end to end — real severity/status/SLA logic, real category+confidence action lookups — before any visual design existed to wire against. Superseded by the real UI below rather than kept running in parallel, once the Figma work landed.
Phase 9 — Real UI
Visual design system
Figma · token-driven · three styles × light/dark
Five initial style directions narrowed to three, each carrying a genuinely different point of view rather than a palette swap: Ledger (dense, tabular, high information density), Clinical (serif, precision-first, reads like a clinical record), and Field (warmer, more approachable, for a less adversarial read of the same data). Each ships in light and dark — six combinations total — driven end to end by a token architecture rather than six hand-maintained copies.
Contrast held to WCAG AAA (7:1) throughout, checked computationally rather than eyeballed — caught and fixed three real failures (accent text against its own ink color dropping as low as 4.77:1 in Clinical light and 6.98:1 in Ledger dark) by retuning the underlying primitives, so the fix propagated everywhere at once.
The Claims Card carries a three-way toggle — Quick View, All Claim Fields, Audit Log — grounded directly in the real schema. Guardrail states from the Human Gate design are fully surfaced: a soft "[Action] anyway" confirmation naming the Pipeline's actual recommendation, and Deny's four-field justification form with a real accept/edit/submit-as-is/cancel flow.
Real UI (8B)
components/ · React + TypeScript + CSS Modules · verified in both dev and production builds
The complete Figma design wired into working React, across all six style/mode combinations. A token architecture (src/app/tokens.css) carries every color/font/radius decision through [data-style]/[data-theme] attributes.
A handful of real, non-cosmetic bugs came out of this pass, each root-caused rather than patched around:
- A shared fade-in/fade-out hook was starting its exit sequence from inside a
useEffect, which only runs after React commits — so on the exact render a modal closed, it was already unmounted for one real paint before the effect could re-add it with a fade-out class. Fixed by catching the transition synchronously during render instead of in an effect. - A status/severity legend popover was getting cut off at the bottom of a short table. Root cause: the table's own horizontal scroll region set
overflow-x: autowithout settingoverflow-yexplicitly, which CSS quietly resolves toautotoo — clipping the popover along with it. Fixed by portaling the popover todocument.body. - Legend badges were overflowing their panel even after repeated width increases — the actual cause was the badge component's own
white-space: nowrap, not the container.
Added after the rest of this phase was already marked done: the checkbox-driven bulk-action bar — selecting any row surfaces a persistent "N selected / Approve N claims / Escalate N claims / Cancel" bar. No bulk actions actually executed yet — this was a render/interaction pass only, deliberately ahead of Phase 13's human-gate wiring catching up to it.
Phase 10 — Workflow visualization
Workflow visualization
standalone Artifact — built out of numeric order, on purpose
An interactive, draggable, zoomable D3 diagram of the full system — the evaluation pipeline, the five category branches and their distinct recommendation logic, the human gate, the interactive router's four tools, Anchor's RAG retrieval path, and the confidence/severity/status taxonomies as a separate reference cluster. One big overview plus three camera-preset detail views sharing the same underlying node set.
Colors reuse the build-log's own ink/teal/coral neutrals for continuity, but the three subsystem colors are a categorical triple run through an actual colorblind-safety validator rather than hand-picked. Two real interaction bugs surfaced and got fixed after review: a text-overflow bug traced to a CSS text-transform/letter-spacing pair the wrap-measurement code wasn't accounting for, and a click-required-twice bug that turned out to be d3's pan-to-zoom and each node's own drag behavior both reacting to the same mousedown — fixed by excluding node-originating gestures from the canvas-pan behavior entirely.
The workflow diagram is badly out of date (rewrite, late in the build)
public/workflow-diagram.html
Asked directly and checked rather than assumed: the standalone diagram is describing roughly a Phase 5–7 snapshot of the system, not what exists today. It still states "20 claims → 2 total calls per pipeline run" as a hardcoded fact (actual: 132 claims, 6 chunks, 12 calls); has no chunking architecture at all; no Kimi/provider-flexibility story anywhere; describes the Router as "one-shot per question, not multi-turn" (false); and still lists thumbs-up/down feedback on Anchor's answers, which was cut from scope and never built. Rewritten node-by-node against the current codebase rather than patched. Node labels stayed factually plain; only the click-through detail text picked up a more direct, ownership-first voice for the decisions behind each piece.
Reference material
Phase 11 — Real API wiring & full prompt review
Pass A — claim-ID redaction
src/lib/claims/claim-number.ts · verified live across the Pipeline, Router, and Anchor
Planning the 100+ claim scale-up surfaced a real, previously-unnoticed problem: the internal claim_id authored for this test set (FRD-UPCODE-01, FRD-MISMATCH-01A, etc.) was being sent to the model unmodified in every Pipeline and Router prompt — and that ID spells out the exact fraud sub-type in its own text. Fixed with a single opaque, realistic-looking claim number (CLM-nnnn-nnnnnn) generated per claim and used for two jobs at once — the UI's display-facing number, and the only claim identifier ever sent to or returned by a model call.
Live re-verification surfaced a second, unrelated finding: the Confidence call was completing a full 20-claim batch reliably only about half the time. Three model-configuration hypotheses were tested and ruled out — a more visually-structured ID format, a doubled token budget, and a bump to medium reasoning effort (which made it measurably worse). The fix that actually worked came from a prompt rewrite: reframing the task explicitly as producing a set of paired judgement-and-recommendation results, rather than a flatter list of dual-purpose entries. Five consecutive live runs afterward: four clean on the first attempt, one recovered after a single retry, zero outright failures.
Pass B — Pipeline wired to a real route
app/api/pipeline/route.ts · dashboard-placeholder-data.ts deleted
The homepage now awaits a real, live two-call Evaluation Pipeline run instead of reading hand-authored placeholder data. Three real bugs surfaced immediately: severity was silently ignoring its own terminal-status reset rule; a Turbopack-specific path-resolution bug broke reading the RAG corpora from inside a real route handler (__dirname doesn't point where it used to once Turbopack relocates a compiled route's chunk — fixed with process.cwd()); and next build was quietly trying to prerender the homepage at build time, meaning every production build would trigger a real, costly Pipeline run. Fixed by marking both the homepage and the API route explicitly dynamic.
Live testing also surfaced a real, repeated misclassification problem: claims authored around deductible and inpatient-day-cap math kept landing as "clean" or "ambiguous" instead of "complex-math." Root-caused to two compounding causes — Call 1 had no visibility into a member's remaining deductible, network status, or annual inpatient-day usage, and separately the category definitions shared an example with the real distinguishing logic buried in a subordinate clause. Fixed both: the missing facts are now handed to Call 1 as raw inputs, and the category instructions gained a hard decision test plus a self-check.
Pass C — Router wired to a real route
app/api/anchor/route.ts (dispatch layer)
The four-tool dispatch built and adversarially tested back in Phase 6 got its first real route to live behind. Surfaced the same class of Turbopack path bug found in Pass B, in a different subsystem — the corpus loader had never actually run inside a bundled route handler before, since nothing had called it live until now.
Pass D — Anchor panel wired to a real route
components/AnchorPanel.tsx · real fetch/loading/error states · verified live
The Anchor panel's input, loading state, and answer rendering are now genuinely live. A long night of live, adversarial-by-accident testing surfaced a real, separate leak Pass A hadn't caught: a hardcoded old-format claim ID example baked directly into the Lookup tool's own schema description, sent to the model on every call regardless of any runtime redaction — fixed. It also surfaced a genuine, still-open list: no way to search by patient or provider name yet, filtered/list results don't get a citation card, answers render as literal markdown syntax, the panel wipes its previous answer on every new question, and — most interesting — a live example where an answer and its own citation card disagreed with each other, traced to the Pipeline's cache running independently per route rather than being shared.
Pass E — full prompt review
All four live model prompts reviewed end to end · Anchor's tools reworked · re-verified against an expanded adversarial set
Every live model-facing prompt read in full and revised against real, observed behavior rather than left as originally drafted. Call 1 was restructured into clearly delineated sections; a proposed addition asking it to justify its own category choice was deliberately dropped — that job belongs to Call 2's isolated recommendation, which never sees Call 1's reasoning and has to judge independently.
Anchor's system prompt changed the most. Lookup gained real filtering by patient name, provider name, dollar amount, and SLA-percent-remaining — closing a real, observed gap where an adjuster asking about a claim by patient name was told it didn't exist. Recommend-action was reworked from "draft a fresh recommendation" to "explain and draft supporting language for the one already computed," since a second, independently-recomputed one risked contradicting it. The tool-use loop gained a graceful fallback: reaching the round limit without a final answer used to throw an error; it now forces one last tools-off call summarizing what was found instead.
Re-verified against an expanded adversarial set — nine new cases layered onto the original ten. One incidentally proved the redesigned recommend-action tool works better than the test itself assumed — asked to draft a denial justification for a claim actually on record as Escalate, Anchor caught the mismatch on its own and offered the correct thing instead.
Phase 12 — Kimi / provider flexibility
Kimi / provider flexibility
src/lib/pipeline/model-client.ts · scripts/compare-providers.ts · Pipeline routed to Kimi in production
Motivated by two real things this build surfaced on its own: Kimi's meaningfully lower cost, and the Pipeline's own observed run-to-run classification inconsistency, which raised a fair, testable question — does a different model handle the same batched-reasoning task more consistently, given the same complete information? Architecture first: model-client.ts consolidates the Pipeline's two calls behind one shared callModel(). Anchor's tool-use loop and the Deny justification-quality check were deliberately left out of this consolidation — their request shapes differ enough between providers that forcing a shared interface would be a leaky abstraction.
Getting Kimi actually running surfaced three real infrastructure bugs. K2.6's own native reasoning mode breaks structured JSON output outright — floods the response with a long run of near-blank whitespace and never completes the object, confirmed reproducible twice. Running with reasoning explicitly disabled is what actually works. Separately, the OpenAI SDK's own client-level timeout didn't reliably fire — one request ran 13+ minutes past its configured bound with no error at all — replaced with a manually enforced Promise.race timeout. And Moonshot's own Tier-0 new-account rate limit got hit purely from testing volume, resolved with a real top-up crossing the threshold that unlocks Tier 1's unlimited daily cap.
Comparison ran through a real multi-pass harness, not a single trusted run — the same fixed, ISO-week-seeded 20-claim set both providers see identically, repeated passes per configuration. That produced the phase's most useful methodological finding: raw accuracy treats every miscategorized claim as equally bad, but the deterministic layer doesn't. A wrong category paired with a cautious confidence tier still reaches a human; only a wrong "clean" call paired with a confident tier resolves automatically, with nobody ever reviewing it — a "silent miss," and tracking it specifically turned out more decision-relevant than the accuracy number on its own. The harness also caught a bug in its own grading: one claim's ground-truth label described which authored "combo" scenario it belonged to, not the category the Pipeline should assign to that specific claim — grading against the raw label had been silently under-scoring both providers by up to a full point for the entire testing history until this was caught and fixed.
Prompt tuning produced a real, mixed result. Targeted few-shot examples for two fraud sub-types Kimi was systematically softening toward clean/ambiguous measurably worked. A dedicated example built for a separate complex-math claim never fixed its target, confirming this kind of tuning has to be verified against repeated results, not assumed to work. An explicit reasoning field, forced ahead of the category decision, was a clean win for Claude but neutral-to-mildly-negative for Kimi, which has no private reasoning phase of its own — kept for Claude, dropped for Kimi.
Final numbers, confirmed across two independent matched-prompt batches each: Claude averaged 18.4/20 (92%) category accuracy, a 5% silent-miss rate, and completed cleanly on roughly 4 of 5 runs. Kimi averaged 16.3/20 (82%), a 13% silent-miss rate, and completed cleanly on every single run tested. Real, opposite trade-offs — Claude is more accurate and safer when it finishes; Kimi finishes more consistently. The Pipeline's real cache and Anchor's own recheck path are now both routed to Kimi in production, a cost-driven decision for a public portfolio prototype with no real patients, providers, or dollars on the line — documented explicitly as a different call than an actual production system would be permitted to make.
Reference material
Phase 13 — Anchor debugging, Human Gate wiring, claim-set scale-up
Pass A — Anchor wired live, Kimi first
src/lib/router/anchor.ts · src/lib/router/model-round.ts · verified against a 19-question adversarial set
I ran Kimi first, cost-driven: the open items left from Phase 11 — a cache/pending duplication bug, a missing recommended-action label, one-shot-only memory — aren't provider-specific, so shaking them out against the cheaper model first meant not paying to debug the same logic twice. The duplication bug was real and visible: an Anchor answer and its own citation card could disagree on a claim's status or severity, traced to the Pipeline's cache not actually being shared across Turbopack's separately bundled routes. Fixed at the root: dashboard rows now carry the Pipeline result they already compute, and Anchor's route builds its claim index from that same client-supplied data instead of an independent cache read.
The panel gained a real scrolling history, paired with exactly one prior turn of memory, distilled to plain question/answer text rather than raw tool output. Live testing caught a real prompt bug, not a data bug: Anchor's own system prompt hardcoded an assumption that fraud always resolves to Deny, contradicting the actual confidence-tiered mapping. Fixed by having the prompt always defer to the tool's own recommended-action field instead of asserting one.
Pass B — Claude wired live, provider toggle shipped
components/SettingsPanel.tsx · same shared per-round helper as Pass A, second provider
Claude went through the identical shared per-round helper Pass A built for Kimi, rather than its own hand-rolled loop — the real test of whether that consolidation was a genuine abstraction. It held: the same 19-question adversarial set ran clean against Claude with no debugging needed. One real, worth-documenting behavior difference: asked to analyze five claims, Claude self-limited to three and explicitly offered to continue — the exact behavior the system prompt asks for. Kimi had done all five in one turn, since OpenAI-style function calling can request several tool calls in a single turn rather than exhausting the budget the way Claude's more literal one-tool-per-turn behavior does.
Claim-card action logic & Anchor visual polish
components/ClaimsCard.tsx · components/ChromeIcons.tsx
Claim-card action buttons now order themselves around the claim's actual recommendation instead of always leading with Approve. Anchor's "thinking" state moved from a static line of text to an actual shimmer — animated bars plus the Anchor wordmark itself. The wordmark's shimmer lands on CSS mask-image rather than an SVG <mask> element, after the mask-based version proved visually unreliable once combined with a moving gradient.
Pass C — Human Gate wiring, real persistence
src/components/ActionConfirmOverlay.tsx · src/lib/humangate/ · every action's overlay matched to Figma · Kimi-backed guardrail
The Human Gate stopped being a placeholder in this pass: every one of the seven action overlays plus a free-form Add-a-Note overlay now run against the real Figma design and real backend logic. A genuinely new mechanism came out of this pass: a uniform 4.2-second grace window on every action's confirmation screen — the action is held in memory only until the window closes, so a same-second correction leaves no trace, while Approve and Recoupment get their one and only chance to catch a misclick.
Per the original plan, the Deny justification-quality guardrail was built and tested against Kimi first, not Claude — confirmed live only after catching that the first implementation had quietly skipped that ordering and defaulted to Claude. Three real bugs surfaced and fixed live: a client-bundle build failure (Turbopack refused to ship a client component that transitively imported Node's fs through the Deny guardrail's own retrieval call); a Strict Mode double-write; and a stale-overlay flash where a just-committed confirmation screen briefly reverted to its own prior form during its fade-out.
Bulk actions, StatTiles wiring, and Anchor's selection awareness
src/lib/ui/bulk-actions.ts · src/components/BulkActionBar.tsx
The checkbox-driven bulk-action bar had been visual-only since Phase 9, deliberately deferred until real persistence existed to wire it to. Deny and Request Recoupment are deliberately excluded from bulk entirely — both call for individual review. Anchor gained real awareness of the checkbox selection itself, not just a single open card — a genuine gap found live when a multi-select "tell me about these claims" question came back empty-handed.
Five real bugs surfaced and were fixed during live testing of this pass, the most of any single pass this build produced: an empty-selection crash (the bar's own state calculation assumed at least one claim was always selected, which is false on every page's default load — this broke the entire worklist until fixed); a server-side localStorage reference; a stale-state bug where the sequential queue's overlay was keyed only by action type, reusing one component instance instead of remounting; a selection that cleared the instant a queue started rather than when it finished; and the Anchor filter-guessing gap described above. None of the five were caught by typecheck, lint, or the unit suite — all five needed a real running server and a real click-through.
Pass F — 103 new claims, 20 → 123
src/lib/claims/claims-seed-data.json · scripts/author-pass-f-claims.ts
I negotiated the category split before any data was written: 72 clean / 18 fraud / 13 complex-math / 12 ambiguous / 8 missing-data across all 123 claims. I set demographics the same way — ages 25–80 skewed above 50, four non-binary patients (a genuine schema widen), last names spanning several naming traditions.
Built as a one-time authoring script rather than hand-typed JSON, so every claim's total charge is derived from its own line items instead of manually re-summed. Three real bugs, caught by the existing verification loop before any of this reached the live app: a counting error double-counted UB-04-only clean claims; the CPT/revenue-code crosswalk was scoped to only the 20 original codes and threw on every new procedure code; and one authored missing-data claim nulled the exact billing-provider NPI field the coverage calculation depends on for its own network lookup.
Procedure/revenue-code plain-English lookup
src/lib/claims/code-descriptions.ts · ClaimsCard.tsx
"Care provided" showed only a raw CPT/HCPCS/revenue code, correct but not self-explanatory. A hand-curated code-to-short-label table now renders alongside every code rather than replacing it, verified by script against every claim in the full claim set, zero gaps.
Live Pipeline scope: capped back to 20, pending Pass G
src/lib/pipeline/cache.ts · src/lib/claims/generate-claims.ts
Two distinct bugs surfaced back to back the moment the 123-claim set actually got read by the running app, both crashing the worklist entirely. First: the per-ISO-week Pipeline cache had no way to notice the underlying seed data had changed mid-week — fixed by adding a cheap fingerprint of the actual claim set as an explicit second cache-key argument. Fixing that surfaced the deeper issue: a fresh live run tried to push all 123 claims through the existing two-call batched Pipeline in one shot and timed out outright — the exact batching-scale risk Pass G was always going to have to answer, just arriving earlier than planned. Rather than force that decision under pressure, a temporary cap (ACTIVE_CLAIM_LIMIT = 20) held the live app steady while Pass G's real fix got built properly.
Pass G — chunked batching, live at full 123-claim scale
src/lib/pipeline/chunk.ts · orchestrator.ts · batch-retry.ts
Measured before guessing: a real, uncached Kimi run at the proven 20-claim scale showed Call 1 using 10,316 of its 16,000-token budget and Call 2 only 3,111 — real numbers, not the flat, unmeasured ceiling both calls had shared since Phase 11. Extrapolated linearly, a single 123-claim Call 1 would need roughly 63K completion tokens and close to 25 minutes — past any of Kimi's own practical per-request limits and obviously past this project's own timeout.
Chunked at ~20 claims per call, chunk count derived from claim count. Getting the chunk distribution right took three real, live-tested attempts: a naive contiguous slice clustered clean claims together, producing real per-chunk token variance. A first fix balanced only a binary clean/non-clean split — helped Call 2 but made Call 1 worse, since fraud/complex-math/ambiguous/missing-data all stayed clustered within the "non-clean" bucket. A second fix distributed every category independently — still wrong, since every category's own round-robin started at chunk index 0, compounding across all five instead of canceling out. The actual fix, caught by a unit test before it ever reached a live call this time: concatenate every category's items in file order and round-robin a single continuous cursor across the whole concatenation, never resetting between categories.
One more real failure: a live run threw a connection-timeout error on one of six Call 1 chunks — a genuine transient network failure. Exposed a real gap: the existing completeness retry only retries a response that comes back incomplete, not one that throws outright, so a single flaky chunk was aborting the entire batch and discarding five other chunks' already-succeeded, already-paid-for results. Fixed with a new per-chunk retry wrapper, independent of the existing completeness retry.
Evidence-length cap: prose failed twice, a real schema constraint worked
analysis.ts's evidence field · maxItems, Kimi-only
Found live: a documentation-mismatch fraud claim rendered with 10 evidence bullets, despite the field's own schema description already saying "aim for 3-6." Tightening that same prose to an explicit "HARD CAP of 6, never more" made it worse — 13 bullets. Kimi does not reliably honor a numeric ceiling stated only in natural language. Switched to an actual JSON Schema maxItems: 6 constraint instead — confirmed live to work cleanly. Checked against the other live provider before trusting it project-wide: Anthropic's structured outputs reject maxItems outright (400 error) — would have been a real regression on the Claude path had it shipped unconditionally. Spliced in as a Kimi-only schema addition instead.
Two "stale after acting" bugs: Anchor's answers, the Claim Card's own recommendation
router/context.ts · ClaimsCard.tsx
Found live, in direct succession: a just-approved claim that Anchor still described as "Needs Approval," then a just-escalated claim it still called "Submitted, flagged." Root cause was narrower than the cache/pending duplication bug already fixed for a related symptom: Anchor's claim index built from each row's result field — the original, frozen Pipeline output — never from the row's own current status/severity. Fixed by patching the row's current status/severity into the result object stored in the index.
Second, separate bug, same session: the Claims Card's "Recommendation:" text had exactly one display-only override already, but that flag only covers a claim the Pipeline resolved with zero human input — a manually-approved complex-math claim kept showing its original recommendation as if still an open ask. Generalized into a real lookup table mapping each recommendation to the statuses that mean it's already been carried out, by any means.
recommendation_fulfilled reaches Anchor too, plus real-world terminal-state labels
recommend-action.ts · lookup.ts · status.ts
Anchor had the identical gap the Card did: asked "tell me what I should do with this claim" about an already-approved claim, it correctly reported the claim's real current status but still framed "Approve as calculated" as an open ask. Having the right facts in hand doesn't mean a model reliably draws the "already done" inference on its own. Confirmed live on a harder case: the same claim, asked about again after moving into a recoupment cycle, correctly recognized fulfillment still held and correctly distinguished the recoupment step as a separate, layered action.
Separately: three of the "fulfilled" statuses read wrong under a generic "No action needed" label — Escalated, Additional Info Requested, and Recoupment Requested are none of them actually closed. New real-world phrasing built for each. Found live within the hour: a second, untouched copy of the exact same display logic existed in Anchor's own citation mini-card, never updated alongside the Claims Card's fix.
analyze_claim's own gap: no status/severity/recommendation fields at all
analyze-claim.ts
Found live: "tell me about this claim" on an already-DENIED fraud claim came back with a full, correct evidence walkthrough but never once mentioned the claim had already been denied. Different root cause from the recommendation_fulfilled fixes: this result type never carried status, severity, recommended_action, or recommendation_fulfilled at all, on either of its two branches. Anchor had no way to know, not a reasoning gap. Fixed by adding all four fields to both branches.
recommendation_fulfilled's own false-case regression: narrating the check instead of just answering
anchor.ts's SYSTEM_PROMPT
The true-case instruction worked, but leaked into the false case too: asked about a genuinely still-open claim, Anchor narrated its own internal check on the ordinary, default case instead of just answering plainly. Tightened the instruction: the false case gets zero mention of the field at all — it should read identically to how Anchor answered before this field existed.
The real cause underneath all three recommendation_fulfilled bugs: prior-turn memory used as a fact source
anchor.ts's SYSTEM_PROMPT + priorTurnNote
Live-tested rigorously before concluding anything: asked the exact same question twice about the same claim with a real approval action taken in between, got back word-for-word identical text both times. Verbatim-identical output from two separate live model calls is not something stochastic generation produces by chance. Confirmed no caching exists anywhere in the client, the route, or the shared dispatch helper — every question genuinely fires a fresh request.
Root cause: the one-prior-turn memory feature's own instruction told the model to consult the prior Q&A "if the new question actually depends on it," but never said the prior answer's own factual content must not be treated as still valid. This explains all three of this session's bugs at once, not as three independent prompt gaps: if Anchor wasn't reliably re-querying fresh data on a repeat question in the first place, no amount of tuning what it says once it has fresh data would have mattered. Fixed with a prominent standing rule stating explicitly that status/category/recommendation/evidence can change between turns, and the prior turn exists only for reference resolution, never as a fact source.
Confirmed live afterward on a genuinely stronger test: asked about a Resolved claim, correct answer; after the claim was reversed back to Needs Approval, asked again — Anchor correctly picked up the new current state too, rather than repeating either its own prior answer or defaulting to a cached memory.
Pass H — the Deny-guardrail saga, eleven rounds
What follows is eleven rounds I went through of the same guardrail failing for eleven different reasons — a wrong document, a fabricated citation, a real gap in the corpus, a retrieval-recall gap, two of the guardrail's own rules contradicting each other on different claims — before the fix turned out to be removing judgment from a field that was never actually a judgment call to begin with.
Denial guardrail validated live — plus a real UI bug and a real design tension it surfaced
ActionConfirmOverlay.tsx
I found this testing the live app myself: "the denial call returned something different rather than letting the denial pass" — Anchor's own four-part denial draft, copied verbatim into the form, got three of four fields rejected. First, a real UI bug: the suggested-message list rendered as a plain ordered list over all four fields, skipping any field that passed — so the browser's native auto-numbering read the remaining fields 1, 2, 3 instead of their real positions. Fixed with an explicit value per list item.
Second, the actual rejection reasoning had been made invisible by an earlier deliberate design decision — replaced with an identical generic message regardless of the real reason, so nobody could tell whether the rejections were justified. Once made visible again, the real feedback turned out to validate the guardrail rather than expose a bug in it: all three catches were correct, sharp, and non-generic. The more useful fix, upstream of the guardrail entirely: Anchor's own drafting instructions never distinguished which corpus document belongs in which of the four fields, and never warned against overclaiming certainty in the reversal-criteria field.
Same fix, a second real claim: still failing, for two new reasons — shared requirements extracted
humangate/deny-field-requirements.ts
A fresh Deny draft on a different claim still failed fields 2 and 4 — for two different reasons the first patch never covered. Field 2 correctly cited the right document this time, but fabricated a policy provision that doesn't actually exist in the retrieved passage. Most concerning: Anchor's own reasoning trace showed it explicitly intending to "be accurate about what the reference material actually states" — and fabricating a citation anyway.
Extracted a single shared requirements constant describing what each of the four fields actually requires, imported by both the judge and the drafter, replacing two independently-worded, independently-drifting descriptions — the same principle already established elsewhere in this codebase for the identical reason (the coverage-policy document is generated from constants, never hand-duplicated).
The shared-requirements fix's own side effect: round budget exhausted, mid-thought non-answer
MAX_TOOL_ROUNDS 4→6 · model-round.ts's forced-answer prompt
The very next live Deny-drafting attempt returned no draft at all — just a trailed-off thought about what it was about to search for next. Traced to the round-budget mechanism: the shared-requirements fix now requires a separate lookup call per field needing a citation instead of reusing one, leaving no round left to actually return an answer. Raised the round budget, and rewrote the forced-answer fallback prompt to never describe a tool call it would have made next, only synthesize from what's already gathered.
Field 2's real, recurring cause: a genuine corpus gap, not a drafting or prompt problem
coverage-constants.ts's FRAUD_NONPAYMENT_PROVISION
A third live Deny draft failed field 2 yet again — but this rejection's own feedback contained the actual answer: the retrieved policy passages genuinely didn't contain any fraud non-payment provision. Checked directly: a grep across the actual corpus for "fraud" or "non-payable" returned zero matches. Every fraud-category denial in this system was asking field 2 to cite a provision that genuinely didn't exist anywhere to cite. Fixed at the source: a new provision constant, rendered into the corpus doc by the same script that already generates it, not hand-edited.
Raised directly, mid-fix, and worth recording honestly: was editing the actual policy corpus to make a failing test pass a legitimate fix, or a shortcut dressed up as one? Paused rather than just finishing the edit. Resolution: the Coverage & Adjudication Policy is explicitly a synthetic document authored for this project, free to revise the same way a real company revises its own policy — kept the addition, but flagging a corpus edit made in response to a failing test should happen before making it, independent of whether the content itself turns out defensible.
Dcl's own crash: truncated Kimi responses, plus a fail-open gap in the guardrail's "never blocks" principle
model-client.ts · guardrails.ts · ActionConfirmOverlay.tsx
A live Deny check started failing outright — no rejection reasoning, just a generic 500. Traced to the same growth that made field 2/4 drafting correct: the anti-fabrication language made Kimi's real per-field feedback text run long enough to intermittently hit the check's token ceiling. The shared model-call layer only ever checked for empty content, not truncation — a response cut off mid-JSON came back truthy and got handed straight to the parser, which threw an opaque error only ever logged server-side.
Fixed at three layers: an explicit truncation check, a raised token budget, and a retry wrapper around the call itself. The adjuster's own framing of the bug pointed at something real but different from what it first sounded like — what was actually missing was a fail-open path for when the check itself breaks, not a rejection. The overlay now distinguishes a real per-field rejection from the check itself failing and, only in the second case, offers an explicit "Submit without automated review" action.
Two more Dcl claims, two more distinct outcomes: a clean accept, then a retrieval-recall gap for the same fraud provision
guardrails.ts
A fresh phantom-billing draft passed all four fields cleanly, confirming the prior fixes hold. A second claim the same session then failed field 2 again — but for a genuinely new reason: the guardrail's query is auto-built from the claim's own category detail and evidence text, and this claim's evidence read entirely clinically, with none of the fraud-flavored vocabulary that let the same provision passage rank highly for other fraud claims that same day. Same corpus content, same category, passing or failing purely on how fraud-flavored a particular claim's evidence happened to read. Fixed by not leaving something deterministically knowable to embedding luck: whenever the category is fraud, the fraud provision is now guaranteed present in the retrieved context regardless of what semantic search actually surfaces.
A fourth field-4 rejection — this time the judge, not the draft, was wrong
deny-field-requirements.ts
An upcoding draft's field 4 offered two reversal paths, both legitimate. Dcl rejected it as internally inconsistent — reasoning that accepting evidence the original allegation was wrong would "prove the fraud allegation false" rather than cure it. Walked through rather than assumed either way, since this is a real domain-logic question: that reasoning doesn't hold up. A denial being reversed by new evidence showing the original allegation was wrong is not a disqualifying contradiction — it's what reversal means. Fixed at the shared source: a reversal category is allowed to show the original theory doesn't hold.
Rounds five and six: generalizing the fix, and "no reversal exists" as a correct answer, not a failure
deny-field-requirements.ts · guardrails.ts
Round four's fix was written as an upcoding-specific carve-out. Round five found the judge reusing the identical flawed logic on a completely different theory pair — the wording hadn't generalized. Rewritten as a general rule instead of an example tied to one fraud pattern, with a real, previously-missing distinction added: a fact from a different, independently-submitted claim is untouchable; a fact that's just this same claim's own documentation is always gap-fillable.
Round six went a level deeper: once a linked claim's own established facts already conclusively corroborate a misrepresentation, there's no remaining scenario where the original submission was correct — "No information could reverse this decision" isn't a fallback for failing to find something better, it's the objectively correct answer, and both Anchor and the judge had been reaching past it.
Round seven: the guardrail's own rules contradicted each other
deny-field-requirements.ts
A different claim got rejected again — this time citing round five/six's own rule against the exact reversal category round four had already ruled valid on this same claim. Two rules, each correct for the claim that motivated it, had never been checked against each other. Fixed by making the actual test explicit: source, not fraud-theory type. A fact from a different, independently-maintained source is untouchable; this same claim's own documentation is always gap-fillable, for any fraud theory.
The regression suite: built from round seven's own lesson, catches a real regression immediately
scripts/validate-deny-guardrail.ts
Seven rounds of live "fix → find a new failure → fix again" cycling was itself a signal: every fix was checked only against the one claim that had just failed, with nothing verifying it didn't silently break a claim that had already passed. Built the direct analog of the claims validator, applied to prompt content instead of code. It proved its worth on the very first run: round seven's own fix had silently broken a claim that had passed cleanly earlier the same session.
Round nine, and a dead end: when two named theories need independent evidence each, and temperature turns out to be pinned
deny-field-requirements.ts · model-client.ts
A claim produced directly contradictory guidance across two live runs of identical text on whether its two named theories needed a hedge. Made the actual test explicit: multiple theories may only be named together when each has its own independent evidence, not when they're different interpretations of one ambiguous signal. Separately, tried and reverted the same day: adding a low temperature to the Deny check specifically, aimed at the verdict flip-flopping the regression suite kept surfacing. Confirmed via a real API error: Kimi K2.6 pins temperature server-side and rejects any override outright.
Field 2 stops being a judgment call: rounds ten and eleven
deny-field-requirements.ts · guardrails.ts · anchor.ts
After nine rounds of prompt-level patches to field 2 specifically, a step back: field 2 was never actually open-ended. Deny is only ever the recommended action for the fraud category at High Confidence, so the correct plan/policy citation is always the exact same fixed text, every time. Fixed by interpolating the fraud-provision constant directly into the shared requirements text as field 2's given, fixed answer — exactly the kind of thing this codebase already treats as a deterministic lookup elsewhere, just not yet applied here.
Round eleven, a new claim: the judge rejected a resubmitted field 1 on the grounds that it hedged between two theories — but the actual submitted text contained no such hedge. Root cause: the userMessage shows the claim's own Pipeline-computed category label directly alongside the adjuster's own field 1 text, with nothing distinguishing the two — the judge was grading the metadata, not the field.
A structural fix instead of another patch: the Auto-fill bypass
ActionConfirmOverlay.tsx · hasUsedAutoFill
A live case surfaced the same pathology a third time: Dcl rejected its own just-given suggestion on a fresh, independent re-evaluation of byte-identical text. Rather than another prompt round chasing non-determinism the regression suite had already shown was real and recurring, a structural fix: once "Auto-fill suggestions" has been used at all, the form now bypasses Dcl entirely and finalizes directly — matching the guardrail's own "never blocks" principle, since Auto-fill is the moment the adjuster takes over, and anything after that is just the human's own call.
Making the guardrail's own disclaimer do real work
anchor.ts · ActionConfirmOverlay.tsx
Given how often Dcl and Anchor genuinely disagree — confirmed repeatedly by the regression suite, not just a felt impression — Anchor's own closing line after a denial draft was rewritten to set real expectations: it now says plainly that the review it's about to go through is also AI-based and won't always agree, and that a flag doesn't mean the draft was wrong.
The claim-set rebalancing pass: 123 → 132, and two "hard" test cases turn out to have been data bugs
claims-seed-data.json · member-accumulators.json · PIPELINE_TARGET_CHUNK_SIZE
Raised directly during live testing: too many flagged claims dead-ended at Escalate, the least satisfying outcome for someone exploring the interface hands-on — a real UX/psychological consideration for a portfolio piece, weighed deliberately rather than dismissed as unnecessary polish. Rebalanced from 123 to 132 claims, with the chunk size bumped so the set divides evenly into 6 chunks instead of 7.
A genuinely surprising find while reconfiguring: two claims tested extensively earlier the same day as "genuinely hard" adversarial fraud cases turned out to already be tagged "clean" in the seed data — the same specialty/procedure-mismatch data bug described below, just discovered from the live-testing side before the systematic audit caught up to them.
A specialty/procedure-mismatch bug, then a systematic audit finds it's a real pattern, not a couple of one-offs
claims-seed-data.json · 3 rounds: one claim, then a linked pair, then a full audit
First found live through Anchor/Dcl testing, not authoring review: a claim tagged "clean" billed an ophthalmological exam under a cardiologist's own NPI — correctly flagged as fraud by the Pipeline despite its intended label. Checking whether that provider showed the same pattern elsewhere surfaced a second instance immediately. Given two instances from one provider, a systematic pass followed rather than assuming that was the whole problem: a generated table of every "clean" claim's provider/specialty, diagnosis, and procedure, read for plausibility.
It wasn't a couple of one-offs — every linked "combo" clean claim across 7 pairs (14 claims) paired a provider with a same-day surgical procedure clearly outside their specialty: an endocrinologist billed for a colonoscopy, an OB/GYN for a hernia repair, a psychiatrist for a knee arthroscopy, a physical therapy clinic for carpal-tunnel-release surgery. Read as a likely authoring artifact: providers and surgical scenarios cycled independently without cross-checking specialty against procedure. Fixed all 7 pairs plus two standalone claims in one pass — swapping the provider where a correctly-matched specialist already existed, swapping the procedure only where none did. Explicitly deferred: roughly 14 more standalone claims flagged at a softer, office-visit-level severity, tracked for a future pass.
Smaller fixes and polish: search, plain-English diagnoses, the SLA-deadline tile, page size
ClaimsTable.tsx · diagnosis-descriptions.ts · stat-range.ts
The claims-table search bar's case-sensitivity bug: the query was lowercased before comparing, but the display number wasn't — meaning a search for "CLM" or "clm" could never match. Procedure-code descriptions alone turned out too generic — a raw E/M code just reads "Office visit" regardless of what it was actually for, since that context lives in the diagnosis. Built a companion diagnosis-description table and combined the two.
The "claims nearing SLA deadline" stat tile's Today/7d/30d toggle produced a deeply counterintuitive spread live (1/50/131 of 132 claims) — traced to using absolute hours-remaining as the threshold when this system's SLA windows are wildly different sizes, meaning "within 30 days" was mathematically almost identical to "hasn't breached yet" for any standard-tier claim. Switched to percent-of-window-remaining, judging every claim on the same relative scale regardless of its actual window size.
Phase 14 — Vercel deployment & final case-study verification
The first real Vercel deployment
next.config.ts · embed.ts · vercel.json
Most of Phases 5 through 13 had never actually been committed to git — the repo's last real commit predated the Pipeline, Router, Human Gate, and the real UI entirely. Caught before deployment rather than after: reviewed, staged, and pushed as two commits (removing reference material and local settings that had no business being in a public repo, then one large catch-up for everything else), rather than fabricating a fictional phase-by-phase history for work that was never actually committed incrementally.
The deploy itself surfaced three real bugs, in order, none of which had ever shown up in local dev — exactly the kind of gap a genuinely different runtime environment exists to catch:
- A typo'd environment variable:
IMI_API_KEYinstead ofKIMI_API_KEYsurfaced as an OpenAI-SDK credential error, since Kimi's client silently falls back to checkingOPENAI_API_KEYwhen its own key resolves empty. - A missing native binary:
@huggingface/transformers' onnxruntime shared library wasn't reaching the deployed function, since Next's automatic file tracing doesn't reliably catch a library that's dynamically loaded at runtime. Marking it as a server-external package alone didn't fix it — confirmed live, the same error persisted — an explicit file-tracing include for the two routes that actually callretrieve()did. - A read-only cache path: once retrieval got further along, the library's default cache directory turned out to sit inside
node_modules, which is read-only on Vercel at runtime — redirected to/tmp, the one writable path in a serverless function.
A fourth failure that looked related — an Anthropic "credit balance too low" error — turned out to be unrelated to any of this: a test script that happened to omit the provider field and fall back to Anthropic by an SDK default, not a problem with the app itself, which defaults to Kimi. Confirmed by testing both providers explicitly once the account was topped up: both work cleanly, with real grounded citations from the actual corpus.
The pipeline loading screen, and a live debugging arc inside it
loading.tsx · fact-cycler.ts
Replaced the placeholder spinner with real branding — the ClaimsDock and Anchor marks, sized and ordered as a deliberate hierarchy — the five-bar shimmer already built for Anchor's own "thinking" indicator, and a cycling list of real facts about the system, shuffled with no immediate repeats. Colors and type follow whatever style and theme the visitor last chose.
The cycling logic went through its own live debugging arc. A first implementation — a chain of nested setTimeout calls — tested correctly in isolation, including under React StrictMode and through the real hydration path, and yet stayed frozen on the very first fact through a genuine ~90-second cold Pipeline run in production. The actual cause: a Suspense fallback held open that long during a streamed SSR response is exactly the scenario most likely to have its DOM node torn down and recreated mid-wait by the browser or an intermediary proxy, silently resetting an in-memory timer chain each time. Rebuilt to derive the current fact from real elapsed wall-clock time, anchored in sessionStorage rather than kept in memory — self-correcting no matter how many times that happens, verified with a test that specifically simulates the remount rather than just the happy path.
Separately, the ClaimsDock logo itself was invisible on first deploy — a flat white fill meant for the masthead's own dark header, rendering near-invisible against the loading screen's light page background. Recolored via a CSS mask against the same ink-primary token the rest of the screen's text already uses, rather than maintaining a second light/dark asset.
Pre-warming the weekly Pipeline cache
vercel.json
The per-ISO-week Pipeline cache means only the first visitor after a weekly rollover pays for a live run — everyone else that week gets an instant cached result. Closed the remaining gap with a daily Vercel Cron hitting the Pipeline route at 00:05 UTC: cheap, since every call after the first real trigger that week is just a cache hit. A known, accepted limit rather than a full guarantee: a visitor arriving earlier the same day the ISO week rolls over, before that day's cron fires, could still hit one cold run a week — judged an acceptable tradeoff for a portfolio site rather than paying for sub-daily cron frequency.