Stale resolved_at after resolve→reopen
Problem
Section titled “Problem”A customer reopens a ticket. The status field flips to reopened but the resolved_at timestamp stays set to the original resolution time. SLA reporting now thinks the ticket is both reopened and resolved. A snapshot test of an open or resolved ticket passes; only the transition exposes the inconsistency.
The code
Section titled “The code”Why review misses it
Section titled “Why review misses it”You read the function and confirm “yes, it reopens.” You don’t look at every column the ticket has and ask “what stale state could be left behind?” The bug is in what the function doesn’t update.
The example test that passes
Section titled “The example test that passes”Tests the one thing the function does. Misses the thing it should also have done.
The SqlProof property
Section titled “The SqlProof property”A @given test asserting if status != 'resolved' then resolved_at IS NULL against a freshly-generated ticket would pass — because every individual ticket state respects the invariant. The bug requires a sequence.
The counterexample
Section titled “The counterexample”Illustrative — Hypothesis would print the actual sequence and state:
The fix
Section titled “The fix”When to reach for state machines
Section titled “When to reach for state machines”A @given property test would have generated thousands of individual tickets and asserted the invariant on each. Every individual ticket would have satisfied it. The bug is in the transition between two valid states.
Rule of thumb:
- Single update changes a row from invalid to invalid? →
@givencatches it on the first example. - Sequence of updates leaves a row in a logically-impossible state? → state machine.
State machines are slower (each example replays N rules), so don’t reach for them when a single-shot property would do — see recipe 3 for the single-shot variant.