Codex Fixes One Bug But Breaks Nearby Logic

The reported bug is gone, two adjacent features regressed. Cap blast radius, enumerate call sites, prefer caller-side guards over shared-util changes.

You filed: “fix the timezone bug in the booking flow.” Codex’s PR makes the bug go away — and silently changes how formatDate() handles null, which is called from 14 other places. The booking flow works; the email service now ships emails with 1970-01-01 because its null users to be displayed as ”—” used to be the contract.

The first instinct is “Codex broke things.” The actual root cause is unbounded blast radius: Codex was free to change shared code, didn’t audit consumers, and you didn’t ask it to. The fix is constraining changes by scope, requiring a consumer-impact report before merge, and preferring caller-side guards over shared-util mutations.

Common causes

Ordered by hit rate, highest first.

1. Fix touched a shared utility called from many places

formatDate, parseUser, serializeError, getEnv — utilities used everywhere. A “small fix” inside ripples to every consumer, half of which Codex didn’t read.

How to spot it: git diff --stat after Codex’s patch. If any utility / common file is in the diff but only one feature’s tests are passing, you’re missing the impact on the others.

2. Codex changed function semantics without changing the signature

The function still returns string, but now returns "" instead of null for missing input. Type-system thinks nothing changed; callers that did if (result === null) silently break.

How to spot it: Look for changes inside function bodies that alter return values, throw behavior, or side effects without changing the type signature. These are TypeScript-invisible breaks.

3. Patch added a new precondition / validation

getUser(id) previously accepted any string; Codex added “if id doesn’t match UUID regex, throw.” Old callers passing numeric IDs (“/users/42”) now crash.

How to spot it: New throw statements at the top of functions are red flags. Audit every caller for input shape.

4. Codex changed a side effect

saveSession(user) used to also write to Redis. Codex “cleaned it up” by removing the Redis write because “the function name says save, not cache.” Now session loads from cache fail.

How to spot it: Removed lines that did I/O (write to DB, queue message, emit event) inside a function whose name doesn’t obviously imply that I/O.

5. Tests covered the fix but not the neighbors

Codex added a regression test for the original bug; the test passes. But there’s no test for the email service that depends on the same util, so the regression is invisible until production.

How to spot it: Look at the test diff. If the only new test exercises the fixed path, the surrounding paths are untested.

6. The fix moved code between files, breaking imports elsewhere

Codex extracted a helper to lib/helpers.ts. Some files imported it from its old location (utils/format.ts); the moved version isn’t reexported. Type errors land in unrelated places.

How to spot it: pnpm typecheck reports errors in files not in Codex’s diff. The new errors are import-resolution failures.

Shortest path to fix

Ordered by ROI. Steps 1 and 2 prevent 80% of collateral damage.

Step 1: Constrain blast radius in the prompt

Task: Fix [specific bug] in [specific file/feature].

CONSTRAINTS:
- You may edit ONLY files in [target path].
- DO NOT change any shared utility (lib/, utils/, common/) without explicit approval.
- DO NOT change function signatures or return contracts.
- If the fix requires touching a shared util, STOP and list every caller first.

The “stop and list” clause is the critical lever — it forces Codex to surface the blast radius before acting.

Step 2: Require a consumer-impact report before applying

For any change touching a shared file:

Before applying the patch, produce this report:
1. List every file that imports the function/symbol you changed:
   grep -rn "import.*formatDate" --include="*.ts" --include="*.tsx" .
2. For each caller, describe in one sentence:
   - What input shape it passes
   - What return value it expects
   - Whether your change preserves that contract
3. Flag any caller whose contract is broken — fix or escalate.

Only after the report passes, apply the patch.

Step 3: Prefer caller-side guards over shared-util changes

If the booking flow needs formatDate to handle null differently, don’t change formatDate — change the booking caller:

// Bad — changes shared util, blast radius = all callers
function formatDate(d: Date | null): string {
  if (!d) return "—"; // changed
  return d.toISOString();
}

// Good — caller-side guard, blast radius = booking only
function renderBookingDate(d: Date | null): string {
  if (!d) return "—";
  return formatDate(d);
}

Rule of thumb: if the new behavior is feature-specific, the guard belongs in the feature.

Step 4: Run a wider test net than the bug’s own tests

In the prompt:

After applying, run not only the bug's test but:
- The full test file for any imported util you changed
- Every test file matching grep for the changed symbol

Report pass/fail count for each.

For a monorepo:

# Find tests that import any changed file
git diff --name-only origin/main -- 'src/**' | while read f; do
  symbol=$(basename "$f" .ts)
  grep -rln "from.*${symbol}" --include="*.test.ts" --include="*.spec.ts" . | sort -u
done

Step 5: Authorize shared-util changes deliberately

When a shared change really is needed, make the authorization explicit:

You may change `lib/format.ts` for this task.
Before applying:
1. List every consumer (`grep -rn "from.*lib/format"`).
2. For each, show me the line that uses the changed export.
3. Wait for my approval before applying.

This makes the cross-cutting change feel like a refactor (deliberate, reviewed) instead of a fix sneaking in extra scope.

Step 6: Add a “consumers” test layer for hot utilities

For utils used in >5 places, add a test file that exercises each consumer’s contract:

// lib/format.consumers.test.ts
import { formatDate } from "./format";

describe("formatDate consumers", () => {
  it("booking flow: handles null as '—'", () => {
    // booking-specific expectation
    expect(formatDate(null, { fallback: "—" })).toBe("—");
  });
  it("email service: handles null as empty string", () => {
    expect(formatDate(null, { fallback: "" })).toBe("");
  });
});

If Codex later changes formatDate, this file fails immediately and pinpoints which consumer’s contract broke.

Prevention

  • Default rule in AGENTS.md: “Do not change shared utilities (lib/, utils/, common/) without enumerating callers”
  • For any patch touching a shared file, require a consumer-impact report before merge
  • Prefer caller-side guards over modifying shared utils — keeps blast radius local
  • Test files for hot utils should include consumer-contract tests, not just unit tests
  • Run wider-than-strictly-necessary tests after shared changes: full feature suites, not just the bug’s own test
  • Treat shared-util edits as refactors, not fixes — separate PR, separate review, deliberate authorization

Tags: #Codex #Coding agent #Troubleshooting #Debug #Collateral break