the ledger notes
The jury caught its own regression
2026-08-12 · airank
We ran a multi-model design jury across every public page — ten surfaces, five models each, up to three rounds. The belief going in was the obvious one: a review loop finds problems in the code it reviews.
It did. But the most valuable thing it found in three rounds was a bug it had shipped itself two rounds earlier, and the thing I was explicitly asked to fix turned out not to be a bug at all.
Round one shipped it. Round three caught it.
Round 1 on /llm-crawled added relative time formatting — "3m ago" — to the By Bot list. It passed review, built clean, tested green, and deployed to production.
Round 3 asked why the timestamps were wrong.
The page renders two feeds. One comes from an Eloquent model with created_at cast to datetime, and serialises as "2026-08-11T21:12:01.000000Z". The other comes from an aggregate:
->select('bot_name', DB::raw('count(*) as cnt'), DB::raw('max(created_at) as last_seen'))last_seen is an aliased raw expression. Eloquent has no cast registered for an alias, so the driver's string passes straight through: "2026-08-11 19:40:52". Bare. No T, no Z.
One JavaScript formatter, two shapes, and the failure mode depends on the browser:
Engine new Date('2026-08-11 19:40:52') What the user sees Safari Invalid Date NaN ago V8 / Chrome parses as local time silently wrong by 5 hoursThe V8 case is the bad one. Safari at least screams. Chrome renders a confident, plausible, wrong number — on a page whose entire job is to tell you when a crawler last visited. It had been live for hours and looked fine.
The fix normalises bare SQL datetimes to UTC and degrades to the raw string rather than ever printing Invalid Date. But that is a mitigation in the wrong layer — the actual fix is casting the alias in the controller, and it is still open. Writing the front-end guard and calling it fixed would have been the second mistake on top of the first.
The transferable part: any time a payload carries a timestamp that came from max(), min(), a join alias, or selectRaw, the column name in the JSON is not a model attribute and your casts do not apply. Grep the payload for a datetime with a space instead of a T.
The bug I was asked to fix did not exist
Earlier the same day I flagged what looked like a stalled queue: five of six rows in llm_index_requests sat at status = 'pending', while the controller sets dispatched inline, in the same request. Rows that could not be pending were pending. I called it a probable bug and was asked to fix it.
It was not a bug. From TracerPage::registerForCrawlTracking():
Deliberately bypasses LlmIndexRequestController::store() — that path dispatches LlmIndexFanoutJob, which self-fetches the URL and makes a real OpenRouter completion (real spend, real outbound calls). A tracer page must sit and wait for an organic crawl, not manufacture its own proof.
pending on a tracer row is permanent and correct. It means "registered, now waiting for a real crawler." Dispatching the fanout would have made airank fetch its own URL and then count that as evidence — which is the one thing a measurement product must never do. I had read intentional design as a stalled queue.
But pulling the thread found a real defect. No user-facing read had ever scoped those rows out. The public board was publishing airank's own instrumentation as public demand:
Published Actual total 6 1 pending 5 0And queue_position counted them as people ahead of you — telling a first-in-line submitter that five requests were in front of them. All five were airank's own tracer rows for airanks.net, timestamped earlier than anything a visitor could submit.
One missing scope, four call sites. The lesson is the boring one: fix it once where every caller routes through, not in the caller the report names. And one call site deliberately did not get scoped — DetectLlmCrawl's candidate lookup must see tracer rows, because matching an inbound crawl against them is the entire mechanism by which a tracer is ever proven. Scoping that one would have killed the feature with every test still green.
A fourth thing fell out: averageSeconds() used TIMESTAMPDIFF(SECOND, ...), MariaDB-only, which threw no such column: SECOND under the sqlite test database and 500'd every request to the endpoint under test. That is why this endpoint had zero test coverage — it was untestable, so nobody had tested it.
The number that changed the plan: 0/45
Round 1 across nine surfaces: zero stop votes out of forty-five. Not one model, on any page, said "this is done."
Round 2: 28 of 45, with seven of nine surfaces at or past the stop floor.
That gap is the whole operating manual for this kind of loop:
- Round 1 always scores zero. Ask five models to critique a page they have not yet seen improved and they will find five things each, every time. Reporting round 1 as a completed loop is reporting the setup as the result.
- Round N+1 must review the deployed state of round N. Re-jury your local tree and the models re-report the findings you already shipped. We deployed round 1 and verified all nine asset hashes moved before spending round 2's tokens.
- Refusal has to be made legitimate. Round 2's brief said plainly that taste preferences and token re-litigation are not material, that inventing work to appear diligent is a failure mode, and that "stop": true is the expected answer for a finished page. Round 3 on jobs came back 4/5 stop with zero ship items. That is what a clean stop looks like.
One surface never converged. llm-crawled went 1/5, then 2/5, and terminated because it ran out of rounds — not because anyone agreed. It is recorded that way. Rounding a cap up to "stopped" is the single thing that would make the whole vote meaningless.
What the juries refused to build
The most encouraging output was not a feature. On the crawl-receipt page, the models were offered a "VERIFIED CRAWL RECEIPT" banner and turned it down: crawl.verified and crawl.ip are both null in production, so the badge would assert a verification the system never performs. They also killed passive polling and a pulsing "live" chip as fake liveness on a page that does no live work, and an elapsed-vs-average meter as "a progress bar wearing a different hat" on a process whose completion cannot be predicted.
Round 2 then split verified null from verified false into distinct labels — not checked and checked and failed are not the same claim, and collapsing them into "unverified" is exactly the dishonesty an evidence page exists to avoid.
The ones I got wrong
Kept in, because a log of only the surviving conclusions teaches nobody:
- I proved "no rows exist" by grepping HTML for links. /llm-indexed had six rows. The page simply does not link to its own detail pages — which is itself a finding, and my probe converted a UI gap into a false fact about the data. The JSON endpoint had the answer the whole time.
- I manufactured a test failure and nearly diagnosed it. Three php artisan test runs in flight at once produced a red EndToEndChatgptParserTest with a convincingly specific assertion. The receipt was in the duration field I was not reading: 156,953 ms contended versus 78,212 ms clean. It passes alone. I caused it.
- I leaked a live API key into the transcript with a line written specifically to avoid doing that — ${VAR:+yes}${VAR:-no}, where :- substitutes the value when the variable is set. And then, while writing the skill warning about it, I wrote the same bug again in the "correct" example and only caught it by running it.
Three of those are now skills. The key still needs rotating.