At 3:40 AM on September 10th, Claude Code hit its weekly limit and reset itself to 4 AM. We had budgeted for that by wiring Codex CLI as a terminal fallback earlier that morning, on the same machine, with the same ChatGPT subscription, already authenticated. By early evening it was the primary engine running review passes against a stuck bug in our JIRA bot’s queue drain: a semaphore that was force-settling healthy wakes under load.

Codex ran that review loop over and over that afternoon. QUEUE_TIMEOUT_SECONDS = 900 was rejected. PRE_QUEUE_TIMEOUT_SECONDS = 3600 was rejected. A recomputed budget was rejected. Each pass took two to eight minutes and burned anywhere from 60,000 to nearly 200,000 tokens. We got nine “not approved” verdicts in a row before one finally cleared.

The field that wasn’t there

Somewhere around the fifth or sixth verdict, we noticed something in the log line itself. Our Claude Code entries look like this:

claude -p (sonnet/medium, 12t, 5.2s) - ...

Model and effort are both there. Our Codex entries looked like this:

codex exec (high, 97760t, 526.73s) - ...

That line has effort, token count and duration, but no model. The field was absent because nothing in the call chain had ever declared one. Every codex exec we had dispatched all day pulled its model from whatever ~/.codex/config.toml said at that moment. That covered the keepalive cron, the JIRA bot reviewer and the ad hoc terminal fallback. The logger couldn’t report a field nobody had set explicitly.

That is a fine setup for a single interactive session. It becomes a trap the second more than one caller shares that file. Bump the global default to a heavier model for one foreground task, and every unattended job downstream shifts with it, silently, with no record of the change and no one deciding it on purpose. A cron job dispatched at 4 AM could run on a completely different model than the one that ran the same script the day before. The only way to find out would be to check the config file’s git history and cross-reference timestamps.

The resolver that lied

Our first fix was to patch the logger. We got it working and watched it fill in real model names for an hour of calls. Then we moved on.

Then we hit a call site with a project-level config profile that overrode the global default. It was the profile our JIRA bot’s own codex exec invocations ran under. Our resolver walked the layers in the wrong order and printed the global default anyway, confidently. It looked exactly like the field we wanted, and it was wrong for every call from that profile, going back to whenever we had added it.

That is worse than a blank column. A missing field tells you to go check. A wrong one tells you not to.

Naming the model at the call site

So we threw the resolver out and enforced the opposite discipline: every dispatched Codex call has to name its own model at the call site, full stop. The shortcut we added that evening to open Codex in the ops repo, the same way our existing one opens Claude, always passes --model explicitly. The wrapper around codex exec now refuses to shell out without a model argument rather than falling back to config. Config still sets a default for interactive, ad hoc terminal use, which is the case it was always fine for. Nothing dispatched from a script or a cron trigger is allowed to inherit it anymore.

The log line for the fix we shipped that night reads codex exec (medium, 22327t, 109.61s). It is still missing a model name, because the change hadn’t landed yet when that call ran. The first call after it landed has one.

That is the whole test. Can you point at any single dispatched call, unattended, hours later, and know exactly what ran without asking the config file to remember for you? If a job inherits its model from a shared file, a change made for one foreground task can shift every unattended job with no record. Each dispatched call should declare its model and log it.