Success story · HR tech · Product decisions

Ship it switched off: the app whose business rules nobody had approved.

A non-technical founder built a staffing platform with Lovable and Claude Code over four months. It contained 64 business decisions — who gets assigned, what a hold costs, what happens on a duplicate — that she had never consciously made. The AI had. We shipped the app live in 48 hours with every unapproved rule switched off, and handed her a list.

Staffing / HR platformBuilt with Lovable + Claude CodeNon-technical founderReadiness 2.0 → 3.548 hours
Success story · HR tech · Product decisions
64unapproved decisions found→1afternoon to decide six months of things
Ship it switched off: the app whose business rules nobody had approved.
prodreadyBuilt with Lovable + Claude Code · production-ready in 48 hours
64
unapproved decisions found
2
implementations of one rule, disagreeing
12
handlers shipped 'Not yet available'
1
afternoon to decide six months of things

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.

BEFORE: rules live wherever the tool put them Front-end (Lovable) hold rule, copy A max 3 open holds API (Claude Code) hold rule, copy B max 5 open holds Background job assignment rule, copy C nothing checks A = B = C same rule, two answers (3 vs 5) Assignment rule new profile, no history: HTTP 500 Duplicate guard none: same worker created twice, assigned twice Owner of these rules: nobody. Approved: 0 of 64.
Before: three copies of the business engine, one rule with two answers, a 500 on every new profile, and no owner for any of it.

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.

TIMELINE: 48 hours of engineering, 17.5 px per hour Inventory 64 rows in the register Flags and freezes 62 rules off, 12 handlers frozen Production-ready config, migrations, audit, 3,650 tests Gate and ship fail-closed matrix, deploy, handover 0 h 6 h 18 h 36 h 48 h 0-6 6-18 18-36 36-48 who approved this? both None = off, half = no boot 31 rejections, 67 approvals 62 questions, 1 afternoon Readiness 2.0 at hour 0. 3.5 at hour 48. Zero unapproved rules running.
Timeline: six hours to find the 64 decisions, twelve to put every one of them behind a flag, the rest for the ordinary production-ready work and the gate.

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.

AFTER: one register, one flag per rule, one way to switch it on Rule register 64 rows: decision, question ID, how it ships today Flag per rule off (both None), or conservative default from env Activation reviewed constant + migration + test flip, one small diff Start-up check half an answer: refuse to start malformed env value: refuse to start UI: 12 handlers frozen each shows "Not yet available" endpoints absent, list length is a test An answer becomes a config change plus a test flip.
After: every rule has a row in the register, a flag that is off or conservative, and one reviewed path to being switched on.

A slice of the register as it was handed over. The full one has 64 rows.

DecisionThe questionHow 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 matchesGuard 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 lastsEnv values; defaults are the stricter old copy (3, 24 h); malformed fails closed
Assignment priority#31: when two workers qualify, who gets the shiftFirst come first served; the scoring rule is off
Backup RPO and RTO#40: how much data may be lost, how long recovery may takeBackup and restore-drill tooling exists; not scheduled; exits 3 if run without a policy
Alert receiver#47: who is paged, on which channelPlaceholder route; alerts logged, none sent
Messaging providers#52: which SMS and email provider, which sender identitySandbox only; live providers off
Languages#58: which locales the platform supportsLocale 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

Send us what you have.

The readiness scan is free — install our MCP and your own coding agent runs it. When you want it fixed, send us what you have: fixed scope, fixed price, agreed before the clock starts. 48 hours later, it's production-ready — on your own cloud, with the tests to prove it.

Feed the machineWatch the 49-second film