Back to writing

Architecture · Long-form

The hardening pass — from vibe-coded prototype to software an enterprise runs

A forty-thousand-line prototype proved the idea. It was not the product. This is what the pass from one to the other consists of, and the mistakes that taught us each rule.

Published · November 2026~13 min read

1. One prototype, one question

In October a contractor-management system arrived on my desk: a presales build for one of India's largest infrastructure and energy conglomerates, a contractor workforce platform for roughly two hundred thousand contract workers across several hundred sites, vibe-coded by a presales engineer who knows the Indian contract-labour domain better than most product teams.

It had been built the way everything is built now: a person with a clear picture of the problem, a model in the editor, and a few weeks. It proved its idea convincingly. It was not a product. The gap between the two states is not a feature list, and it is not a rewrite. It is a specific kind of work that I have started calling the hardening pass, because that is what it is: a pass over everything that already exists, asking a fixed set of questions, and refusing to move on until each one has an answer the code enforces.

This piece is about that pass. The rules are the easy part to list. The reason I trust them is that almost every one was written down within forty-eight hours of being caught out by its absence.

2. Feature breadth is not the gap

The presales build deserved more respect than the phrase “vibe-coded prototype” usually gets. Forty thousand lines of TypeScript. Twenty-four functional modules. Fifty-one database migrations. Layered role-based access with time-based one-time passwords for external users, a hash-chained audit log, generated API documentation, and a domain model that already understood principal employers, licence thresholds, minimum-wage matrices, gate passes and statutory registers. The hard domain thinking had been done.

What the baseline audit found was a different category of thing. The signing secret for session tokens fell back to a dev-only default when the environment variable was missing, and the shipped environment file did not set it, so anyone could mint an administrator token for any tenant. The single sign-on handler checked that a signature element existed in the response, not that the signature was valid. There was no rate limit on login. Generic create-read-update-delete routes applied data scope on reads but not on updates and deletes, so a site-scoped user could edit records outside their site by guessing an identifier. Inbound webhooks failed open when no secret was configured. Cross-origin requests were allowed from anywhere. Identifiers came from counting rows. Documents were base64 strings in the database. The default database was a file.

None of that is a criticism of the engineer. Every one of those defaults is the correct choice for a demo, and a demo is what was asked for. The point is structural. A prototype is built under the assumption that the person using it is friendly, alone, and on a laptop. A product has to assume an adversary, a second tenant, and shift change at several hundred sites on a Tuesday morning.

The prototype had done the hard thinking about the domain. It had not yet done any thinking about what happens when something goes wrong.

3. Eight questions, every endpoint

The pass itself is not creative work, which is exactly why it gets skipped. It is the same eight questions asked of every endpoint, every screen and every background job, with the answer enforced on the server rather than in the interface. After the second week of October I wrote them down as the standard for everything we build, and they have not changed since.

Can this be created twice? Uniqueness is checked with the label trimmed, spaces collapsed and case ignored, on create and on edit, and the response names the record that already exists. Is a person stored as text? An approver or an assignee is a reference to a real, active user who holds the right permission. A deactivated user is never offered and never accepted. Can a step complete silently? If a required step cannot run, because the workflow is missing or inactive, or there is no approver, or a fact is absent, it blocks with a reason and raises an alert. It never auto-completes and it never guesses. What happens to a missing fact? The record is marked as needing information. It is neither dropped nor filled in.

Is the save atomic? A rule with five levels, or an import with two thousand rows, lands in one transaction or not at all, and a concurrent edit gets a conflict response that says someone else changed this, reload. Who set this field? Fields the server owns are stripped from whatever the client sent, and the actor recorded on every row is the signed-in person, not a system account. Can this be deleted while in use? No, and the refusal shows where it is used. Who can see this row? Every read is scoped to the caller's data scope, a record outside that scope answers not-found rather than forbidden, so its existence is not leaked, and caches are keyed by scope so a warm cache cannot serve one user another user's world.

The hardening pass · from prototype to product

Stage 0

The prototype

Vibe-coded in weeks. Deep domain model, broad feature surface, demo-grade everywhere else. It proves the idea; it does not assume an adversary, a second tenant, or shift change on a Tuesday.

Stage 1

The baseline audit

Security, authorisation, scenarios, responsive layouts, theme. Findings ranked critical to low, each with file and line. Also a list of what the prototype did well, so nobody rebuilds it.

Stage 2 · Eight questions, every endpoint

  • Can this be created twice?

    Uniqueness checked case- and space-insensitively on create and edit; 409 naming the existing record.

  • Is a person stored as text?

    Approvers and assignees are references to real, active users with the right permission. Deactivated users are never offered.

  • Can a step complete silently?

    A step that cannot run blocks with a reason and an alert. Never auto-complete, never guess. The one sanctioned skip is audited.

  • What happens to a missing fact?

    The record is marked needs-info. It is not dropped and it is not guessed.

  • Is the save atomic?

    Multi-row writes run in one transaction; concurrent edits are serialised; a stale edit gets a reload-and-retry 409.

  • Who set this field?

    Server-owned fields are stripped from client input. The actor is the signed-in profile, never a system user.

  • Can this be deleted while in use?

    Refuse, and show where it is used.

  • Who can see this row?

    Every read is scoped. Out of scope answers 404, not 403. Caches are keyed by scope.

Stage 3

Acceptance at the client's scale

A synthetic tenant the size of the real one. Three personas. Every list endpoint scored on time, limit clamping, paging and row-level scope. Then the acceptance run is re-run until it is boring.

None of the stages add a feature. They change what the existing features do when something goes wrong, which is the only time an enterprise finds out what it bought.

Around those eight sit the things that are less about data and more about behaviour under pressure. Heavy actions are repeat-safe and capped. Uploaded files have their magic bytes sniffed, their size and count limited, and their hashes de-duplicated. And every mutation records where it came from, with what the server can verify kept separate from what the client claimed, because the two sometimes disagree and the disagreement is itself evidence.

4. The audit that read all zeros

Some rules come from the code lying to you. We had built phone layouts for the staff application, and an automated overflow audit that walked every screen at phone widths and counted anything clipped or scrolling sideways. The audit reported zero. Then I opened the application on an actual phone, in an ordinary browser, and called what I saw horrible, which was accurate. An icon rail squeezed into the margin, tiles cut in half, tabs overflowing, the bottom bar wrapping onto two lines.

The phone layouts had been written to switch on only inside the app shell, the embedded web view our mobile app wraps around the site. The audit ran in that mode. A real phone browser never entered it and got the desktop layout compressed to 360 pixels. The numbers were honest about the thing they measured and silent about the thing that mattered.

The fix was small: a phone class set before first paint at 760 pixels and below, with the layout rules hanging off app mode or phone, and only the hiding of our own chrome staying app-only. The rule that came out of it was not small. Test phones in a normal browser as well as in the app, run the audit on an iPhone SE, an iPhone 14 and a 360-pixel device as well as desktop and tablet, and then look at every screenshot rather than trusting the count. A metric that reads zero is either good news or a blind spot, and you cannot tell which from the metric.

5. Test like the client will: ten thousand workers

The most productive single day of the pass was the one where we stopped testing with the demo tenant. We built a synthetic tenant the size of the real one: ten thousand workers and engagements across three sites and ten contractors, seventy thousand attendance days, eighteen thousand punches, forty thousand gate events, plus the passes, grievances, incidents and exits that go with them. Deterministic, idempotent, about four seconds to generate. Then a re-runnable acceptance harness that hit all thirty-five list endpoints as three personas, a tenant administrator, a site-restricted HR user, and a contractor administrator logging in through a second factor, and scored each call on four things: under two seconds, limit clamped, paged, and no row outside the caller's scope.

The first run found what the demo tenant could never have found. A site-restricted HR user could see other sites' gate passes, and other sites' grievances, including safety grievances. The medical board endpoint materialised every one of the ten thousand engagements on each call, ignoring its limit, and took three seconds doing it. The gate-event log took over two seconds across forty thousand rows because the only composite index began with the site column rather than the time column. A messaging endpoint accepted a user-supplied limit with no cap at all. The unread badge on the alerts bell was computed from the sixty rows already loaded on screen rather than from the caller's actual backlog. The final run was 274 passes, no failures and 146 not-applicable, and every fix is a migration or a commit that a client can read.

That same synthetic-tenant work found the bug I think about most. While enrolling the contractor persona in multi-factor authentication, we discovered that the token-signing function picked its payload fields explicitly, and the flag marking a token as awaiting-second-factor was not one of them. The flag was silently dropped. The token issued before the one-time code was verified was already a full session, and the verification endpoint had never worked because it never saw a token in the pending state. Multi-factor authentication had been present, tested in the sense that the screens existed, and entirely bypassable. Nobody had logged in as the persona it was meant to protect.

A feature is not what the screen shows. It is what the server refuses.

6. Duplicates are a contract, not a validation

The first of the eight questions, can this be created twice, turned out to need the most design. An audit of roughly seventy transactional endpoints showed that “no duplicates” means three different things depending on who is submitting.

Machines replay. A gate device resends a punch, a message provider redelivers a webhook, a kiosk retries a form. Those endpoints are idempotent: the request carries or derives a de-duplication key, for a punch the worker, the direction, the minute and the device's own reference, and a replay returns the existing row marked as duplicate rather than creating a second one. The key is deliberately narrow so that a device reusing one reference across genuinely distinct events still records both.

Humans double-submit, and sometimes they mean it. A second grievance from the same worker on the same day is probably a double click, but it might be a second grievance. Those endpoints answer with a conflict, the existing identifier, and a flag saying a forced retry is allowed. The interface shows the server's message and a create-anyway button, and the forced create is recorded as forced in the audit. And some things are hard stops with no force at all: an active debarment, a pending pass request, the same challan twice in one wage run, an invoice that has already been paid. The one remaining race, two users claiming the same invoice number in the same instant, was closed inside a transaction.

The reason this is a contract and not a validation is that the interface has to know which of the three it is talking to. One error shape, one client helper, eleven create flows wired to it, and the behaviour is the same everywhere, which is the only way a rule survives the next engineer.

7. Two products, one identity

The presales prototype had its own login, its own users table, its own role model and its own database. For a company that already runs its people on our HRMS, that is a second identity for every manager and a synchronisation service nobody asked for. The hardening decision with the longest reach was to put the contractor system inside the client's existing tenant database, in its own schema with a least-privilege login that can select from a named list of HRMS tables and nothing else, and to make the HRMS role management the single role system for every internal user.

An HRMS role maps to a persona template on the contractor side, and the per-screen rights for that persona live in the existing permission matrix. Internal users arrive with the HRMS token, exchanged once for a scoped session, with a shadow user created just in time and the employee code carried through. External personas, contractor administrators, workers, auditors, keep a local login with a second factor because they have no identity in the HRMS to borrow. Employee masters, organisation units, holidays and shifts become joins and views instead of copies. Site scope derives from the HRMS level-access settings and fails closed. That decision deleted an entire integration layer. It is written up on its own in Two products, one tenant.

8. The fork at migration fifty-one

One more thing the audit found, by accident. Comparing the presales engineer's build with our own hardened build of the same system, the two repositories turned out to share one history, forked at database migration fifty-one. From there the presales engineer's migrations fifty-two to sixty-three were thirteen feature deltas: attendance notification rules, wage allocation, sourcing, shift inference, overtime rules, broadcasts, ERP milestones. Ours, with the same numbers, were the hardening track: invite codes over SMS instead of passwords shown on screen, statutory ledgers, disclosure tiers for personal data, shift actuals, the validation framework.

Which means any “take that feature from the presales build” request is a port, not a merge. Each one is renumbered to sixty-four and above, hand-merged where the shared modules diverged, and then put through the same gate as new work: translation rows for every string, the validation rules, and an acceptance sweep. Thirteen ports, each a day or two, each cheaper than the alternative of two products drifting apart with the same name on the door.

9. What vibe coding changes, and what it does not

In The bimodal developer I described two engineers given the same uploader spec and the same tools: one shipped the spec, the other shipped the audit log, the background job and the resume-from-failed-row that a real customer would need. The hardening pass is that second engineer's instinct written down, so that the result no longer depends on which engineer you happened to get. The prototype is what the literalist output looks like at the scale of a whole product. The pass is the thinker's checklist, made explicit, made mandatory, and attached to every specification we write: positive, negative and corner cases, the benefit to the client, performance, and abuse.

What the tools change is the economics. The validation sweep that applied shared format rules across sixty-four files took a day. The duplicate-entry contract across seventy endpoints took a day. The acceptance harness, the synthetic tenant and the fixes it demanded took a day. Two years ago each of those would have been a sprint, and the pass would have been argued down to the three items somebody could prove a customer had asked for. Now the whole pass costs less than the demo did, which removes the only honest excuse for skipping it.

What the tools do not change is which questions get asked. A model asked to add a feature will add the feature. A model asked whether this endpoint can complete silently will tell you, and fix it, and the quality of that answer is exactly the quality of the question. The engineer who never pauses the session to ask the eight questions gets a bigger prototype. That is the same lesson as the PDF page-size story in the bimodal piece, now operating at the level of a product rather than a bug.

10. The honest open question

How much of the pass can become a linter. Some of it already has. Secrets are scanned on every commit. The format validators are shared between the two layers, so a field cannot accept on the client what the server would refuse. Those are the parts of the pass that are about form.

The eight questions are about meaning, and I do not yet have a static check for “this step can complete silently” or “this cache is not keyed by scope”. The acceptance harness is the closest thing: it is a behavioural test that encodes the scope and paging questions and runs against data shaped like the client's. Whether the rest of the questions can be encoded the same way, or whether they stay a discipline that has to live in people, is the thing I want to know by the time the next prototype lands. Because another one will. That is not a problem to prevent. It is the way ideas get proven now.

The prototype proves the idea. The hardening pass is where the product is born.

The origin of this way of working is in What vibe coding taught me about enterprise UX; the people side is in The bimodal developer; and the engineers who sit with the client while the pass happens are described in Forward Deployed Engineers.

Turning a prototype into something an enterprise will run, or deciding what your own hardening pass should contain? Let's talk.

More writing