Designing a multi-service ticketing system whose code AI writes: what people decide, what AI needs to read

A concert ticketing system: seat selection, holds, payment, tickets, notifications, gate check-in, an on-sale waiting room. What people decide, how to split services, measuring double-sold seats and repeated payment webhooks, and writing docs AI can extend features from.

The previous post went through the building blocks of a system: estimates, caches, databases, queues, handling failure. The last post of the series puts them together, with a present-day condition: most of the code is written by AI agents. Not suggestions line by line, but an agent reading the repo, planning, editing many files, running tests, then extending the system over hundreds of working sessions.

The first question sounds philosophical but is very practical: if AI writes the code, what do people still do? This post's answer: people do the things code can't infer by itself. They set out the problem, the constraints and the business rules. They make the decisions that are hard to reverse. They supply the context. They define what "correct" means and verify it. And one constraint governs all of it: an agent remembers nothing between sessions. Everything it knows about the system is what's in the repo and in the prompt. In other words, documentation is the system's memory.

The running example is an event ticketing system: fans pick seats for a concert, hold them for a few minutes, pay, receive an e-ticket, then get checked in at the stadium gate. It sounds like a small online shop, but it gathers almost every hard problem in system design in one place: thousands of people fighting over the same seat, real money passing through a third party that can report late or report twice, an on-sale day with hundreds of thousands of times the traffic of an ordinary day, and gates that must keep working when the stadium's network is jammed. This is a hypothetical design for illustration, not the real architecture of any product.

The route: what only people can provide, estimates (ordinary days and on-sale day), splitting the system into blocks and deciding whether to make them microservices, the core that mustn't be wrong (no seat sold twice), payments and webhooks, on-sale day with a waiting room, check-in without a network, which decisions belong to people and which to AI, the context for the agent (measured on this site's own repo), writing documentation AI can understand, extending a feature, verification, then dividing the roles between people and AI.

The two contention experiments were really run, with Python 3.12 and SQLite, several threads writing to the same database at once; token counts were made with the o200k_base tokenizer on this site's repo. Load figures are estimates, and are marked as such.

What only people can provide

Agents are good at reasoning from what they can read. But some things aren't in any file, because they live in the client's head, in the contract with the event organizers, in the law. Without them, an agent still writes code, but code for a product it guessed. The brief:

# Ticketing system v1: brief

## Goal
Sell reserved-seat tickets for concerts and large events; fans get e-tickets, checked in by QR code.
Success = sell out a 50,000-seat concert without the site going down or any seat sold twice.

## Users and scale
Ordinary days: a few thousand page views a day. On-sale day for a big concert: ~300,000 people
in the first 10 minutes. Organizers: a few dozen, creating events in an admin portal.

## Business rules
- At most 4 tickets per account per event.
- Seats are held for 10 minutes from selection; unpaid holds are released.
- First into the waiting room, first to buy.
- Refunds: only when an event is cancelled, 100% within 7 days.

## Constraints
- Payment through two domestic payment gateways (domestic cards, e-wallets); never store card numbers.
- An electronic invoice for every order.
- Team: two engineers plus agents; first release in 3 months, before the first concert.

## Acceptance criteria (measurable)
- 0 seats sold to two people; 0 orders charged without tickets (tests plus daily reconciliation).
- On-sale day: the site stays up; the waiting room shows each person's position; checkout p95 < 2 seconds.
- Gate check-in works without a network, and each ticket gets in only once.

## Not in v1
A resale marketplace, demand-based pricing, a separate mobile app (the web on phones is enough).

Each section answers a question an agent can't answer correctly on its own:

  • Goal and success criteria: without them, every trade-off becomes a guess.
  • Business rules: how long seats are held, how many tickets each person may buy, who buys first, when refunds happen. These numbers and rules are decided by the organizers, not by engineers or agents. An agent lacking them will choose "hold for 15 minutes" because it sounds reasonable, and nobody notices until the organizer contract says 10.
  • Constraints: which payment gateways, electronic invoices, team size, deadlines. An agent doesn't know which gateways your company has signed with.
  • Measurable acceptance criteria: "no double selling" and "no lost money" have to become tests and a reconciliation that run.
  • Non-goals: agents tend to do extra to be "complete"; a "not doing" list stops that at the root.

This site's repo has exactly this kind of document: PRODUCT.md records durable facts about the product, with one heading well worth copying: "Offer (as stated by the owner; don't invent other claims)". Business facts are supplied by people, and the AI is told plainly not to make up more.

Estimates: ordinary days and on-sale day

This system has two faces, and the skills from the previous post apply to each (all of these are estimates):

Ordinary day:  a few thousand views / day                ≈ under 1 request / second
On-sale day:   300,000 people in 10 minutes              ≈ 500 new people / second
               each viewing the seat map, refreshing every 2 seconds
               → 300,000 ÷ 2                             ≈ 150,000 reads / second

On-sale day is hundreds of thousands of times an ordinary day, for a few dozen minutes. But look more closely at the writes:

50,000 seats ÷ ~2.5 tickets per order                         = 20,000 orders
selling out in ~20 minutes                                     ≈ 1,000 orders / minute ≈ 17 orders / second
a few hold attempts per order (seats taken by someone else)    ≈ 50 writes / second

17 orders per second is something a Postgres database handles comfortably. The problem on on-sale day isn't heavy writing; it's three other things:

  1. 150,000 reads per second of the seat map, if all of them reach the database.
  2. Contention: hundreds of people clicking the same front-row seat in the same second; only one may win.
  3. 300,000 people for 20,000 orders: most won't get tickets, and the system has to say so honestly and early, instead of letting them wait and then showing an error.

Each problem gets its own block in the sections below: caching for the reads, conditional updates for the contention, a waiting room for the crowd.

The system's blocks, and whether to make them microservices

Listed by responsibility, each block is one job the system has to do:

Block Responsibility Data Load profile
CDN + bot protection Serve static pages and cached seat maps, block bots Carries most of the 150,000 reads
Waiting room Queue people at on-sale, admit them at a set pace Redis Busy only on on-sale day
Accounts Registration, sign-in with a one-time code by phone Postgres Light
Events Events, venues, seating plans, prices by section Postgres, cache Read-heavy, rarely changes
Seat inventory Each seat's state: free, held, sold Postgres The core that mustn't be wrong, high contention
Orders The basket, time-limited holds, orchestrating the whole purchase Postgres Moderate writes
Payments Talking to the payment gateways, receiving webhooks, reconciliation Postgres The outside world is slow and may report twice
Ticketing Signed e-tickets, QR codes Postgres, file storage After payment
Notifications Email, SMS, push messages Queue Background, tolerant of delay
Check-in A QR scanning app at the gates, working offline A copy on the device A burst before showtime
Organizer admin Create events, see revenue, export reports Read replica Light
Invoices, reports Electronic invoices, statistics Queue, replica Background

Between the blocks sits an event bus: OrderPaid, TicketIssued, EventCancelled... A completed payment doesn't call notifications or invoicing directly; it emits an event, and whoever needs it listens (Observer at the scale of a system, as in the design patterns post).

Twelve blocks don't mean twelve separately deployed services. Each service split off is one more thing to deploy and monitor, called over the network (thousands of times dearer than a function call, by the previous post's latency table), and it loses the ability to wrap several changes in one database transaction. With a team of two engineers, the practical approach is a modular monolith: one application, split into clear modules exactly as in the table, each module owning its own tables and talking to other modules only through public functions or events, in the spirit of SOLID. Only blocks with their own operational reason become separate services:

  • The waiting room: it must take 500 new people per second without dragging down everything else; it runs separately, possibly at the network edge.
  • Notification and invoice workers: background work that may be slow and mustn't compete with the purchase flow for resources.
  • Check-in: it runs on devices at the gates, so it's already a separate application by nature.

Clear module boundaries today are what let you split out services tomorrow, when the numbers demand it. This is exactly the kind of one-way-door decision people must make (next sections).

The core that mustn't be wrong: no seat sold to two people

The most natural way to write a seat hold, and the way a hasty agent might write it, is read, then write:

status = db.execute("SELECT status FROM seats WHERE id = ?", (seat,)).fetchone()[0]
if status == "free":
    ...  # check the 4-ticket limit, work out the price
    db.execute("UPDATE seats SET status = 'sold' WHERE id = ?", (seat,))
    db.execute("INSERT INTO orders (seat_id) VALUES (?)", (seat,))

This code passes every sequential test. The experiment: 50 seats, 400 purchase attempts aimed at random seats, 8 threads running at once against the same SQLite database. Then compare with a conditional update, merging "check" and "write" into one statement:

cur = db.execute("UPDATE seats SET status = 'sold' WHERE id = ? AND status = 'free'", (seat,))
if cur.rowcount == 1:  # exactly one caller's statement changes the row
    db.execute("INSERT INTO orders (seat_id) VALUES (?)", (seat,))
Approach Work between read and write Orders (3 runs) Seats sold to two or more people
Read, then write 1 ms (check limits, price) 65, 68, 61 14, 12, 10
Read, then write nothing 54, 57, 57 4, 6, 7
Conditional update 50, 50, 50 0, 0, 0

With just 50 seats and 8 threads, read-then-write oversold by 11–18 orders, and even with nothing in between, still by 4–7. The reason: between one person reading "seat free" and writing "sold", someone else reads "seat free" too. On a real on-sale day, with hundreds of people clicking the same seat, that gap isn't rare; it's certain.

The conditional update gave exactly 50 orders for 50 seats, all three times. The database guarantees only one statement can change that row from free to anything else; whoever comes second sees rowcount = 0 and is told "someone just took that seat". Equivalent approaches: SELECT ... FOR UPDATE to lock the row inside a transaction, or a UNIQUE (event_id, seat_id) constraint on the tickets table. What matters is that the database is the only referee, not application code, not a copy of the state in a cache.

The 10-minute hold adds a state: a held seat with held_until. The hold statement accepts both free seats and seats whose held_until has passed (WHERE status = 'free' OR held_until < now()), so abandoned seats return by themselves, without waiting for a cleanup job to run on time.

Payments: money passing through a third party

The purchase is a chain of steps across several blocks and an outside system:

  1. Seat inventory holds the seats (conditional update, held_until = now + 10 minutes).
  2. Orders creates a pending order, with the amount as an integer number of dong, never a floating-point number.
  3. The user is sent to the payment gateway.
  4. The gateway calls back a webhook with the result, and the user is redirected back.
  5. Payments records it, seat inventory moves the seats to sold, orders to paid, and an OrderPaid event is emitted.
  6. Ticketing creates signed tickets; notifications sends email and SMS; invoices issues the electronic invoice.

Every step can fail, and the design has to answer each case in advance:

What happens What to do
The webhook arrives two or three times for one transaction Process idempotently by the gateway's transaction ID
The user pays, but the webhook arrives after the 10-minute hold expired If the seats are still free: sell; if someone else has them: refund automatically, explain clearly
The webhook never arrives Poll the gateway on a schedule; reconcile at the end of the day
The order is written as paid, then the machine dies before OrderPaid is emitted Outbox: write the state and the event in the same transaction

The second experiment measures the first case, because it's more common than people think: gateways resend webhooks when they don't get a response fast enough. 1,000 orders, each reported 1–3 times (1,611 deliveries in total), 8 threads processing at once. The first approach checks the order's status before issuing tickets, which sounds safe already:

if db.execute("SELECT status FROM orders WHERE id = ?", (order_id,)).fetchone()[0] == "paid":
    return
...  # mark as paid, issue the ticket

The second records the transaction ID in a table with a primary key, and only issues tickets if that row is new:

cur = db.execute("INSERT OR IGNORE INTO payments (txn_id, order_id) VALUES (?, ?)", (txn, order_id))
if cur.rowcount == 1:  # first time we've seen this transaction
    ...  # mark as paid, issue the ticket, in the same transaction
Approach Tickets issued for 1,000 orders (2 runs) Orders with two or more tickets
Check the status, then write 1,003 and 1,001 3 and 1
Idempotent by transaction ID 1,000 and 1,000 0

The status check is almost right: 1–3 extra tickets per 1,000 orders. Being almost right is what makes it dangerous: sequential tests are always green, a few trial runs are green too, and the bug only shows up on on-sale day, as a few duplicate tickets at the gates. The same lesson as the seat experiment: check-then-write is two steps, and between the two steps is a gap.

Finally, reconciliation: every night, a job downloads the gateway's transaction report and compares it with the payments table. Money without a ticket, a ticket without money: alert a person. Reconciliation is the safety net for every case the steps above miss, and the only way to make "0 orders charged without tickets" a number you can check rather than a promise.

On-sale day: the waiting room and caching

Three problems on on-sale day, three solutions:

150,000 reads per second of the seat map. The seat map is built as one JSON file per section, cached at the CDN for 1–2 seconds. A user might see a seat as "free" that was taken a second ago; that's fine, because the map is only for choosing, and the final decision is the conditional update in seat inventory. It's a deliberate consistency choice (previous post): the display accepts being slightly stale, the sale is strictly consistent. The origin only rebuilds each file about once a second, instead of 150,000 times.

300,000 people for 20,000 orders. A virtual waiting room: everyone arriving at on-sale gets a place in the queue (random for those who arrived before opening, in arrival order after that, following the brief's "first in, first to buy"), then is admitted to the purchase pages at a fixed pace. The pace comes from Little's law in the previous post:

each person spends ~5 minutes in the purchase pages (choose seats, pay)
we want at most ~5,000 people in the purchase pages at once
→ admission pace = 5,000 ÷ 5 minutes = 1,000 people / minute

At that pace, tickets sell out in about 20 minutes, and the person in position 100,000 is almost certainly not getting one. A good waiting room says so honestly: it shows the position and an estimated wait, and announces "sold out" as soon as the inventory reaches zero, instead of keeping 280,000 people waiting for an hour.

Bots and scalpers. A 4-ticket limit per account, accounts verified by phone number, rate limits per account and per IP, bot checks at the CDN. No method stops all of it; the goal is to make bots cost more than they earn.

And before every big on-sale: a load test with that exact shape (a few hundred thousand virtual people entering the waiting room, a few thousand buying at once and fighting over the best seats), on an environment like production. Estimates only tell you what to test; the load test tells you whether the system holds.

Check-in without a network

Fifteen minutes before showtime, 50,000 people converge on the gates, and the mobile network around the stadium is usually jammed exactly then. Check-in can't depend on asking a server for every scan.

  • Digitally signed tickets: the QR code contains the ticket ID, event, section, and a signature made with the system's private key. The check-in app holds the public key, so it can tell a real ticket from a fake without a network.
  • Download the list of valid tickets to the devices before the gates open, along with tickets that were cancelled or transferred.
  • Each ticket gets in once: each device remembers what it has scanned and syncs with the other devices when there's a network. With the network completely down, two different gates could each admit the same screenshotted ticket for a few minutes. That's a consistency trade-off that must be written down and accepted by the organizers, with mitigations (each section goes through its own group of gates, so a section-A ticket scanned at a section-B gate is suspicious straight away).

Which decisions are people's, which are AI's

Not every decision needs a person. A useful split is one-way and two-way doors: decisions that can be reversed cheaply (variable names, how functions are split, small utility libraries) are two-way doors, left to the agent within the repo's conventions. Decisions that are very costly to reverse, or that involve money and contracts, are one-way doors, and people must make them:

Decision Kind Who decides
Business rules: hold time, ticket limits, refunds, waiting-room order A contract with the organizers People (in the brief)
Modular monolith or microservices, which blocks to split out One-way: shapes the whole team and infrastructure People, after the AI proposes
Payment gateways, SMS provider, electronic invoicing One-way: contracts, integrations People
How double selling is prevented (the database as referee) One-way: getting it wrong costs money and reputation People, on the AI's proposals and experiments
The trade-off for offline check-in Must be accepted by the organizers People
The data model (seats, orders, tickets, payments) One-way: real data will live in it People, after the AI proposes
Structure inside one module Two-way AI, within conventions
Function names, splitting functions, tests for each Two-way AI, reviewed by people

For one-way doors, an effective process is: the AI proposes and tries, a person chooses, it gets written down. Agents are very good at listing options, and at writing an experiment like the two above on the spot to compare them. People are good at weighing what isn't in the numbers: what organizers will tolerate, how many moving parts the team can operate, which risks are acceptable. Once a decision is made, it must be recorded, because the agent won't remember next session. The usual form is an ADR (architecture decision record), one short file per decision:

# ADR-003: Seat state lives in Postgres, changed by conditional updates

Status: accepted (2026-10-17)

## Context
On on-sale day, hundreds of people fight over the same seat. Criterion: 0 seats sold to two people.
Experiment: docs/experiments/seat-race.md (read-then-write oversold 11–18 orders on 50 seats).

## Decision
One row per seat in the `seats` table. Every state change goes through `inventory.hold()`,
`inventory.sell()`, `inventory.release()`, each a single UPDATE conditional on the current
state; `rowcount` decides who wins.

## Consequences
+ The database is the only referee; correct however many application servers there are.
+ ~50 writes per second at peak, comfortably within one Postgres.
- The seat map users see can be 1–2 seconds stale (cached), which is acceptable.

## Considered
Counters and locks in Redis: faster, but a second source of truth to keep in step with
the database; losing Redis mid-on-sale loses the holds.
Distributed locks: one more system to operate for a problem the database already solves.

The "Considered" section matters as much as the decision. Without it, six months later an agent asked to "speed up seat holds" will propose moving seat state into Redis, with very convincing arguments, and nobody will remember why it wasn't chosen.

What the agent's context contains

An agent knows only what's in its context window, and the post on the harness measured that every token in it costs money and time on every turn. Good context isn't the most context; it's exactly what's needed, at the right moment. It comes in layers:

Layer Content When it's read
Root instructions file (CLAUDE.md, AGENTS.md) What the repo is, test and lint commands, key conventions, prohibitions, a pointer to the docs index Every session, automatically
Docs index (docs/README.md) One line per document: name and content At the start of a job
Topic documents Architecture, modules, state machines, business rules, ADRs When a job touches that topic
Example code One module done "properly", to imitate When writing a similar module
Tests and check commands A runnable definition of "correct", concurrency tests included After every change
The spec for the current job Goal, acceptance criteria, non-goals Start of the session, written by a person
Tools (MCP, CLIs) Run load tests, read staging logs, see CI failures When needed, through tool calls

The repo of the site you're reading is organized exactly this way, and counting tokens shows why the index layer matters:

Part of the repo Files Tokens
The index docs/README.md 1 488
One topic document, e.g. docs/conventions.md 1 1,107
All documentation (docs/, PRODUCT.md, DESIGN.md, README.md) 15 23,231
All Python code (src/) 52 29,866
Tests (tests/) 16 15,816
HTML templates 43 21,411

An agent asked to change the caching reads the index (488 tokens), sees the line "caching.md: What Cloudflare and browsers cache...", then opens just that file, instead of loading all 23,000 tokens of documentation. That's progressive disclosure, the same principle as skills in the harness post. A small repo like this site's, code and docs together, comes to about 90,000 tokens, still within a large context window. A ticketing system with twelve modules after two years can be ten or a hundred times bigger, and then a good index is the difference between an agent finding the right module and an agent editing the wrong place.

The root instructions file should be short: it's read in every session, so every extra line is a cost repeated forever. For a system that handles money, the "Never" section matters most:

# Ticketing system

Reserved-seat event ticketing. Product and business rules: PRODUCT.md. Docs: docs/README.md (read the index first).

## Commands
- Tests: `uv run pytest`  ·  Lint: `./scripts/lint.sh`  ·  Concurrency tests: `uv run pytest -m concurrency`
- Every change must pass tests and lint; changes in `inventory/`, `orders/`, `payments/` must also pass the concurrency tests.

## Conventions
- One package per module in `src/`, owning its own tables; other modules only call public functions
  in `<module>/api.py` or listen to events. See docs/modules.md.
- Example module to imitate: `src/events/`.
- Architecture decisions live in docs/adr/; don't go against an ADR without asking.

## Never
- Change seat state outside `inventory.hold()`, `inventory.sell()`, `inventory.release()`.
- Write read-then-write for anything contended; use conditional updates or UNIQUE constraints.
- Handle a payment webhook without idempotency on the gateway's transaction ID.
- Use floating point for money; money is an integer number of dong.
- Call real payment, SMS or invoicing services in tests; use the fakes in tests/fakes/.
- Change business rules (hold time, ticket limits, refunds) without a request recorded in PRODUCT.md.

The line "Example module to imitate" is worth more than pages of guidance: the clean code post showed that agents imitate the code around them. And the line "Change seat state outside inventory.hold()..." turns the decision in ADR-003 into a checkable rule: one lint command can find every UPDATE seats outside the inventory module.

Writing documentation AI can understand and extend

Documentation written for agents is also good documentation for people; its faults just show up faster: a person reading a wrong document asks a colleague, an agent follows the mistake exactly. A few rules:

  • Durable facts, not a diary. "Seats are held for 10 minutes, set in src/inventory/config.py, per the organizer contract" rather than "last week we agreed that...". Tables beat paragraphs when the content is a list of facts.
  • State machines written as tables. Seats (free → held → sold, held → free on expiry), orders (pending → paid → refunded, pending → expired), tickets (issued → checked_in, issued → transferred). One row per transition: from where, to where, who may do it, under what condition. This is the kind of document agents use best, because it turns into code and tests almost directly.
  • One topic per file, with an index. Like docs/README.md above: an agent finds the right file from a one-line description.
  • Record why, not just what. The code already says "what". Documents and ADRs keep "why" and "what was considered": that's what stops an agent "optimizing" a deliberate decision back the other way.
  • State prohibitions and boundaries plainly. This site's PRODUCT.md says "don't invent other claims"; docs/conventions.md says "Never call blocking I/O directly inside async def". One clear prohibition works better than ten vague recommendations.
  • The same name everywhere. A "hold" is hold in the docs, the code and the table names, not reserve in one place, lock in another and booking in a third. Agents search by words; inconsistent names make documentation unfindable. A short glossary (docs/glossary.md) solves this.
  • Update the docs in the same change. Put it in the definition of "done": a new feature updates the related docs, a new decision adds an ADR. Wrong documentation is worse than none, so when a section stops being true, delete it.

The repo's documentation tree, a few months in:

CLAUDE.md             instructions for agents, short, read every session
PRODUCT.md            the product and business rules: holds, ticket limits, refunds, waiting room
docs/
  README.md           the index: one line per file
  architecture.md     the blocks, the purchase flow, events between modules, which services run apart
  modules.md          each module: which tables it owns, its public functions, the events it hears
  state-machines.md   seats, orders, tickets, payments: every state transition
  payments.md         webhook flow, idempotency, refunds, reconciliation
  on-sale.md          waiting room, seat-map caching, bot protection, the load-test script
  check-in.md         signed tickets, working offline, the trade-off that was accepted
  experiments/        seat-race.md, webhook-duplicates.md, ... (commands, results, dates run)
  glossary.md         event, section, seat, hold, order, ticket, transfer...
  adr/                001-modular-monolith.md, 002-postgres.md, 003-seat-state.md, ...

Extending a feature: ticket transfers

After the first concert, the most requested feature is transferring a ticket to a friend. A person writes the spec, short but with everything the agent can't infer:

# Feature: ticket transfers

## Goal
A ticket holder transfers a ticket to another account, free of charge, before the show.

## Acceptance criteria
- The recipient needs an account with a verified phone number; the 4-ticket limit counts received tickets.
- After a transfer, the old QR code no longer opens the gate; the recipient gets a new one.
- Transfers lock 2 hours before the gates open (so the check-in lists can update in time).
- Two simultaneous requests to transfer the same ticket: exactly one succeeds (with a concurrency test).

## Not doing
Paid resale, splitting a multi-ticket order among several people at once.

Then the agent works, and each step relies on a layer of context prepared in advance:

  1. Reads CLAUDE.md (automatically), then the index.
  2. Opens state-machines.md: tickets gain the transition issued → transferred, and the recipient's new ticket starts at issued. Opens check-in.md to learn how the cancelled-ticket list reaches the check-in devices, and why the 2-hour lock is needed.
  3. Reads glossary.md and uses "transfer" (transfer), without inventing a new word.
  4. Imitates the example module's structure, puts the feature in the tickets module, and calls orders through its public function to check the 4-ticket limit.
  5. Writes tests first for the acceptance criteria, especially the concurrency test: two threads transferring the same ticket, exactly one wins; and the old ticket rejected by a simulated scanner. The SMS gateway is a fake, just like the fakes in the SOLID post.
  6. Writes code until tests, lint and concurrency tests all pass.
  7. Updates state-machines.md, check-in.md, glossary.md, and proposes ADR-011: why transfers lock 2 hours before the gates open.

The person reviews the diff, the state machine and the ADR, without typing code. The review question isn't "does the code run" (the tests answered that), but "is this what I wanted, and is there a gap": can a recipient get around the 4-ticket limit by receiving from several people, does a transferred ticket still sit in an old email with a valid QR code?

Verification: defining "correct" with things that run

Agents write code many times faster than people, so the bottleneck moves to verification. A system written by AI needs layers of verification that don't require a person to read every line:

  • Concurrency tests for everything contended: seat holds, payments, transfers. The two experiments in this post are exactly that kind of test: many threads, one database, then check an invariant (orders never exceed seats, one ticket per paid order). Sequential tests never catch this kind of bug.
  • Invariant checks in production: a query run every minute that counts seats with two tickets, paid orders without tickets, tickets without orders. The result must always be 0; anything else is an immediate alert.
  • Daily payment reconciliation against the gateways' reports.
  • Load tests before every big on-sale, with the real waiting room, caching and purchase flow on an environment like production.
  • Hard fences for things that must never happen: hooks that block agents from running commands against the production database or reading files with the payment gateways' keys (the harness post), and the ticket-signing key kept only in the secret store.

Dividing the roles between people and AI

Work People AI agents
Problem, goals, users Provide them Ask about what's ambiguous
Business rules: holds, ticket limits, refunds Provide them (from the organizer contract) Turn them into state machines, code and tests
Constraints: payment gateways, invoicing, team, deadlines Provide them Work out the consequences (estimates, waiting-room pace)
One-way-door decisions Choose and own them List options, write comparison experiments, write the ADR
Two-way-door decisions Set the conventions Decide within the conventions
Code, tests, migrations Review the diff Write, run, fix until they pass, concurrency tests included
Documentation Write the product, business-rule and "never" parts Keep the state machines and technical parts in step with the code
Verification Define "correct" (invariants, criteria), watch reconciliation and numbers Run tests and load tests, report the real results

Summary

Key idea In the ticketing system
Documentation is the system's memory Agents remember nothing between sessions; all they know is in the repo
People provide what code can't infer Goals, business rules, constraints, measurable criteria, non-goals
On-sale day isn't a write problem ~17 orders/second, but ~150,000 reads/second and hundreds fighting over one seat
Many blocks, few services 12 blocks in a modular monolith; only the waiting room, background workers and check-in split out
The database is the referee Read-then-write oversold 11–18 orders on 50 seats; conditional updates: exactly 50
Idempotent payments A status check still issued 1–3 extra tickets per 1,000 orders; by transaction ID: exactly 1,000
On-sale day Seat maps cached for 1–2 seconds, a waiting room at 1,000 people/minute by Little's law, load tests
Offline check-in Signed tickets, lists downloaded in advance, a trade-off accepted by the organizers
One-way doors for people, two-way doors for AI Business rules, architecture, vendors chosen by people; recorded as ADRs
Layered context, progressive disclosure A 488-token index first, then just the document needed, instead of all 23,000
Extending features People write the spec and review; agents read the state machines, imitate, write concurrency tests, update the docs
Verification Concurrency tests, production invariants, reconciliation, load tests, hard fences

This series went from a 26-line function to a system whose code is mostly written by AI, but the question at every level is the same: which change is coming, and which structure makes it cheap? At the level of a function, the answer is clear names and small functions. At the level of classes, SOLID and a few patterns. At the level of a system, measure first, estimate first. And when the one writing the code is an AI, one more thing: whatever people know but don't write down, as far as the system is concerned, doesn't exist.