Field Notes

Opus planned it, Sonnet built it, and the plan had the bugs

Opus planned it, Sonnet built it, and the plan had the bugs

Claude Code has a model setting called opusplan. In plan mode it uses Opus. When you approve the plan and Claude starts executing, it switches to Sonnet. The idea is simple: spend the expensive reasoning on the design and the cheaper, faster model on typing it out.

Claude Code terminal: /model opusplan, answered with: Set model to Opus in plan mode, else Sonnet and saved as your default for new sessions.
One command, and it becomes the default for new sessions too.

This week I used it for a real feature on this site: a live Workbench. Its entries moved from a hardcoded Scala file into PostgreSQL. They are managed in my blog admin, and a Promote button copies a card from my private FOCUS board into a Workbench draft. It touches a Flyway migration, authorization, two React apps and the public page, so it was a fair test.

Then, before shipping, I switched to plain Opus and ran a deep review. I found the review's result more interesting than the feature: the two worst findings were not Sonnet's mistakes. They were lines in the plan I had approved.

How the session actually ran

The session transcripts record which model answered each turn and when every subagent started, so the timeline below comes from the logs, not my memory.

Five steps. Plan, Opus in plan mode, 01:32 to 01:53, 4 Explore agents and 1 Plan agent, asked me 3 design questions. Approve, me. Build, Sonnet, 01:53 to 02:22, main thread, 209 backend and 265 frontend tests, a 26-check run on the live stack. Review, Opus via /ship, 02:24 to 03:20, 11 finders, 28 candidates, 15 reported, 2 judgment calls sent to me. Look, me in Chrome: one layout bug.
Times are UTC from the session transcript. opusplan covers the first three steps; the review and the browser check were mine to add.

Plan, on Opus (about 20 minutes). Opus sent four Explore agents through the repository, then a Plan agent to draft. It asked me three design questions:

  • how publishing should work;
  • whether a promoted entry stays linked to its card;
  • what the card should show afterwards.

The plan it produced was thorough: the schema and seed, a table of when "Last updated" moves, every route, the tests per spec, the docs to update, risks, and a manual verification script. It also found a security problem before any code existed. My blog's publishing token covers the whole /api/blog/admin/ prefix, so a promote route under that prefix would let the token copy private cards. The plan specified a two-layer carve-out for exactly that route.

Build, on Sonnet (about 30 minutes). Sonnet worked in the main thread with no subagents and followed the plan closely: the migration, the schema-contract checks, the repository, the controllers, both UIs and the docs. It finished with 209 backend tests on real PostgreSQL, 265 frontend tests and a 26-check script against the running stack with a real Keycloak sign-in. The tests caught one real bug on the way: Anorm binding an Option[UUID] as varchar. The token carve-out was implemented as specified, and later the review could not break it.

Everything was green. The browser extension had not connected, so nobody had looked at the UI.

The review, back on Opus

I switched with /model opus and ran /ship, my documented path to production, asking for a deep review of what Sonnet did. Its review step fans out:

  • 11 finders, each looking for one kind of problem;
  • 28 candidate findings, each handed to its own verifier that tries to refute it;
  • a final sweep.

It ran for 55 minutes and reported 15 findings. /ship applies clear, low-risk fixes and sends judgment calls back to me. Ten were fixed directly, two came to me as questions, and three are documented follow-ups.

Three cards. Tests: proved token 401 before body parsing, drafts hidden, escaping, the V9 to V10 upgrade; caught an Anorm UUID binding bug. Deep review: caught a private-wording slug leak, a stale-tab overwrite, a keyboard drag trap, NUL giving 500, collapsed line breaks. Browser: caught Del stranded on its own line.
Tests, the review and a look in the browser each caught a different kind of bug.

The plan had the bugs

The two questions that came back to me were the most serious findings, and both were plan decisions.

Three verbatim lines from the plan, each with what it became. Schema: slug ... UNIQUE, immutable; became a private card title frozen into a public anchor. Risks: last write wins between two Manage Blog tabs; became a stale tab silently republishing an entry. FOCUS: if a keyboard test shows Enter or Space starting a drag, add onKeyDown stopPropagation; became Enter on Promote starting a drag, because no keyboard test ran.
Verbatim lines from the approved plan. Sonnet implemented each one faithfully.

An immutable slug. The plan made each entry's anchor slug immutable, so links survive a retitle. Promotion builds the slug from the card title, and FOCUS card titles are private notes to myself. Promote a card, retitle it for readers and publish it, and the old wording sits in the page source for good. The fix: the slug follows the title while the entry is a draft, and freezes on the save that publishes it.

Last write wins. The plan listed it as an accepted risk. The review spelled out the concrete case: unpublish an entry in one tab, fix a typo in a stale second tab, and the typo fix silently republishes it. Seeing that scenario, I changed my mind. A stale save now answers 409.

A conditional check. The plan said that if a keyboard test showed Enter on a card button starting a drag, add a keydown stop. No keyboard test ran, and Enter on Promote did start a drag. The same plan asked for a visual check at narrow widths. That one never happened either, and in Chrome the card's "Del" button sat alone on its own line.

Three columns. Decided in the plan: private card wording frozen into a public anchor; a stale tab silently undoing a newer save. A check that never ran: Enter on a card button started a drag; Del stranded on its own line. Implementation detail: no-break-space titles passed and NUL gave 500; note line breaks collapsed; saving one stage's order dropped the others; admin dates in the browser's time zone.
Sorted by origin, the expensive findings started before the code did.

The findings that really were Sonnet's code were the ordinary kind a review exists for:

  • a title made of no-break spaces passed validation, and a NUL character returned 500;
  • note line breaks collapsed on the public page;
  • saving one stage's order dropped unsaved reorders in the others;
  • admin dates were shown in the browser's time zone.

All of them were fixed before merge.

What I take from it

Sonnet does what the plan says, including the mistakes. That is the point of the split, and it is also its weakness. Sonnet did not question an immutable slug or an accepted risk, and it read "if a test shows" as optional. A plan handed to a faster model is a specification, so write it like one.

Opus caught Opus. The planner and the reviewer were the same model. The difference was a fresh context and a different question: "how does this go wrong?" instead of "how do I build this?". The verifier step mattered as much. A refuted finding never reached me.

opusplan stops at the plan boundary. It covers plan and build. The review and the browser check are steps I had to add, and on this feature they were where most of the value came from.

What I change next time

  • No conditionals in a plan for Sonnet. "Verify Enter on Promote presses it; if not, stop keydown" is a step with a pass condition. "If a test shows…" is a suggestion.
  • Judgment calls go in the plan as open questions, each with a concrete failure scenario, so I decide them before code exists, not during review.
  • A browser look is a required gate before /ship, with a screenshot of every new UI state.
  • The deep review runs in a separate context, on the bigger model, with a verifier per finding.

The feature shipped as one PR with 211 backend tests and five green CI jobs. I would use opusplan again. Next time, though, I will spend longer reading the plan, because that is where this feature's worst bugs started.

Keep reading