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:
- 150,000 reads per second of the seat map, if all of them reach the database.
- Contention: hundreds of people clicking the same front-row seat in the same second; only one may win.
- 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:
- Seat inventory holds the seats (conditional update,
held_until= now + 10 minutes). - Orders creates a
pendingorder, with the amount as an integer number of dong, never a floating-point number. - The user is sent to the payment gateway.
- The gateway calls back a webhook with the result, and the user is redirected back.
- Payments records it, seat inventory moves the seats to
sold, orders topaid, and anOrderPaidevent is emitted. - 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 → freeon 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.mdabove: 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.mdsays "don't invent other claims";docs/conventions.mdsays "Never call blocking I/O directly insideasync def". One clear prohibition works better than ten vague recommendations. - The same name everywhere. A "hold" is
holdin the docs, the code and the table names, notreservein one place,lockin another andbookingin 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:
- Reads
CLAUDE.md(automatically), then the index. - Opens
state-machines.md: tickets gain the transitionissued → transferred, and the recipient's new ticket starts atissued. Openscheck-in.mdto learn how the cancelled-ticket list reaches the check-in devices, and why the 2-hour lock is needed. - Reads
glossary.mdand uses "transfer" (transfer), without inventing a new word. - Imitates the example module's structure, puts the feature in the
ticketsmodule, and callsordersthrough its public function to check the 4-ticket limit. - 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.
- Writes code until tests, lint and concurrency tests all pass.
- 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,
paidorders 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.