Sound familiar?
You built the app in Lovable, then moved the hard parts to Claude Code when the prompts stopped fitting on one screen. Four months in, it works: clients post shifts, workers get matched, holds expire, invoices go out. You have not written a line of it.
Somewhere in that code is the answer to "what happens when the same person is registered twice", and you do not know what the answer is, because you never gave one. The tool picked something, and it picked a lot of somethings. Real users arrive next month, and you are starting to wonder which of those somethings have your name on them.
What they sent us
The founder runs a staffing platform: agencies post shifts, workers hold and take them, a manager approves. She built it over four months: Lovable for the screens, Claude Code for the API and the matching logic. She sent prodready the repository, a login, and a list of five things she was worried about. None of the five were the problem.
Discovery scanned 19 dimensions and logged 125 findings: 4 critical, 32 high, 71 medium, 18 low. Readiness 2.0 out of 5, "developing". There were 36 tests, and 16 acceptance rows, every one marked NOT RUN.
Normal for a demo. The finding that mattered was not on the security list.
The business rules were not in one place: three copies of the business engine, one in the front-end, one in the API, one in a background job, and nothing that checked they agreed. One rule, how many shifts a client may hold at once, had two implementations. The front-end allowed three, the API allowed five. Both had been written by an assistant on different days from different prompts, and both were wrong in the only sense that matters: nobody had decided.
So we went through every branch that touched money, access or assignment and asked one question of each: who approved this? The answer came back "nobody" 64 times. Sixty-four business decisions: who gets assigned first, what a hold costs, what a cancelled hold refunds, what a manager may override, what happens on a duplicate profile. Each made by the tool, none seen by the founder.
Two were already failing. A brand-new worker profile with no shift history returned HTTP 500 the moment the assignment rule looked at it, because the rule assumed a history. And there was no duplicate guard at all. The same worker, same email, could be created twice, and then assigned twice.
What would have happened
What actually happens
On day 4 the first paying agency uploads twelve workers by CSV. Three are already in the system under a different spelling. Nothing stops it.
On day 9 a worker with two profiles is assigned two shifts at the same hour in two cities; the agency hears about it from its client. On day 12 the agency asks why the app let it hold five shifts on Tuesday and three on Wednesday. Nobody can answer, because the code says both.
On day 20 an invoice goes out at the API's number and a credit note at the front-end's number. The agency's lawyer asks for the platform terms. The terms say the founder sets these rules. She never did.
The rule that set them was written by a tool that was never asked, it is in production, and the contract has her signature on it.
Forty-eight hours
Hour 0 to 6: the inventory
Before touching anything we listed every rule. Not the code, the decisions: each branch that changes money, access or assignment became one row with three columns. What the decision is; the question the founder has to answer, with an ID; how it ships today. That register had 64 rows by hour 6.
Two she could answer on the spot (where it runs, who the first admin is). Sixty-two she could not, and we did not ask her to; the pipeline never contacts the owner mid-build; the question goes in the register and the story is marked blocked on a product-owner decision.
Hour 6 to 18: every unapproved rule behind a flag
The principle is simple: a rule nobody approved does not run. It ships either switched off, or with the most conservative value anyone had already written, and it must be impossible to half-switch it on.
The duplicate guard is the cleanest example. It needs two answers: which key makes two profiles the same person, and what the app does when it sees one. Both constants ship as None.
# rules/duplicates.py, awaiting owner decision #10
DEDUPE_KEY_KIND = None # "email" | "phone" | "tax_id"
DUPLICATE_RESPONSE = None # "refuse" | "merge" | "flag_for_review"
def check_config():
a, b = DEDUPE_KEY_KIND, DUPLICATE_RESPONSE
if a is None and b is None:
return "inactive" # both None: guard off, app runs
if a is None or b is None:
sys.exit("duplicate guard half-configured; see decision #10")
return "active"
Both None means inactive and the app starts. One of them set means somebody answered half a question, and the app refuses to boot rather than guess the other half. This runs at start-up, before the first request, so a wrong deploy fails in ten seconds instead of on day 9.
The hold limits took the other shape. There were already two numbers in the code, so the flag is an environment value with a conservative default: unset keeps the stricter of the old behaviours, malformed refuses to start.
def hold_limit(name, default):
raw = os.getenv(name)
if raw is None:
return default # unset: old behaviour, stricter copy
if not raw.isdigit() or int(raw) < 1:
sys.exit(f"{name} malformed") # malformed: refuse to start
return int(raw)
MAX_OPEN_HOLDS = hold_limit("MAX_OPEN_HOLDS", 3) # decision #21
HOLD_TTL_HOURS = hold_limit("HOLD_TTL_HOURS", 24) # decision #22
The front-end copy and the background-job copy were deleted: one rule, one file, one owner.
Then the screens. Twelve handlers in the worker flow, everything after the profile step, depended on rules with no answer. We did not hide the buttons; the handler names went into a frozen list, each one shows "Not yet available" when clicked, the matching endpoints were removed rather than disabled, and a test asserts the list has exactly 12 entries. Adding a thirteenth or removing one fails the build.
Hour 18 to 36: the rest of production-ready
The decisions were the story, but the app still had 4 critical findings. Config failed open when an environment variable was missing; now it fails closed and refuses placeholder secrets. There was no way to create the first admin in production; now there is one audited command.
The schema was created at start-up from live models; now migrations are the only owner of it, and the first migration refuses to run downgrade. 12 CHECK constraints and 42 foreign keys went in, added as NOT VALID and validated once zero orphan rows remained. An append-only audit table, enforced by a trigger, records every assignment and every hold.
The 36 tests became roughly 3,650. Every change went to three independent reviewers scoring architecture, security and functional completeness 0 to 5; anything under 3 is a reject. There were 31 rejections, and each one became a mandatory task.
From our desk
The first version of the duplicate guard was rejected. If one constant was set and the other was not, it logged a warning and ran inactive. The security reviewer's note was one line: a warning nobody reads is a rule nobody approved. The rewrite refuses to start, and that is the version above.
Hour 36 to 48: gate, ship, hand over
The final gate runs every suite, a load test at 20 requests per second for five minutes with 50,000 shifts seeded, and a fail-closed matrix that tries to boot the app with missing env, placeholder secrets, a SQLite URL, the demo seed, a missing signing key. All refused. Deployed to the founder's own server with a migration job that runs before the web process starts. Then the handover: the register, 64 rows, 62 open, each with the question written so that a yes or a number is a complete answer.
Turning a rule on is the same three-step change every time. A reviewed constant, a migration, a test that was already written and skipped.
# 1. the constant: one reviewed line
DEDUPE_KEY_KIND = "email"; DUPLICATE_RESPONSE = "refuse" # #10 answered
# 2. the migration: forward-only, fails if duplicates already exist
"""0009 activate duplicate guard (#10)."""
def upgrade():
op.execute("CREATE UNIQUE INDEX ux_worker_email ON worker (lower(email))")
# 3. the test: un-skip it
# @pytest.mark.skip(reason="decision #10 open")
def test_second_profile_with_same_email_is_refused(): ...
Each rule had this prepared before the founder had seen the question. So an answer becomes a config change plus a test flip, and the pipeline's reviewers see it as one small diff, not a feature.
What it looks like now
Readiness 2.0 to 3.5. Critical findings 4 to 0. Three copies of the business engine to one. And a register the founder can read in ten minutes, where every row is a decision she can make, a question that answers it, and the exact state the rule is in right now.
A slice of the register as it was handed over. The full one has 64 rows.
| Decision | The question | How it ships today |
|---|---|---|
| Worker flow after the profile step | #3 to #14: what a worker sees and may do after registering (availability, holds, cancellations) | 12 handlers show "Not yet available"; endpoints absent |
| Duplicate profile | #10: which key makes two profiles one person, and what happens when it matches | Guard shipped inactive; both constants None; a half-answer refuses to start |
| Hold limits | #21, #22: how many open holds per client, how long a hold lasts | Env values; defaults are the stricter old copy (3, 24 h); malformed fails closed |
| Assignment priority | #31: when two workers qualify, who gets the shift | First come first served; the scoring rule is off |
| Backup RPO and RTO | #40: how much data may be lost, how long recovery may take | Backup and restore-drill tooling exists; not scheduled; exits 3 if run without a policy |
| Alert receiver | #47: who is paged, on which channel | Placeholder route; alerts logged, none sent |
| Messaging providers | #52: which SMS and email provider, which sender identity | Sandbox only; live providers off |
| Languages | #58: which locales the platform supports | Locale registry; one approved locale enabled |
She got the register on Friday afternoon.
I had been avoiding those decisions for six months without knowing they were decisions. The list made them small. I did most of it before dinner.Founder
Most of 62 questions in one afternoon. Each answer she gave came back to us as one reviewed line, one migration and one un-skipped test. The duplicate guard was live the next morning. Nothing else in the app had to change, because nothing else had ever been allowed to depend on a rule nobody had approved.
- Every rule that touches money, access or assignment has one implementation and one row in the register
- No rule runs without an approval or a conservative default; a half-configured rule stops the app from starting
- The 12 unfinished worker-flow screens say so, and their endpoints do not exist
- Switching a rule on is a reviewed constant, a migration and a test flip, never a rewrite
- Every assignment and every hold is in an append-only audit table, so a dispute has a record instead of a memory
Do this tonight
Do this tonight
1. Count the decisions you never made. Run this in your repository and read the hits, not the number.
grep -rnE "if .*(price|fee|cost|amount|rate|refund|limit|max|role|admin|can_|allow|assign|match|priority)" \
--include='*.ts' --include='*.tsx' --include='*.py' --include='*.js' src/ \
| grep -v test | sort
Next to each line, write who approved it. If the honest answer is "the tool", that is one row in your register. Expect dozens.
2. Register the same person twice. Sign up with your own email, then again with the same email in capitals. If both succeed, you have no duplicate guard.
Then, with the fresh empty account, open every screen and watch the browser's Network tab filtered to status 500. Empty accounts find rules that assume history.
3. Find one rule that lives twice. Take one number from step 1 (a hold limit, a fee, a maximum) and search the whole repository for it. If it appears in both the front-end folder and the API folder, check whether the two agree; ours did not.
These checks find the rules you did not decide. They do not tell you which decisions are safe to defer, how to ship the undecided ones switched off without breaking the ones around them, or how to prove to a reviewer that a half-configured rule can never reach production. That is what the prodready 48 hours are for.
The rule
The rule
A rule nobody approved does not ship on. It ships off, with a question attached. An unapproved rule in production is a liability with your name on it, and the tool that wrote it will not be in the room when someone asks why.
I had been avoiding those decisions for six months without knowing they were decisions. The list made them small. I did most of it before dinner.— Founder