Buyers serve themselves, and the ERP stays in charge
Sparta was built for a Ukrainian distributor of stationery, household goods and tools. Its wholesale buyers bought through managers, and much of the managers’ time went to routine questions such as “when will my order ship?” The plan was a platform where buyers serve themselves. They buy what is on the shelf, book goods from shipments still on the way and pre-order from the next container from China. The managers would move from routine to developing clients. The product’s founding document put it plainly: they are freed, not let go.
The company’s ERP, its accounting and stock system, had to stay the authority for stock, prices, customers, settlements and exchange rates. Sparta’s job was to make those facts usable online, and to account for everything that had happened since the ERP last spoke: orders placed, goods held, changes not yet recorded.
My partner brought the warehouse, the business processes and the imports. A separate developer owned the ERP’s side of the exchange. Everything else was mine: the product and its domain model, the storefront and the operations app, TOR, down to their pixels, the API and the database, my half of the exchange and its contract, delivery, and the process for the coding agents I built it with.
The pictures on this page come from an anonymized demo copy of the database made in October. The catalog, photos and stock are real, prices are distorted, and the people are fictional, with stock portraits from Unsplash.
Agents scaffold in hours, subtraction takes months
The first three days showed what agent speed costs. On 31 March the agents laid down a monorepo, a Fastify API and PostgreSQL with ParadeDB. By the night of 2 April, commits named “Phase 0” to “Phase 7” had added a Next.js storefront, pre-orders, translations, SEO, an Excel export, a “FOMO” feature, an admin hub and a Telegram bot with retrieval. A Strapi CMS arrived at 01:46 on 1 April and was removed the next night. Everything more or less worked, and almost none of it had been decided.
Some first-day choices held. ParadeDB adds a BM25 search engine to PostgreSQL, so search, filters and facet counts read the same rows, in the same transaction, as the rest of the system, with no search service to keep in sync. Fastify kept every business rule, which made the next move cheap: a month in, the storefront left Next.js for TanStack Start, and nothing behind it changed.
TanStack Start on Bun, server-rendered, installable
The operations app: React 19, Vite, 87 screens
Permissions, orders, prices, stock holds, the exchange
Data, BM25 search and facets in one transaction
34 background functions and retryable delivery
18 inbound streams, 5 outbound
Most of what came after was subtraction. On 2 August I made it a rule: until the first launch there is no product legacy. A field, alias or fallback that no real user, producer or data had ever relied on is deleted, not carried through a transition period.
The clearest case was a brand that existed only in the schema. A storefront column ran through 10 tables, ready for a second brand on the same backend, and the pricing documents described its tiers. Before deciding anything, on 30 July I had it measured. The second brand had zero prices, zero orders and no entry point that could reach it; its only two accounts were test fixtures. It wasn’t even an independent axis: each pricing tier belonged to exactly one storefront, so the “brand” was a second name for a tier. Of about 700 references, 13 changed behaviour. The rest was plumbing.
The two applications share eight workspace packages, among them the design system and the table engine, which have case studies of their own: Sid-Kit and Data Grid.
Under the hoodThe platform in brief
- API. Fastify 5 with about 400 routes, Drizzle on the
postgresdriver, better-auth, AJV schemas and Inngest for jobs. Bun in development, Node 24 in production. - Data. ParadeDB on PostgreSQL 18: 162 tables and 359 migrations in one schema. Redis only for phone-login rate limits, S3-compatible storage for media.
- Storefront. TanStack Start on
Bun.serve, in Ukrainian, Russian and English, with 1,879 message keys in each locale. - TOR. React 19, Vite, React Router 7 and TanStack Query: CRM, orders, procurement, catalog, photos, AI translation, content, analytics and the exchange, 87 screens and 64 navigation items.
- Search. A generated column with names in three languages, SKU and MPN; terms expanded for a wrong keyboard layout and Latin–Cyrillic transliteration; exact barcodes; trigram suggestions for typos.
- Facets. Counts are disjunctive, each attribute counted without its own selection. One pass replaced 31 queries, from about 260 ms of database time per render to about 37 ms, and JIT is off for the whole database after it cost one query 286 ms. Development data of about 8,800 products, July 2026; there was never production traffic.
- Accounts. A person, the company in the ERP, a buyer workspace and the membership between them, with 13 scoped capabilities. A partner’s clients get child workspaces with at most 8, and every change re-checks the acting context under lock.
- Sign-in. Sparta never reveals whether an email or phone is registered before the person proves control of it: an unknown phone gets a decoy challenge identical to a real one.
- Live updates. Server-sent events carry only product IDs after commit; the browser coalesces a burst for 50 ms and re-reads in the buyer’s own context.
- Size. About 970,000 lines of TypeScript, more than 40% of them tests, and 78 decisions recorded as ADRs.
Availability is an action, not a number
Stock on the shelf, goods in transit and a future shipment are different promises, so a buyer should see what they can do with a product rather than decode a figure. I reduced those situations to one action per product, in priority order.
- Stock availableBuy
Otherwise, check incoming goods
- Incoming availableBook
Otherwise, check whether pre-orders are allowed
- Pre-orders allowedPre-order
Otherwise, the product is unavailable
- None of theseStay visible
The product stays in the catalog, marked unavailable
Each action has its own cart, and pre-orders have their own balance and statuses. A product that becomes unavailable stays in the catalog, so its page keeps its place in search and still captures demand.
Behind the action are three layers. The ERP supplies the stock facts, and Sparta never edits them. Sparta records its own holds as append-only events. The quantity a buyer can order is the ERP’s figure minus the holds the ERP hasn’t absorbed yet.
The hard case falls between the two systems. A buyer orders the last units, but the next stock snapshot still shows them available. A hold counts as absorbed only when the ERP document is older than the snapshot’s own timestamp, so the order in which messages arrive doesn’t matter. An unknown state never releases stock: ambiguous cases go to an operator. Order creation is one database transaction. It claims the request’s idempotency key, locks and rechecks the cart and availability, then writes the order, the revision requested from the ERP and the outgoing exchange record. About 70 tests cover concurrency, from parallel orders to a doubtful hold that must stay in place.
The examples became the contract
By late July the plans for the exchange had stopped converging. Four rounds of review on two slices produced more blockers each time, not fewer. The schema, the field inventory, the tests and the guards each claimed in turn to be the source of truth, and each contradicted another. A closer look showed why nothing settled. Every golden test ran in dry-run mode, which returned before the handler, so the examples proved nothing. And the guard that demanded golden updates could be satisfied by editing a single receipt ID.
On 26 July I decided what was true. The golden JSON examples are the contract: not illustrations, not fixtures. Everything else is derived from them or checked against them. They have three readers, which is what lets them be the authority. I read them to see how the exchange really runs, the ERP developer builds their half from them, and agents derive what nobody reads directly: the schemas, the tests and the internals.
- A request goldenHand-written JSON, exactly as the ERP will send it
- The real routeSignature, schema, journal, adapters, the database
- Deep comparisonAgainst the committed response golden
- Everything derivedSchema, OpenAPI and TOR examples must agree
A semantic change to a golden stops the push until I approve that exact content for that environment.
That made the examples my material. I read them the way I read a screen, and edited them field by field. On the morning of 30 July I went through the price and product goldens:
The golden still has no MPN, the manufacturer’s optional part number. It needs adding. And there are no barcodes. What else is missing? A valid_from on every price makes no sense: the ERP guarantees freshness at the moment of export, for the whole export.
Within the hour I removed IDs the ERP had no reason to store, renamed a field so its meaning was obvious (order_qty_multiple, the multiple an order must come in), and took a tier percentage out of the price cascade, because the ERP already sent a price for every tier. “We don’t calculate, the ERP does.”
The rules that followed were strict. Goldens are never generated: a shortage refusal with reasons per line carries business meaning that no inventory could produce. A response golden is written before the code that produces it. The validator must accept every committed golden, so a disagreement is a defect in the schema. And agents can’t publish a contract change: the push wrapper hashes the changed golden content and stops with exit code 42 unless I have approved exactly that content for that environment. The instruction to agents is explicit: never invent an approval.
Under the hoodUnder the exchange
- Direction. The ERP pushes to Sparta’s HTTPS endpoints and collects Sparta’s documents by polling an outbox; Sparta never reads from the ERP. Each kind of data is a stream with its own endpoint, and each can be switched off in TOR.
- Intake. HMAC signature, a timestamp window and an IP allowlist; a strict schema where an unknown key rejects the request; the envelope journaled before items are applied, each in its own transaction. An exact replay returns the same answer and repeats nothing.
- Item outcomes. Applied, unchanged, stale, deferred or failed. Stale is judged by the ERP’s own timestamp; a deferred item waits durably for the record it depends on.
- Whole records. Every live item carries its complete state, with empty values sent as
null,falseor[]; a deletion is a tombstone withdeleted: true. - Stock snapshots. A full snapshot of up to 100,000 items is one package, because a missing product means zero stock; a threshold stops one that would suddenly zero too much of the catalog. Other streams take up to 1,000 items and 4 MiB, counted while the body is read.
- Money. JSON numbers with a declared range and at most four decimals per class of field; inside Sparta, prices, amounts, percentages and rates are separate fixed-scale types.
- The way back. Four closed outcomes: accepted, accepted with changes (carrying the ERP’s exact money), rejected or failed. Outgoing payloads are immutable.
- Errors. A rejected request lists up to 20 structural problems by public field name, in words: “array (use [] when empty)”.
I cut my own process when it became the work
Ten days after making the goldens the contract, I doubted them.
I’m starting to doubt that this concept, goldens as the proven source of truth, made sense. It’s turning out to be a very expensive concept. We’ve been looking at it for who knows how long, and the top-level task, making the exchange work, isn’t moving.
The doubt was fair. Around the goldens and the agents, a control plane had grown with a life of its own: readiness receipts, a review ledger, machine handoffs, progress forecasts and a process event store. One exchange program ran to 303 commits in two weeks. On 7 August I saw what that meant for a single slice. A plan to keep money exact through the exchange had been implemented from scratch five times, and each time the reviewer rejected it. The reviews weren’t wrong: the fifth candidate still rounded 99,999,999,999,999.99 to 99,999,999,999,999.98. But the plan never said where the exact number lived, so every executor invented the storage again, and the process let the loop run, because a diff review didn’t spend the budget for plan reviews.
I didn’t want one model to judge the process it was running, so I set two against each other and decided between them. GPT-5.6 Sol had been orchestrating. That day I asked it to analyse its last session as a process advisor and took the analysis to Claude: “He may have overcomplicated it, as usual, but there’s always a grain of sense in what he says.” The next morning I gave Sol the brief for the cut:
I want you to distil what’s useful in our process from what’s less useful and risky: the complication, the swelling rituals, the cargo cults. Describe this refined process and how it differs from the current one. Then we’ll develop exactly this minimal model, very carefully, protecting its compactness.
I took Sol’s answer to Claude, which implemented it within the hour. Its commit counted the damage: in the previous 14 days, 512 of 998 commits had been the process talking to itself, against 367 product commits. The process kept four guarantees and lost everything that didn’t strengthen one of them. Work starts from the live base, with one writer per isolated worktree. An executor can’t accept, integrate or publish its own work. Done means a host review, focused proof, a green gate and a green deploy on dev. One brief now carried a slice from problem to handoff instead of five documents. The change deleted 10,925 lines and added 713. I took it back to Sol for review; it found two blockers left, and one more deletion pass closed them by early afternoon. That night the exchange programs gave way to bounded plans and one boundary for the first live exchange, another 9,868 lines of plans and documents gone.
Two rules keep it from growing back. A new guard has to name the defect it would have prevented. And if repairing a mechanism produces a new major defect in that same mechanism, the mechanism is narrowed or deleted. There is no third repair.
The goldens got the same treatment. On 26 August I looked at them as a reader rather than as their author:
I can see we have far too many goldens, and it’s very hard to tell why each one exists. Surely we can cut their number, keep only what’s really valuable and make those good illustrations, maybe with a description.
Each golden got a declared purpose. Fifteen pairs of refusals that repeated one mechanism across different fields left the corpus, from 446 files to 416, and each was re-proved by a test at its real route.
Designed down to the drop line
The process is the part of Sparta that’s easiest to count. Most of my messages were about something else: how the product should look and feel. I designed the interface the way I edited the contract, one precise note at a time, with screenshots and screen recordings of what was wrong.
The product page is one example. A wholesale buyer compares dozens of similar products, so the next one in the list should be a swipe or an arrow away. Codex had built paging, and it felt wrong, so I asked Claude for the right design:
Look at our product page. There’s a paging animation and swipe paging on phones, and it’s done badly. Suggest how to do it properly, so the animation is smooth, like a carousel: one screen slides away and another takes its place at once, with two warmed-up screens.
That is how it works now. Every list leaves a snapshot behind, with its query, filters, sort and each product’s position, so paging works the same from a category, a search or a filtered view. The track holds three slides, and about 160 ms after the reader stops, both neighbours get their photo and data loaded. If the data hasn’t arrived, the next slide is drawn from the snapshot with the real title, price and buttons. The gesture never waits for the network. The track follows the finger, commits after 56 px, or 24 px with a quick flick, and replaces the URL afterwards, so Back returns to the list. View Transitions were left out on purpose: they can’t follow a finger or stop halfway.

A product page with the buyer’s own price.
The first frame had to be right too. The storefront is server-rendered, and the server picks the layout from the browser’s client hints, so a phone gets the phone layout in its first HTML instead of a desktop page that rearranges itself. Two checks catch hydration mismatches, including attribute-only drift that React doesn’t report, and browser tests compare element boxes before and after the handoff.

The catalog on a phone, laid out by the server.
The day before the release I was still in the operations app’s category tree, where staff order the storefront’s categories by dragging. It couldn’t tell “insert between” from “drop into”, so I specified the interaction:
Make the row taller so the drop target is bigger, and show “drop between” as a blue 2 px line between rows with a ring at its start; that’s how this metaphor is usually shown now. When you drop into a row, make the row blue, as if selected. Drag only by the grip. And make the ghost compact; don’t try to copy the real row one to one.
It landed that afternoon, tuned from a screen recording, and went out with the release early the next morning.
A double click makes one order
Orders, bookings and pre-orders each have a cart, and all three go through one wizard: items, delivery, payment and confirmation. The cart shows the buyer’s own price. If an earlier order hasn’t entered processing yet, it offers to add the products to it rather than start a second document. The submit carries an idempotency key, so a double click or a retry creates one order.
Checkout for a buyer, on the demo copy.

01. CartAn offer to add to an open order.

02. DeliverySaved addresses and recipients.

03. PaymentCash on delivery only where the carrier has it.

04. ConfirmationOne order, however many times it is sent.
The cabinet then follows the ERP’s processing: new, picking, ready to ship, issued, cancelling or rejected. Each order keeps its contents, its money as the ERP recorded it and a history of changes, including the moment the accounting system accepted it.
The buyer’s cabinet on demo orders.

Orders

One order
The build that was tested is the build that ships
Most of Sparta is tested against a real database. API integration tests run on PostgreSQL in a container, with no database mocks, and a guard refuses any database whose name doesn’t end in _test. The release run of 4 September had 10,835 tests: 5,629 for the API, 3,026 for the storefront, 979 for the operations app and 1,201 for Storybook, plus browser tests for both applications.
The gates were built from failures. A gate piped into another command once had its failure masked, and broken pushes shipped three times, so gate and push became one command. The CI image runs a real Node, because the Bun image aliases node to Bun, and for a week in July that made API tests fail in a way production never would. A lint ratchet of 15 custom rules doesn’t let any file’s violation count grow.
Deployment promotes the same immutable image that passed on dev. Production accepts only a full commit SHA, and each deploy writes a receipt with every service’s image and the migration head. Rollback refuses when the migration fingerprint differs, because the schema only moves forward. Migrations need a backup less than 26 hours old. Backups leave the server every four hours, and once a month a timer restores the latest set into an isolated container to prove it works.
The release candidate went out on 4 September. The first two ship pushes were refused before publishing, for two API failures and a stale Storybook mock and then for one web test. The third passed.
Under the hoodThe gates in numbers
- CI. GitLab, self-hosted since April: 40 jobs in eight stages. The image pins Bun, Node and Playwright, and the pipeline fails if its Dockerfile drifts from the pinned tag.
- Local gates. 1,269 runs recorded from August to October. The session gate took 17.7 s at the median, the full ship gate 841 s. Running two jobs at a time cut the local matrix from 1,233 to 750 s, and an unchanged tree reuses a proof for six hours.
- Policy. 22 checks selected by the changed files, from migration safety to release notes.
- Migrations. 18 rehearsal scripts, a review ledger for destructive migrations, and a check that schema and migrations haven’t drifted apart.
- Environments. Dev, sandbox and production as separate Compose projects with their own data; production has 12 services. Outbound effects are allowed by recipient, so a test environment can’t email a real customer.
How it was built
Sparta was built with Codex and Claude Code, under rules I designed. Codex ran in more than 2,500 sessions, mostly on GPT-5.6 Sol and GPT-5.5. From mid-July, Claude Code ran in 151 sessions with 245 subagents, mostly on Claude Fable 5 and Claude Opus 5, and Claude co-signed 1,790 of the commits. July alone had 2,455 commits; the busiest day, 6 July, had 222.
- OrchestratorPicks one slice from the queue and writes the brief
- ExecutorImplements in an isolated worktree; never commits
- ReviewerThe other model family, once, after implementation
- IntegrationGate, push, a green deploy on dev, then the next slice
A reviewer always came from the other model family; a same-model review was refused. Both tools read the same 53 skills from one folder, and knowledge had fixed homes. A glossary defines about 40 domain terms and bans 32 synonyms. The canon describes current behaviour, ADRs keep the decisions that are hard to reverse, and plans are deleted when done. When the canon and the code disagree, the code is right. A settled decision is recorded in my own words, and a reviewer’s suggestion is never recorded as if I had accepted it.
I made the product calls, wrote the design notes and the contract edits, and arbitrated: when the two model families disagreed, I carried one’s critique to the other and decided. The overreach wasn’t only the agents’. The concept I doubted on 5 August was my own decision.
- The foundation.
A monorepo, a Fastify API and ParadeDB, then three days of scaffolding.
- The platform settles.
GitLab CI and self-hosted environments, then TanStack Start in place of Next.js.
- The busiest day: 222 commits.
In the month with 2,455 of them.
- Golden examples become the contract.
After four rounds of review that would not converge.
- The second brand is measured.
Zero prices, zero orders, 13 references that mattered.
- The process is cut.
Four guarantees, bounded plans and one boundary for the first live exchange.
- A release candidate in production.
Then the freeze.
- 5,508commits from 31 March to 4 September, 79% of them in June to August
- 1,790commits co-signed by Claude
- 151Claude Code sessions, with 245 subagents
- 78architecture decisions recorded as ADRs
- 417golden files for 18 exchange endpoints
Frozen at a release candidate
On 4 September 2026 a release candidate was promoted to production. The partnership did not go ahead, and Sparta was frozen before commercial launch. There are no buyers, revenue or adoption figures to report. The exchange never ran against the real ERP: every scenario is proven through Sparta’s own intake path, and the ERP module stayed outside the repository with its developer.
The remaining work was the first live exchange, the first real production hotfix, a separate production host, parts of the recovery and durability acceptance, and the delivery of web push. The case is about a product model, a platform and a contract built and tested to a release. It hasn’t had the months of commercial use that would test those decisions against a live wholesale business.

