Replay & verify
Verdicts & divergences
The rules are identical for every runtime, live in lib/src/core/verdict.dart, and are pinned by spec/fixtures/conformance/verdicts.json. They are deliberately strict: an attempt that didn't run as recorded is never evidence of a fix.
Attempts
| Attempt status | When |
|---|---|
| reproduced | The original failure assertion fired (same signature). |
| clean | It ran to the end as recorded: there is a failure assertion to check, every recorded input was dispatched, required restoration happened, no other failure fired, and nothing material diverged. |
| invalid | Anything else: errors, timeouts, crashes without a report, incompatible capsules, no assertion, material divergences. |
Batches
bugcapsule run | bugcapsule verify | Rule | Exit |
|---|---|---|---|
| REPRODUCED | — | Every requested attempt reproduced | 0 |
| — | FIXED | Every requested attempt clean | 0 |
| NOT REPRODUCED | — | Every requested attempt clean | 1 |
| — | STILL FAILING | Reproduced, no clean attempt | 1 |
| FLAKY | FLAKY | Both reproduced and clean attempts | 2 |
| INCONCLUSIVE | INCONCLUSIVE | Anything else | 3 |
| INCOMPATIBLE | INCOMPATIBLE | Wrong runtime, or state schema changed | 3 |
Attempts that never ran count against the requested number. One clean attempt plus one error is INCONCLUSIVE, never FIXED. The verdict is recorded on your project with the number of valid attempts; a capsule moves to fixed only when every requested attempt was valid.
In regress run: FIXED → pass; STILL FAILING → fail; FLAKY → the test is quarantined (skipped unless --include-quarantined); INCOMPATIBLE → stale (schema changed without a migration); anything else → inconclusive, counted as a failure.
Divergence catalogue
Types not marked expected are material: an attempt with one can't count as clean. Unknown divergence types are material too.
| Type | Means | What to do | |
|---|---|---|---|
build.gitSha | expected | Replayed build is a different commit (normal for verify) | Nothing |
screen.size | expected | Different screen; pointer coordinates were scaled | Use the capture device, or add semantic actions |
network.unused | expected | A recorded response was never requested | Nothing |
network.unrecorded | material | The app sent a request the recording never saw | Add an endpoint rule for volatile fields; if a fix changed the requests, capture a new capsule on the fixed path |
network.reused | material | More requests with one key than recorded; the last fixture was served again | Check for retries or duplicate calls in the replayed build |
network.noBody | material | A fixture without a body was served | Keep body capture on for that endpoint; raise maxBodyBytes |
network.escaped | material | Web: a browser request bypassed the SDK to another origin | Init before code grabs fetch; add static origins to web.allowOrigins |
action.unregistered | material | A recorded action id isn't registered in the replayed build | Register it (renamed? registered late?) |
text.untracked | material | A recorded text id isn't tracked in the replayed build | Track it before the replay's input reaches it |
text.redacted, storage.redacted, state.redacted, route.redacted | material | A value was redacted on device and replayed as a placeholder | For QA builds, keep test-account tokens out of the deny list, or exclude that flow |
pointer.mismatch, selector.ambiguous, selector.missing | material | Web: the element at the recorded point isn't the recorded target | Add stable data-testid / data-bugcapsule-action attributes |
pointer.unsupported | material | iOS: the capsule needs pointer replay | Wrap the taps in BugCapsule.action |
lifecycle.unsupported | material | The runtime can't drive a recorded lifecycle state | Usually unavoidable (iOS hidden) |
restore.<integration> | material | A restorer threw | Fix the restore callback / serializer |
restore.missing, restore.missingIntegration, integration.missing | material | Captured data has no integration to restore it | Add the same integrations to the replayed build |
restore.late | material | A restorer registered after the first input, or storage was read before restoration | Init first; register state earlier |
bloc.unrestored, riverpod.unrestored, notifier.unrestored, getx.unrestored, mobx.unrestored | material | Flutter: state couldn't be injected from a periodic checkpoint | Register a serializer; avoid constructor-dispatched events |
capability.unsupported | material | The capsule needs a capability the replaying runtime lacks | See the feature matrix |
dispatch.error | material | Dispatching a recorded input threw | Read the attempt's details |
failure.other | material | A different failure fired | It may be a new bug; inspect it |
runtime.mismatch | incompatible | Capsule from another runtime | Replay in the right project (-p, --runtime) |
Reading a result
$ bugcapsule verify 35f3c4ba-… -n 3 -p examples/shop
verify #1 PASS 9.6s pointer, 6 inputs, 6 responses (×3)
FIXED — failure gone in 3/3 valid runs
recorded: status fixed; runs: replay reproduced 3/3, verify not_reproduced 3/3 (validAttempts 3)
$ VITE_BUGCAPSULE_DEMO_FIX=discontinued bugcapsule verify 5200d4bc-… -n 2
verify #1 INVALID 8.3s … unexpected failure: …no recorded response for POST …/api/checkout
INCONCLUSIVE
The second one is correct: the fix makes the app send a request the recording never saw, so strict replay can't prove the bug is gone. Capture a capsule that covers the fixed path.
Login-gated flows
Tokens and OTPs are redacted on device, so replaying a flow behind a login often hits *.redacted and becomes inconclusive (a missing token can send the app down another path). For QA builds, use a test account whose token you deliberately leave out of the deny list, or start the failing flow after login so the token isn't part of it.