DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • A Practical Pipeline for Identifying Sensitive Columns Before Test Data Masking
  • Practical QA Workflow Showing How Teams Integrate LLM Testing into Real CI/CD Pipelines
  • Testing Strategies for Web Development Code Generated by LLMs
  • The LLM Selection War Story: Part 1 - Why Your Model Selection Process is Fundamentally Broken

Trending

  • Retesting Best Practices for Agile Teams: A Quick Guide to Bug Fix Verification
  • AGENTS.md Makes Your Java Codebase AI-Agent Ready
  • AI in SRE: A Practical Autonomy Model for Self-Healing Infrastructure
  • Why SQL Server Applications Break on PostgreSQL and How Compatibility Layers Fix It
  1. DZone
  2. Data Engineering
  3. AI/ML
  4. Code Generation Is Solved; Trust Is the Bottleneck

Code Generation Is Solved; Trust Is the Bottleneck

Polygraph is an open-source Claude Code plugin that finds bugs in stateful code (reducers, workflows, checkout flows, session managers) in any language.

By 
Jean-Jacques Dubray user avatar
Jean-Jacques Dubray
·
Aug. 14, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
89 Views

Join the DZone community and get the full member experience.

Join For Free

You have a checkout flow. You have 40 tests. They're green.

Now: what happens when a payment webhook arrives after the user cancels? What happens when a retry lands on a session that already expired? What happens on the fourth failed attempt when autoRenew is off and the period boundary has already passed?

You don't know. Not because you're careless — because a state machine with 6 states, 7 actions, and 3 payload values has thousands of reachable (state, action, data) combinations, and your 40 tests visit 40 of them. The bugs that page you at 2 am live in the other several thousand. Polygraph is a Claude Code plugin and standalone CLI that walks all of them.

Why You'd Bother

Polygraph is for stateful code: reducers, workflow engines, protocol handlers, session managers, order state machines, anything with a dispatch(state, action) shape. If your code is a pile of pure functions, go use property-based testing. If it's a state machine, keep reading.

Narrow on shape, not on language. The model reads your source in whatever it's written in — you name it once as lang in the contract — and the trace format is just NDJSON, so any runtime that can log a {pre, action, data, post} line per step can feed it. What's always JavaScript is the derived spec and your rules, because those are what the replayer and model checker execute on Node.

What you get back is not a lint warning. It's a shortest action sequence that reaches a state violating a rule you wrote. Something like:

Shell
 
✗ never-charged-twice [state] — pred returned false
      init                            {"status":"new","attempts":0,"hasDue":false}
      CREATE({})                   -> {"status":"active","attempts":0,"hasDue":false}
      RENEW_CHARGE({"result":"5xx"}) -> {"status":"grace","attempts":1,"hasDue":true}
      RENEW_CHARGE({"result":"ok"})  -> {"status":"grace","attempts":2,"hasDue":true}


That's a repro. You paste it into a test file, and you have a failing test in about ninety seconds. 

A real one: On a production SaaS subscription-billing machine, Polygraph flagged a disagreement on exactly one window: a 5xx from the payment processor during renewal moved the row to grace and marked it due, when the dunning path in the same codebase correctly treated 5xx as ambiguous. The next retry rotated the idempotency key. If the 503'd transfer had actually settled, the customer got charged twice. A human reviewer had found the same bug by hand; five independent model-derived readings of the source landed on it blind.

And in the controlled seeded-bug eval, the split is worth knowing: replaying real traces against the derived spec found 0 of 5 seeded bugs. Model checking found 5 of 5, with counterexamples. Trace replay tells you whether to trust the model. Model checking is where the bugs actually are.

What It Actually Does

Three artifacts, all diffable, all in your repo:

1. contract.json — the scope. Which state fields matter, which actions the machine accepts, what data each action can carry, which states are terminal, and lang is the language your source is written in.

2. A spec — a JavaScript model of your code, written by an LLM from your source (whatever language that is). It's a strict SAM v2 module: every action it ignores has to say why via reject(reason), it can't hide bookkeeping state, and it declares its own action/data domains — so the checker knows what to explore with zero config. Several specs are generated independently and vote, so one bad generation doesn't decide anything.

JavaScript
 
export const stateInvariants = [  
  { name: 'locked-only-at-limit', pred: (s) => s.status !== 'locked' || s.attempts >= 3 },
];

export const transitionInvariants = [  
  { name: 'expired-never-verifies', pred: (pre, action, data, post) => !(action === 'ATTEMPT' && data?.expired) || post.status !== 'verified' },
];


3. invariants.mjs — your rules, as plain JS predicates:

This part is yours and can't be automated away. Code with a bug is a perfectly faithful description of the wrong behavior. Invariants are where your intent enters the system.

Then two checks run. 

  • Replay asks "is the spec faithful?" Real traces ({pre, action, data, post} windows, captured by wrapping your dispatch once) are replayed against each spec, with positive and negative controls proving the harness can tell good from bad. 
  • Model check asks "where are the bugs?" It iterates the faithful spec exhaustively from init against your invariants and prints the shortest path to every violation.

The Caveats

  • "Exhaustive" means exhaustive over the finite (action, data) domain declared in your contract. A machine whose behavior depends on unbounded counters or arbitrary strings is checked only at the representative values someone chose. That's the standard TLA+ modeling move, and the gap between declared domain and real data is real.
  • It's a consistency check, not a proof. A clean run means your code's observable behavior matches an independent reading of its own source. Nothing more.
  • Every finding is a lead to investigate, not a verdict. There is no triage step that discharges "real invariant break with no observable consequence."
  • It's experimental and not peer-reviewed. Don't make it your only safeguard on safety-critical code.

API Key and Cost 

Only three things call the Anthropic API: spec generation, code authoring (polygen), and polynv's optional headless invariant harvest. You need ANTHROPIC_API_KEY in your environment for those, including inside Claude Code, where the skills shell out to the same scripts and do not use your session credentials.

Ballpark, on a typical machine:

you run key? cost
verify.mjs --source … (generate + replay) yes ~$0.50
polygen.mjs --intent … (author new code — JS/TS output only) yes ~$2
replay saved specs, model check, --tla, polyvers, polynv, polyrun no $0


That second row is the load-bearing one. Everything that checks (replay, the exhaustive model check, version gating, the mutation grade, TLC escalation) is keyless, local, and deterministic on Node ≥ 20. Which is precisely what makes CI viable: you commit the spec, and the gate re-runs it on every merge request for free. No key in CI, no per-MR API bill, no nondeterminism in your pipeline.

That gate is polygate, and there's a GitLab reference implementation at <POLYGATE_GITLAB_URL> — a .gitlab-ci.yml you can copy that runs corpus validation, replay, and the model check against your committed artifacts and fails the MR on a violation. (Contrast: Specula, the closest comparable agentic TLA+ pipeline, reports a median of $57 and 3.7 hours per system. Excellent tool, structurally can't run on every MR.)

Getting Started

Prerequisite, and it's a hard one: The stateful code has to be runnable in isolation, because traces are ground truth from the code actually executing. A clean step boundary: a dispatch, reducer, or handler, from experience, Claude will refactor it easily for you. If it only runs against a live DB or device, stand up doubles first (in Claude Code, the agent will build them). Note this is the only place your language matters, and only for convenience: the bundled withTracing / tapReducer helpers are JS, so a Go or Python machine means writing the {pre, action, data, post} NDJSON lines yourself. It's about ten lines.

Zero-cost first (no key, five minutes):

Shell
 
git clone https://github.com/cognitive-fab/polygraph
cd polygraph && npm test        # validates the bundled corpus, runs the controls
npm run verify:turnstile-v2     # replays bundled specs — see the output shape


Then on your own machine, as a plugin:

Shell
 
/plugin marketplace add cognitive-fab/polygraph 
/plugin install polygraph@polygraph


…and just ask: "verify this state machine", or /polygraph:polygraph for the guided end-to-end run (Claude drafts the contract, instruments the boundary, captures traces, runs controls, triages with you). Trace capture is historically what made this expensive; it's the step the agent now carries.

Or plain CLI, no Claude Code:

Wrap your dispatch once, projecting only the contract's observable keys (JS shown; in another language, emit the same NDJSON shape by hand):

JavaScript
 
import { withTracing } from '<plugin>/scripts/instrument/trace-emitter.mjs';
const dispatch = withTracing(
  rawDispatch, () => ({ status: m.status }),'traces/s1_normal.ndjson'
);


Note --source takes your real file, in your real language:

Shell
 
node scripts/validate_corpus.mjs contract.json traces/  # no key
node scripts/verify.mjs --contract contract.json --source src/machine.ts \  
     --traces traces/ --model opus-5 --n 5 --out out/   # key, ~$0.50


That writes out/findings.md and the generated specs to out/specs/. Commit the winning one, and from then on the loop is free:

Shell
 
node scripts/check.mjs --spec out/specs/spec_0.js --contract contract.json \  
    --invariants invariants.mjs  # no key, forever


There's no default model: pass --model. Use opus-5 or better; deriving a faithful transition function is a hard reasoning task and lighter models don't clear the bar. If you see empty specs, you lowered --max-tokens below what the reasoning block needs; put it back to 32000.

Apache-2.0. The method is written up in arXiv:2607.05076.

Your test suite is a sample. This is the census.

Requirements engineering Testing large language model

Opinions expressed by DZone contributors are their own.

Related

  • A Practical Pipeline for Identifying Sensitive Columns Before Test Data Masking
  • Practical QA Workflow Showing How Teams Integrate LLM Testing into Real CI/CD Pipelines
  • Testing Strategies for Web Development Code Generated by LLMs
  • The LLM Selection War Story: Part 1 - Why Your Model Selection Process is Fundamentally Broken

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook