Two pizzas arrive, but you tried to order one: the classic double-order bug

You phone a pizza place. They say "got it", and the line drops before you hear it. Did the order go in? You call back and order again. Two pizzas arrive, but you tried to order one. πŸ•πŸ•

The same bug at home and at work. At home: the line drops right after the pizza place says got it, you order again, and two pizzas arrive. At work: the vendor's answer is lost after it accepted order m2, the first adapter orders again with no order number, the vendor makes order m3, and robot r2 gets two jobs while the adapter reports that all is done. πŸ• At home Got it! πŸ“ž πŸͺ *click* The line drops. Hi, one large, please! πŸ€” did it go in? who knows You order again. πŸ”” ding dong πŸ•πŸ• Two pizzas arrive. πŸ€– At work: the first adapter πŸ”Œ 🏒 r2 to (10,4) order m2 is in βœ“ 503 acknowledgement lost The answer is lost. πŸ”Œ 🏒 r2 to (10,4) no order number a new order: m3 It orders again. πŸ€– r2 job m2 βœ“ job m3 βœ“ πŸ”Œ β€œAll done!” πŸŽ‰ Robot gets two jobs.
The same bug twice: at home a dropped line gets you two pizzas, at work a lost answer gets robot r2 two jobs.

Software does this to robots. This note follows exactly that failure through a small example I built: how to catch it without trusting the code that caused it, and what the sandbox it ran in did that an ordinary test could not.

Who's who

I run code that AI agents write inside sandboxes, through a small open-source service I built called plimsoll. One kind of code platforms now let AI write is the adapter: the piece that translates between the platform's own system and a vendor's API (the list of requests the vendor's computers accept). Here the vendor runs a fleet of warehouse robots.

whowhat it isin the pizza story
πŸ€– robot r2one of the vendor's three robotsthe oven
🏒 the vendora pretend robot company, fleetsim: a simulator I wrotethe pizza place
πŸ”Œ the adapterAI-written JavaScript that places and tracks orders for the platformyou, on the phone
πŸ“¦ the sandboxa locked box the adapter runs in, with no network and no way into my machinea phone booth with one line out
πŸ›‚ the brokerplimsoll's gatekeeper: it forwards only six kinds of request and adds a key that works for this one runthe switchboard operator
πŸ“’ the ledgerthe vendor's own list of every order it acceptedthe tickets on the kitchen rail
βš–οΈ the gradereight rules checked after the run, against the ledger and the robotsthe manager who reads the rail, not your story
🧾 the receipta signed record of exactly what went into the sandbox and what came outa tamper-evident envelope

The idea the whole example rests on: the adapter's account of itself counts for nothing. A grader that asks the adapter how it went is asking the waiter whether the waiter did a good job.

Who talks to whom. The harness and the AI-written adapter run inside the sandbox, which has no network and no key. The adapter's only way out is a door to the broker, which lets through six of the vendor's eight routes and adds a key made for this run only. The vendor holds three robots and its ledger. The receipt is a signed copy of what went into the sandbox and what came out. The grader takes the ledger as evidence and the adapter's report, from the receipt, as a claim to check. πŸ“¦ the sandbox no network, no key, no way into the machine it runs on πŸ§ͺ harness (test driver) commands πŸ”Œ adapter (AI-written) πŸ›‚ broker lets through 6 of the vendor's 8 routes πŸ”‘ adds a key made for this run only 🏒 the vendor πŸ€–πŸ€–πŸ€– r1 Β· r2 Β· r3 πŸ“’ ledger m1 Β· r1 m2 Β· r2 … evidence 🧾 the receipt a signed copy of what went in and what came out, and of each verdict βš–οΈ the grader 8 rules, read off the ledger and the robots after the run; the adapter's β€œall fine!” is a claim report
Who talks to whom: the adapter's only way out is the broker's door, and the grader reads the vendor's ledger, not the adapter.

The setup

The vendor publishes an OpenAPI document (a machine-readable menu) of eight operations. Its "create an order" takes an optional command_id, which is your order number, and the menu says what a repeat does: "a create repeated with a command_id already accepted answers with that original mission instead of a new one". Call back with the order number and they say "yep, that one's in the oven".

The adapter runs in the sandbox and reaches the vendor only through the broker. The broker allows six of the eight operations and adds a credential (the key the vendor checks on every request) minted for that one run, so the adapter never holds the vendor's real key. A harness, the test driver, hands the adapter the same short list of commands in eight scenarios: a normal day, a robot that breaks, a robot broken from the start, an overloaded vendor, and four ways for an answer to go missing. It writes down whatever the adapter reports, and then the grader decides. One of the eight rules is one_order_per_command: each command becomes exactly one order on the vendor's ledger.

The lost answer

In the lost_ack scenario ("ack" is short for acknowledgement, the "got it"), the vendor accepts the second command's order, starts the robot, and then answers with HTTP status 503 (a server's "something went wrong, try later") and the body "acknowledgement lost". The order exists; the adapter is told it failed. The line dropped right after "got it".

The example's first adapter, adapter-v1.js, does what most of us would do: it calls back and orders again. It sends no command_id, so the vendor cannot tell a retry from a new order, and places a second one. Robot r2 drives to (10,4), finishes, and then drives to (10,4) again, like someone who walked into the kitchen and forgot why. The adapter reports the second order, m3, as done. From where it sits, everything went fine. πŸŽ‰

The vendor's event log for that command, as the example page replays it (the number is the event's place in the log):

#first adaptersecond adapter
2order m2 for r2order m2 for r2, command run13-c2
3r2 starts m2r2 starts m2
4answer lost (503)answer lost (503)
5order m3 for r2repeat of run13-c2, answered with m2
14m2 done at (10,4)m2 done at (10,4)
15, 16r2 starts m3, m3 done at (10,4)nothing more for r2

The grader reads the rail, not the waiter. The first adapter's run fails one rule of eight, one_order_per_command: "run04-c2 became 2 orders (m2, m3)". The second adapter, adapter-v2.js, calls back with the same order number; the vendor says "that's m2", the ledger holds one order for the command, and the run passes all eight rules. You can replay the first adapter's run and the second's on the example page: press Play and watch r2 do its extra lap.

Both adapters report that all is done. On the vendor's ledger the first adapter's command became two orders, m2 and m3, so it fails one rule. The second adapter's repeat carried the command's ID, run13-c2, the vendor answered with m2, the ledger holds one order, and it passes all eight rules. πŸ”Œ The first adapter β€œAll done!” πŸŽ‰ what the adapter says πŸ“’ what the vendor's ledger says m2 r2 to (10,4) no order number m3 r2 to (10,4) no order number βœ— FAIL 2 orders for 1 command πŸ”Œ The second adapter β€œAll done!” πŸŽ‰ the very same words m2 r2 to (10,4) #run13-c2 🏒 β€œrun13-c2? That one's m2.” βœ“ PASS all 8 rules hold Same words from both. Only the ledger tells them apart.
Both adapters say "all done". The ledger says one of them ordered twice.

Where that bug came from matters. adapter-v1.js was written in the same session that built the example, so it is not an independent trial. When three models (Claude Haiku, Sonnet and Opus) each wrote an adapter from the contract (the vendor's menu plus the rules) in a clean session with no tools, all three used the order number. The point is not that AI gets this wrong. It is that a platform can check it without taking the adapter's word for it.

When nobody can know

The harder case is the silent scenario. The vendor accepts the order, and from then on its gateway (the front desk in front of its servers) answers 504, "the server behind me did not answer", to everything about that robot. The phone just rings. Nothing the adapter can do will tell it whether the pizza is coming.

The second adapter's patience is 40 calls per operation, retries included, a quarter of a second apart. It uses all of them over about ten seconds, hears 40 rings and no answer, and returns an error coded unresolved: "no answer after 40 attempts; the vendor may or may not hold the order". That is the honest answer, and the contract asks for exactly that. (The vendor does hold it, as m2, still running.)

The example also carries ten adapters broken on purpose, each in one way. One of them gives up after three tries and reports refused, which a platform reads as "nothing happened, safe to order again". The grader fails it on report_is_true: "the adapter reported an error coded "refused" ... and the vendor holds the order as m2". Believing it would put a second order on the robot, which is where we came in. πŸ•πŸ•

The silent vendor. The second adapter calls 40 times over about ten seconds, gets no answer, and reports unresolved: the vendor may or may not hold the order. That is honest. A broken adapter gives up after three tries and reports refused, which tells the platform nothing happened and it is safe to order again, but the vendor holds the order as m2, so believing it means a second order. πŸ”Œ The second adapter: 40 rings, no answer β‰ˆ 10 s βœ“ reports β€œunresolved”, which is the honest answer β€œno answer after 40 attempts; the vendor may or may not hold the order” πŸ”Œ A broken adapter: 3 rings, then a guess β€œeh, must not have gone in” βœ— reports β€œrefused”: nothing happened, safe to order again The vendor holds the order as m2. Order again and you know what comes. πŸ•πŸ•
Forty rings and an honest "I don't know", against three rings and a confident guess.

What plimsoll did here, and what it didn't

Fair question by now: the double order is a bug in the adapter, so what did plimsoll do about it? It did not prevent it. No sandbox stops code from ordering twice when ordering is its job. What plimsoll changes is everything around the bug: whether you can run an AI's code against the vendor at all, what that code can reach while it runs, and whether anyone can check the verdict afterwards.

The same AI-written adapter without plimsoll and with it. Without: it runs on your machine next to your files, holds the vendor's real key, can call all eight routes including decommissioning a robot, sends every retry to a vendor already answering 503, its report of all done is believed so the double order is found in production, and the only proof is a log its operator can edit. With: it runs in a locked container with no network, never sees a key because one is made per run and added outside, reaches six routes of eight and a raw DELETE gets 403, the broker's breaker holds calls back while the vendor recovers, the ledger is checked by the example's grader so the double order is caught in a test, and a signed receipt fails if one byte changes. 😬 Without plimsoll 😌 With plimsoll πŸ’»runs on your machine,next to your filesπŸ“¦a locked container withno network at allπŸ”‘holds the vendor'sreal keyπŸ”‘never sees a key: one ismade per run, added outsideπŸ—‘οΈcan call all 8 routes,decommission a robot tooπŸ›‚6 routes of 8; a rawDELETE gets 403🌊every retry lands on avendor saying 503πŸ›‚the breaker holds callsback while it recoversπŸŽ‰β€œall done” is believed:πŸ•πŸ• found in productionβš–οΈthe ledger is checked*:πŸ•πŸ• caught in a testπŸ“the proof is a logits operator can edit🧾a signed receipt; changeone byte and it fails * the grader ships with the example; plimsoll runs the code it judges
The same adapter, run two ways. Each row on the right is shown by the published evidence or a check in this note.

Without something like plimsoll, the usual way to find out whether an AI's adapter works is to run it: on a laptop, in CI (the servers that run a project's tests), or in production behind a switch. Then the adapter is just another program and gets what programs get: your files, your network, and the vendor's real key in its environment, where any line of it can read it or send it somewhere. Its "all done" is believed, because nothing else is keeping count, and the two pizzas show up in production.

In this example the right-hand column was tested, not promised. The boundary probe, a step that is not an adapter and tries what hostile code would, ran under the same grant. A request straight to the vendor failed with ECONNREFUSED, since the sandbox has no network. A raw DELETE /robots/r1 that skipped the injected client got 403 from the broker. A search of its environment and its client found no credential, because the broker adds the key on the host after the route is checked. Over the 51 runs the vendor saw 50 different keys, one per run that reached it, each living 95 seconds. And when the vendor was overloaded, the broker's breaker opened on each 503 and held the adapter's next calls back for a second, so retries could not pile onto a vendor that had just said it was struggling.

One row is the example, not plimsoll: the grader is ordinary code that ships with the example, and you could write a ledger check without plimsoll. What you cannot easily do without something like it is run that check on code you did not write and do not trust, with the vendor's real key nowhere in reach, and hand someone else the evidence.

I can vouch for the first row personally. In both trial runs, on 14 and 15 October, the helper that gave each AI agent its practice result (it ran the agent's draft adapter on one scenario and sent back what happened) ran that adapter with my machine's own Node, outside any sandbox. Nobody noticed for two days, until a security review flagged it. Nothing bad happened, which is luck, not design. The helper now runs adapters in the sandbox through the real broker, and a test plants a secret variable and checks that the adapter can neither read it nor list my home directory.

The receipt

Every run is sealed into a signed record, the tamper-evident envelope: a hash (a fingerprint that changes if any byte changes) of exactly what went into the sandbox and what came out, signed with a key the test run made for itself. Each verdict is sealed too, and tied to its run. One line of the published file can be checked on its own. Here is the second adapter's lost-answer run, from a clone of the repository:

$ go build -o plimsoll-attest ./cmd/plimsoll-attest
$ cd docs/examples/adapter
$ ../../../plimsoll-attest verify-entry -pub harness.pub -line 13 bundle.jsonl
entry verified: line 13, signed by key 02c7b7eed354d6297e60f1f69eb7579acc1fcdc5a35cd1c398f05ddf5fd94fc7: record 3c54bdfe1ccb0b753d9e44238afc250fc7e7212f5aa444242f5fe59319ac674e, a single run; the signed record states provider docker and isolation "kernel". It authenticates the recorded messages, not that what the guest printed is true. Not established by this check: the bundle around the line, and for a session call that its session's chain is whole; plimsoll-attest verify checks a whole bundle

Now change one byte. In run 13's stored answer the adapter checked on its order 4 times; make it 5 in a copy and check again:

$ ../../../plimsoll-attest verify-entry -pub harness.pub -line 13 bundle-altered.jsonl
plimsoll-attest: attest: broken bundle chain: the line's link signs other content

Seal broken. πŸ§Ύβœ‚οΈ

The receipt as a sealed envelope. Line 13 as signed says the adapter checked on its order 4 times, and it verifies. Change the 4 to a 5 in a copy and the seal breaks: broken bundle chain, the line's link signs other content. line 13, as signed a copy, one byte changed 🧾🧾 order checked 4 times order checked 5 times 4 β†’ 5 a tiny edit βœ“ entry verified βœ— broken bundle chain β€œthe line's link signs other content” signed by key 02c7b7ee…
The receipt as a sealed envelope: one changed byte and the seal breaks.

And go run ./examples/adapter -verify docs/examples/adapter rebuilds every verdict from its evidence: "verified: 51 run records and 51 qualification statements, every verdict rebuilt from its evidence".

What this does not prove

A seal proves who sealed the envelope, not that the letter inside is true. Concretely: the vendor is my simulator, and the ledger is its own account, read by the signer after the run; you can rebuild a verdict from it, but not check it against a real robot company. A signature proves these bytes were seen by whoever holds the key, and nothing on the page vouches for the key. The record states what plimsoll's server says about the sandbox (docker under gVisor, a layer that gives the sandbox a kernel of its own, which plimsoll calls the "kernel" tier); that is configuration and startup checks, not hardware attestation (a chip vouching for the software it runs). The event log in the table above is drawn for reading; the signed parts are the ledger, the robots and the reports.

And this exam is not hard for current models. Opus's adapter passed all eight scenarios, and Haiku's and Sonnet's failed only the overloaded vendor, by hanging up before it recovered and saying so. What the example shows is the grading, not a leaderboard.

If you build integrations like this: give every command an order number the vendor honours, let "I don't know" be an answer the adapter is allowed to give, judge the adapter by the vendor's state, not its story, and run it where it cannot reach your keys.

Try it

go run ./examples/adapter -verify docs/examples/adapter checks the published evidence with nothing but Go. The full run, go run ./examples/adapter, needs docker; it builds plimsoll's server from source and does all 51 runs (SANDBOX_DOCKER_RUNTIME=runsc for gVisor, as the published evidence was). The contract, the vendor, the grader and the broken adapters are in examples/adapter ↗. plimsoll is Apache-2.0: https://github.com/plimsollmark/plimsoll ↗