2026-07-15

Designing PIN Lockout for Someone Else's Phone

Last post ended with a broken registration flow triaged to the top of Sprint 2. This one covers two things: how that fix survived an unplanned interruption, and the first real Sprint 2 story, which turned out to be the most interesting design problem in the project so far.

A pause that cost nothing

Mid-way through the registration fix, I hit my usage limits and had to stop. The code was done and both test suites were green, but the change had not been verified against the running stack, and there was no pull request yet.

Before stopping, the state went into the session log: what was on the branch, which tests covered it, and a numbered list of exactly what remained. The next session started with two words, "finish 17," and picked up without a single question. Live verification, pull request, merge, done.

The lesson is not new but it keeps proving itself. Documentation is not overhead you pay after the work. It is what makes the work interruptible. A solo project with usage limits is a good forcing function for a habit that matters just as much on teams, where the interruption is a vacation, a reorg, or a handoff.

The threat model is the product decision

The first groomed Sprint 2 story was PIN lockout. The interesting part is why it exists at all.

This app's core rule is that everything happens on the customer's phone. A bartender confirms a beer by typing their personal 6-digit PIN into a device they do not own or trust. That is great for the bar's workflow and terrible for the PIN, because the person most motivated to guess it is holding the keypad. Without protection, a customer could sit at the bar and try codes until something works.

The design answer has three parts:

  • Two axes, equal thresholds. Five consecutive wrong guesses lock a PIN for fifteen minutes. Separately, five failures in a rolling window block the customer account itself, checked before any PIN is verified. The second axis exists because guesses cannot be attributed to a target PIN; a hostile customer attacks all of them at once, so the account doing the guessing is the thing to stop. Equal thresholds mean neither axis is the weaker path.
  • No oracle. A wrong guess, a locked PIN, and a blocked account all return the same generic rejection, byte for byte. The device is untrusted, so it learns nothing: not whether a PIN exists, not whether it was right, not why it was refused. There is a test that asserts the responses are identical, because a distinguishable error message here would quietly undo the whole design.
  • The truth lives server-side. Every rejected attempt is recorded with its real reason. The admin audit work later in this sprint will read from exactly that table.

One decision from Sprint 1 paid off here: the lockout columns were added to the schema back when the PIN table was first designed, before any behavior used them. This story wired them up without touching that table's shape. Designing the schema one step ahead of the features is cheap when you do it and expensive to retrofit when you do not.

The PM angle

An honest miss: I merged the pull request before its CI run had registered, trusting the local test runs. The pipeline ran after the merge and passed, so nothing burned, but the gate exists to be waited for, not raced. The fix is a habit change, confirm the checks have actually started before merging, and it goes on the list alongside the things that went right.

The other note is about thresholds. Five attempts and fifteen minutes are defaults from grooming, encoded as named constants in one place. They are guesses about real bar behavior, and they will be tuned when reality reports back. Writing tunable decisions as obviously tunable code is part of what makes them cheap to revisit.

Where the project stands

Sprint 2 is at three of seven: both live-testing interrupts and the PIN lockout story are merged, tested, and verified against the running stack. Next is PIN lifecycle, so admins can issue and reset PINs properly instead of relying on the development seed. The mug is still two hundred beers away, but the path to it keeps getting safer.

Comments

Loading comments...