AI Agents · Benchmark

The Bug That Needed
Two Subscribers to Exist

A synthetic task where all five languages score 100% on the first try reads like good news. It is mostly a ceiling effect — a test with no room to fail teaches nothing about where the edges are. Four levels of deliberately harder tasks later, the last one found a bug none of the five had ever been in a position to have: two Bounded Contexts subscribing to the same event, for the first time in the repository's history.

The setup was simple: the same synthetic domain, Voucher — issue to ACTIVE, redeem as a plain transition with no event, expire as an event since other parts of the system react to it — built independently across all five languages at once, each agent given nothing but its own implementations/<lang>/CLAUDE.md as an entry point. No doc paths, no scaffolding-tool hints. All five hit a perfect harness score, and all five independently converged on the identical judgment: publish the event on expire() only, matching the same "does anything actually react?" pattern the docs already establish elsewhere. Strong evidence the docs communicate consistently across languages — and, along the way, the run surfaced three real tooling regressions nobody had noticed: two scaffolding generators still emitting a shape a naming rule built the same day now forbade, and two harnesses drifting out of parity on which directories their file walkers were supposed to skip.

A Perfect Score Everywhere Is Not Reassuring

Five languages, one easy task, five first-try wins. On its own that result explains nothing about where any implementation would actually fail — a test that always passes has no discriminative power, and Voucher was, deliberately, an easy first task. The real question was what to build next, and running the same easy shape again wasn't going to answer it. What the task needed wasn't more repeats. It needed to get harder, on purpose, in directions specifically chosen to exercise code paths nothing before this had ever exercised.

A Ladder, Not a Repeat

Level 2 — Booking/Cancellation, two Aggregates inside one Bounded Context plus a Domain Service, mirroring Payment/Refund's existing RefundEligibilityService — was the first sign the ladder actually discriminated. All five still reached 100%, and all five independently made the same subtle judgment call the spec allowed room to get wrong (a rejected booking is never persisted, unlike Refund's persisted REJECTED state) — but NestJS scored 96/100 on its first pass, a real defect this time, a raw string thrown where the convention requires a typed enum, then corrected it on its own.

Level 3 — Membership, which needs a synchronous Adapter reading another BC's Account status — again converged on the right pattern in all five: the synchronous read, not the asynchronous Integration Event a level-4 task would have needed instead, and every language correctly translated Account's status enum into a plain boolean rather than leaking the enum itself across the boundary. Java's agent went a step further on its own, avoiding a Spring bean-name collision with Card's existing AccountAdapterImpl by noticing and reading an existing code comment that named the conflict before writing anything.

Level 4, Built to Contrast With Level 3

StandingOrder was designed specifically as level 3's mirror image: create one against an Account, and it becomes ACTIVE; if that Account is later suspended, the StandingOrder must become PAUSED automatically, and CANCELLED if the Account is closed — the reaction has to happen the moment the Account's status changes, never through a direct call on StandingOrder itself. The correct pattern this time is the opposite of level 3's: subscribing to an asynchronous Integration Event, not a synchronous lookup. All five made exactly that distinction, and this time verification was strengthened to match the stakes — each agent had to prove it with a real end-to-end test that actually calls the suspend/close API and polls until the reaction completes, not a unit test asserting the handler function alone.

All five passed, independent re-verification matching every self-report exactly. The interesting part wasn't the score.

Nothing had ever called it with two

Card was already subscribing to the same two Account events StandingOrder now needed. The moment a second subscriber existed for an eventType, it exposed that the root domain-events.md's stated principle — one event, multiple handler subscribers, 1:N — had never actually been load-bearing code in two of the five languages. It had been true in the docs since before this benchmark existed, and false in the code the entire time, because nothing had ever tried it.

Java-springboot's handler map was built with Collectors.toMap(eventType, identity()) — a shape where registering a second handler bean for an eventType already in use throws IllegalStateException: Duplicate key at boot, not at runtime under load, but the instant the application tries to start. FastAPI's build_event_handlers() returned dict[str, EventHandlerFn], one callable per key — no crash at all, just the second registration silently overwriting the first, so only the newer subscriber would ever actually run. Go and NestJS had never had the problem: Go's main.go hand-assembles a plain map where adding a second call under the same key is unremarkable, and NestJS's registry was already list-shaped from the start. Each language's agent fixed its own case without coordinating with the others — Java moved to Collectors.groupingBy, producing a real Map<String, List<OutboxEventHandler>>; FastAPI moved to dict[str, list[EventHandlerFn]] and updated its consumer, its scaffolding generator, and the doc all together.

Kotlin's fix was the odd one out — not wrong, but a different shape of workaround. Rather than restructuring its registry to be list-valued, it added the second handler call directly inside the existing per-eventType lambda:

"AccountSuspendedEvent" to { eventId, payload ->
    accountSuspendedEventHandler.handle(objectMapper.readValue(payload, AccountSuspendedEvent::class.java), eventId)
    standingOrderPauseHandler.handle(objectMapper.readValue(payload, AccountSuspendedEvent::class.java), eventId)
}

Functionally correct for exactly two subscribers, hardcoded rather than structural — a third subscriber to the same event will need a hand edit to this lambda rather than a new registration, unlike Java and FastAPI's now-generalized shape. Flagged, not fixed; the harness still passes, because nothing in it requires the more scalable form.

What the Task Actually Tested

This mirrors, and extends, the lesson from building a second domain to validate the harness itself: some bugs only exist once a specific combination of circumstances shows up in the code, and no amount of reading, no amount of repeating an easy task, and no static rule can produce that combination on its own — only running the actual scenario can. Level 1's perfect scores measured whether five languages agree on an easy judgment call. Level 4 measured something a perfect score can hide entirely: whether a codebase survives the first time a real-world shape of usage — two things caring about the same event — actually happens to it. Three of five languages hadn't, silently, until a task was deliberately built to make it happen.

Further reading in the repo

docs/benchmark.md — the full run, every level, every self-report vs. independent re-verification table · OutboxEventDispatcher.java — the real fix, list-valued handler map · Can an AI Agent Follow Your Architecture? — the methodology this run is built on