---
title: "When the Paywall Flow Is Broken, Build the Staff Tool, With Dry-Run, a Cap, and an Audit Row"
canonical: https://dxdev.com/blog/staff-dry-run-tool-with-cap-and-audit-log/
datePublished: 2026-04-28
---
A customer needed their team-credit count bumped. The product flow that's supposed to handle that is incomplete for the kind of account they had, and the staff admin page doesn't even render the field. So the number was sitting in a database column, reachable, with no sanctioned way to change it.

There is an obvious move here, and it's the wrong one. Open a query window against prod, run a targeted `UPDATE` against the account, watch it say "1 row affected," done. Five seconds. The customer is unblocked.

The five-second UPDATE is a trap. The right move costs maybe twenty extra minutes, leaves you something a non-engineer can run, and turns a one-off into a tool. It's the pattern I reach for every time the real flow is broken but the data change is legitimate.

## Why the raw UPDATE is worse than it looks

The raw UPDATE feels surgical, but look at everything it doesn't do.

It doesn't validate anything. You typed a number. Did you mean that number? Is it even a sane value for this product, or did this account just become a tournament organizer where credits work differently? The UPDATE doesn't know and doesn't care. Whatever integer you type goes in.

It doesn't show you the before state. You're changing a number you can't see in the same breath. If the current value isn't what you assumed, you never find out, because the statement just overwrites it.

It doesn't have a brake. A fat finger adds an extra zero, or a stray `WHERE` clause turns one row into every row. There is nothing between your typo and the customer's account.

It doesn't leave a trace. Three months from now, someone asks why this account has a different credit count than what the order history shows. There is no answer. The change happened outside every system that records changes. It's now folklore, if anyone even remembers.

And the worst part: it doesn't compose. The next time this comes up, and it will, because the flow that should handle it is still broken, you do the whole thing again from memory. Every repetition is a fresh chance to fat-finger prod. You've built nothing.

A raw UPDATE is not a smaller version of the right tool; it moves the same byte with every safety stripped off.

## The tool that's barely more code

Here's what I built instead. It's a staff page. One form and a handler, and it does exactly five things the UPDATE didn't.

**It reads and shows the current value first.** Before anything else, the page loads the current team-credit count for the target account and prints it. You see the existing number on screen. You're never editing blind. This alone catches the "wait, that's not the account I thought" mistake, which is the most common one.

**It dry-runs by default.** This is the load-bearing part. The form has a mode, and the mode defaults to dry-run, not commit. Submit it, and it reports exactly what it *would* do: "would set team credits for account X, account type validated, within cap." No write. You read that line, confirm it matches your intent, and only then flip to commit mode and resubmit. The destructive path is never the default path. You have to ask for it on purpose, after you've seen the preview.

One real caution here that I've been burned by before: the dry-run has to walk the *same code path* as the commit, right up to the write itself. If dry-run runs a different branch that builds the SQL differently or skips the validation, then "dry-run looked fine" tells you nothing about what commit will do. The preview is only worth something if it's the real operation minus the final `INSERT`/`UPDATE`. Build it as one path with a boolean at the very end, not two paths that you hope agree.

**It validates.** The handler checks the account type before it will commit. Not every account type is allowed to have this field set this way, and the validation is the difference between "fixed one account" and "corrupted an account whose credits mean something else." The validation is the part the UPDATE skipped entirely, and it's the part that actually encodes what you know about the data.

**It caps at a sane maximum.** Hard ceiling. Nobody in a legitimate scenario is bumping this field to four digits. If a value comes in above the cap, the tool refuses and tells you why. This is pure foot-gun insurance. It doesn't make the tool more capable; it makes the tool impossible to use as a wrecking ball. The cap costs one `if` statement and it's the single highest-leverage line in the file, because it converts "a typo wipes an account" into "a typo gets rejected with a message."

**It logs an audit row.** Every commit writes a row to a dedicated log table: who, which account, old value, new value, when. Now the question "why does this account have a different credit count" has an answer that lives in the database, next to the data it explains. The audit row is what makes the change *legible* later, to you, to support, to whoever inherits this.

That's the whole tool. Read, dry-run, validate, cap, log. Tally the extra code against the raw UPDATE and it's a form, a `SELECT` you were going to need anyway to see the current value, one validation check, one `if` for the cap, and one `INSERT` into a log table. Twenty minutes, maybe. For that you get something a support person can run without a database client, without prod write access, and without the ability to do real damage.

## The same shape, the second time

The proof that this is a pattern and not a one-off: the same week, a second nearly identical need came up. Some accounts needed their expiration date aligned across an organization and its sub-events. Different field, different table, same exact shape. So I built a companion tool with the same five properties: shows current state, dry-runs by default, validates, bounds the change, logs to audit. It shipped in the same hotfix train, and it took almost no thinking, because the shape was already decided.

That's the real payoff. Once you've built one read-dry-run-validate-cap-log tool, the next one isn't a design problem, it's a fill-in-the-blanks. You stop writing one-off UPDATEs, because the safe version is now the *fast* version. You're not choosing between speed and safety; the tool is both.

## Don't let the tool excuse the missing flow

One discipline to bolt on, or the pattern quietly becomes its own kind of debt. A staff tool that papers over a broken product flow is a patch, not a fix. The reason you needed it is that the real flow can't do this and the admin page doesn't expose the field. That's a bug, and the staff tool makes it *less* visible, because now there's a workaround and the pressure to fix the underlying flow drops to zero.

So when you ship the tool, file the ticket for the real fix at the same time. Exposing the field on the detail page properly, or making the paywall flow handle this account type, is the actual resolution. The staff tool is the bridge that keeps a customer unblocked today without a raw prod write, while the real fix waits its turn in a queue instead of living as tribal knowledge in your head.

## Related

- [When the Paywall Flow Is Broken, Build the Staff Tool: Dry-Run, a Cap, and an Audit Row](caps-audit-log-dry-run-staff-tool-not-raw-update): companion framing of the same pattern from a slightly different angle
- [The auto-approve feature that quietly rewrote 10 registrants](automation-that-clobbered-user-data-opt-out-plus-restore): what happens when a data-modifying automation ships without a dry-run gate
- [Don't leave the recovery gun loaded: scrubbing prod constants at end of day](dont-leave-the-recovery-gun-loaded): the disarm discipline that belongs at the end of every staff-tool session
- [The Dry-Run That Lied: When --dry-run and --apply Run Different Code Paths](dry-run-that-lied-different-code-path-from-apply): why dry-run must walk the same code path as the real write, not a parallel branch
- [The One-Shot Data-Repair Script as a First-Class Artifact](one-shot-data-repair-script-as-first-class-artifact): treating recovery and repair scripts as real artifacts with the same rigor as production code
