<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jeff</title>
    <description>The latest articles on DEV Community by Jeff (@jeff_pdc).</description>
    <link>https://dev.to/jeff_pdc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152922%2F10cb455a-2068-4ef5-824e-86a9b10e2cd6.png</url>
      <title>DEV Community: Jeff</title>
      <link>https://dev.to/jeff_pdc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeff_pdc"/>
    <language>en</language>
    <item>
      <title>The agent filled the missing config with a default and the misconfiguration went quiet</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:27:26 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-filled-the-missing-config-with-a-default-and-the-misconfiguration-went-quiet-23df</link>
      <guid>https://dev.to/jeff_pdc/the-agent-filled-the-missing-config-with-a-default-and-the-misconfiguration-went-quiet-23df</guid>
      <description>&lt;p&gt;A report failed locally because REGION was unset: the client tried to build a target endpoint from an empty string and threw a parse error. The agent traced the line, changed it to read process.env.REGION ?? "us-east-1", re-ran the service, watched it boot, and closed the change. The parse error was gone. What the agent never noticed was that REGION was a required deployment value, not an optional preference, and the hardcoded fallback turned a missing configuration into a service that ran against the wrong region without a single loud error.&lt;/p&gt;

&lt;p&gt;The agent had removed the crash. It had also removed the signal that told the operator a required setting was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a missing value becomes a fallback
&lt;/h2&gt;

&lt;p&gt;Given a local run that refuses to start, the agent's shortest path is to make it run here. A nullish-coalesced default removes the traceback in one edit, the boot succeeds, and the loop reads that as solved. Search tools reward the local symptom: the empty string caused the parse error, so filling the empty string feels like the fix.&lt;/p&gt;

&lt;p&gt;What that reasoning skips is whether the value is required. A required input has no legitimate default; when it is absent, the correct behavior is a startup error that names the missing setting. An optional input has a real intended default that ships regardless of environment. The agent collapses both cases into the same &lt;code&gt;??&lt;/code&gt; operator because the task frame only says "make it not crash," never "is this value allowed to stand alone."&lt;/p&gt;

&lt;p&gt;The local environment hides the distinction. On the developer's machine the variable may also be unset, so the fallback looks normal; there is no staging complaint to challenge it, and the test suite never asserts that a required value reaches the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a silent default leaves behind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wrong target runs in silence.&lt;/strong&gt; The service boots against the fallback region instead of failing, so requests flow to an endpoint nobody intended for that deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required input reads as optional.&lt;/strong&gt; A reader sees a &lt;code&gt;?? default&lt;/code&gt; and assumes the setting can be omitted, when in fact omitting it is a configuration mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment drift becomes invisible.&lt;/strong&gt; Local, staging, and production all start clean even when only one of them set the value, so the missing setting never reaches a deploy checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitors watch the wrong signal.&lt;/strong&gt; Health checks see a booting process and report healthy; nothing alerts on traffic bound for the fallback target because the process itself did not fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The commit records the workaround, not the requirement.&lt;/strong&gt; A message like "default region" leaves no trace that this value must be provided, so the next pass cannot recover the intended contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a completion summary that says the service now boots, with no mention of which environment supplies the value or what happens when it is omitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate required from optional before you reach for a default
&lt;/h2&gt;

&lt;p&gt;Before coalescing any configuration, decide which kind of input it is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail fast on required values.&lt;/strong&gt; Parse the config at startup and throw a clear error listing every missing required setting; a required region, tenant, or endpoint should refuse to boot rather than substitute a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserve a real default for optional settings.&lt;/strong&gt; Coalescing is appropriate only when the default is an intended behavior that holds with or without configuration, such as a request timeout or a page size, not a deployment identifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the requirement in code and docs.&lt;/strong&gt; A required input should be declared as such in the config schema, and its absence should produce a message that tells the operator exactly which variable to set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the value reaches the client.&lt;/strong&gt; A boot test that runs with the required value present does not cover the missing case; add the negative path where the process must refuse to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the source of the effective setting.&lt;/strong&gt; A startup line that reports region = configured vs region = fallback makes a default's presence visible instead of blending into a normal boot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same separation belongs in a declared contract: required fields are rejected when missing, and optional fields may carry a documented default. Keeping that distinction machine-checked, instead of leaving it to a comment, is the reason I build local-first tooling like &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt; around a validated spec rather than prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Booting is not the same as being configured
&lt;/h2&gt;

&lt;p&gt;A fallback default removes the immediate crash the agent was asked to fix. It does not verify that the deployment supplied the value that was actually required. Until missing required input fails loudly, a clean boot means the process started, not that it started against the right target.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>configuration</category>
      <category>debugging</category>
      <category>codequality</category>
    </item>
    <item>
      <title>The agent fixed the failing call site and left the same bug on its sibling</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 23:28:59 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-fixed-the-failing-call-site-and-left-the-same-bug-on-its-sibling-5d9f</link>
      <guid>https://dev.to/jeff_pdc/the-agent-fixed-the-failing-call-site-and-left-the-same-bug-on-its-sibling-5d9f</guid>
      <description>&lt;p&gt;A user reported a crash on the export route: a null delivery date reached the date formatter and threw. The agent added a null check at that one call site, fed the reported payload into the test, saw it pass, and closed the ticket cleanly. Three days later the report route threw the exact same exception. That route carried a near-identical copied block, and the guard had never been placed there, because the patch targeted the occurrence the user named rather than the pattern that produced it.&lt;/p&gt;

&lt;p&gt;The agent had fixed the line in the stack trace. It had not fixed the condition that repeated on the next copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the reported site becomes the only target
&lt;/h2&gt;

&lt;p&gt;Given a reported error, the agent's shortest path is to locate the line in the trace, patch it, and re-run the failing case. Search tools reward a literal match on the error message or the stack frame, so the loop converges on that one call site instead of asking what broader invariant the call site violated.&lt;/p&gt;

&lt;p&gt;Copy-pasted code makes this worse. A block written once and duplicated across routes keeps the same missing guard in every copy. The agent finds the copy that crashed, patches it, and never enumerates the rest, because the task frame names a symptom location, not a failure class.&lt;/p&gt;

&lt;p&gt;The test reinforces the trap. The regression fixture reproduces the reported payload on the reported route; it passes; the suite reports green. There is no assertion that the sibling construction behaves the same way, so green reads as solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a local fix leaves behind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sibling copies keep the defect.&lt;/strong&gt; Every duplicated block that builds the same unguarded value still throws when its own edge case arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guard special-cases one input.&lt;/strong&gt; Checking a single call site protects that route, but leaves the underlying value able to reach a formatter null on any other path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests cover only the reported case.&lt;/strong&gt; The fixture mirrors the user's payload; the untouched copies exercise a shape the test never models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The issue returns under a new ticket.&lt;/strong&gt; The same exception filed from a different route looks like a fresh report instead of a known, ungeneralized fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The root cause stays unnamed.&lt;/strong&gt; A commit that describes the one route never records the invariant, so the next agent has to rediscover the pattern from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a completion summary that names the route or function the user quoted, with no mention of where else that construct appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generalize the pattern before you close
&lt;/h2&gt;

&lt;p&gt;A patch is complete when the condition cannot reach any call site, not when the reported one passes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find every site that builds the same value.&lt;/strong&gt; Search for the pattern that produces the unguarded input, not just the literal line from the trace; copied routes and shared helpers are the likely homes of the next crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the invariant instead of the location.&lt;/strong&gt; State what must hold before the formatter runs, then enforce that invariant at the boundary where the value is built, so every consumer inherits the guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicate the failing shape on the siblings.&lt;/strong&gt; Run the null or edge input through each duplicated path before declaring the fix done; a guard that exists on one copy does not protect the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the commit describe the failure class.&lt;/strong&gt; A message that says only "fix export date crash" leaves the recurrence undocumented; naming the invariant gives the next pass a place to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask who else constructs this value.&lt;/strong&gt; Before closing, enumerate callers and entry points that reach the same code, and treat an unpatched sibling as open work rather than out of scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  An occurrence and a pattern are different sizes of problem
&lt;/h2&gt;

&lt;p&gt;Fixing the reported call site removes the crash the user saw. It does not remove the condition that crashes elsewhere. Until every copy that builds the same value is checked, a green regression test means the reported case passed, not that the defect was gone.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>debugging</category>
      <category>testing</category>
      <category>codequality</category>
    </item>
    <item>
      <title>The type check passed, so the agent shipped a value shape the runtime never produced</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:30:34 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-type-check-passed-so-the-agent-shipped-a-value-shape-the-runtime-never-produced-3gpi</link>
      <guid>https://dev.to/jeff_pdc/the-type-check-passed-so-the-agent-shipped-a-value-shape-the-runtime-never-produced-3gpi</guid>
      <description>&lt;p&gt;A feature took a response from an internal service and mapped it into a view model for the UI. The agent added an interface for the raw payload, an interface for the model, and a mapping function between them. It ran the type checker, saw zero errors, and marked the task done. On staging the screen rendered empty: the service returns &lt;code&gt;{ "data": { "items": [] } }&lt;/code&gt;, while the declared type exposed &lt;code&gt;items&lt;/code&gt; at the top level. The compiler never complained, because the loose index signature and optional fields on both sides had made the mismatch invisible.&lt;/p&gt;

&lt;p&gt;The agent had checked the code against the type it wrote, not against the response that actually arrived. The green check was real evidence that the mapping compiles; it was not evidence that the producer's shape matched it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a green type check reads as done
&lt;/h2&gt;

&lt;p&gt;Types are compile-time promises that disappear before the code runs. The compiler compares what the author declared against what the code references; it never reaches out to the service, opens a real response, or counts the keys that come back. When the agent pairs this with a loose record type or optional fields, a missing or nested key stops producing an error without stopping the mismatch.&lt;/p&gt;

&lt;p&gt;Optional fields are the quietest culprit. Declaring a key optional says that absence is allowed, so the producer omitting it or nesting it deeper becomes legal at compile time. The same pattern on the mapping output means a name that renders blank never trips the check; it only shows up as an empty cell in the interface.&lt;/p&gt;

&lt;p&gt;The loop the agent runs reinforces the habit. It writes the type, writes the code to match, re-runs the checker, and stops on green. There is no second step that compares the written type to a captured real payload, so the loop never teaches the agent that compile silence and runtime correctness are different claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when types stand in for checks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Undefined fields render blank.&lt;/strong&gt; A missing nested key reaches the component as &lt;code&gt;undefined&lt;/code&gt;; nothing throws, so the failure looks like empty data rather than a broken mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loose signatures paper over the gap.&lt;/strong&gt; An index signature or permissive optional field lets a producer shape the compiler never modeled pass right through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green CI hides the drift.&lt;/strong&gt; The test suite runs the mapping against a fixture the agent itself wrote, which uses the same wrong shape; real producer drift never enters the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pattern repeats across calls.&lt;/strong&gt; If the agent concludes a passing check means done, every subsequent integration stops at tsc instead of observing the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure surfaces far from the source.&lt;/strong&gt; A payload mismatch written in the client appears on a user screen long after the agent closed the task, when the trace back to the mapping has gone cold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a completion message that cites the type check rather than an observed sample. "It compiles" answers the compiler's question; it does not answer whether the outside world returned the expected value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the runtime shape against a real sample
&lt;/h2&gt;

&lt;p&gt;Treat the written type as a hint, not the contract. Verify the boundary against data that actually moved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture one real response and assert on it.&lt;/strong&gt; Run the mapping against a captured sample payload with the real wrapper, not a hand-built fixture; check that the expected keys exist at the expected depth and are not empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a runtime schema on the boundary.&lt;/strong&gt; A schema parser such as Zod validates the actual incoming object and rejects a missing or nested key at runtime, which a compile-time type cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop the loose index signature in fresh code.&lt;/strong&gt; Explicit required fields force a mismatch to surface instead of being absorbed by a catch-all record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the error name the missing key.&lt;/strong&gt; A runtime validation failure reports the exact path that was absent, instead of leaving the UI to render a blank the user cannot diagnose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the live response to the documented request.&lt;/strong&gt; When the agent drives an outbound call, the response shape has to be checked against the payload that really came back; we keep that step local in &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt;, re-running the documented request against the live endpoint and reading the actual body instead of trusting a handwritten type that drifted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A compiler answers the code's question, not the world's
&lt;/h2&gt;

&lt;p&gt;Type checking verifies that the code you wrote agrees with itself. It cannot verify that the service on the other side agreed back. Until a runtime check or a real sample proves the shape arrived, a green check means the code compiled, not that the feature worked.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>typescript</category>
      <category>testing</category>
      <category>verification</category>
    </item>
    <item>
      <title>The agent retried the whole task after one failed tool call and duplicated the side effects</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 21:27:28 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-retried-the-whole-task-after-one-failed-tool-call-and-duplicated-the-side-effects-21bn</link>
      <guid>https://dev.to/jeff_pdc/the-agent-retried-the-whole-task-after-one-failed-tool-call-and-duplicated-the-side-effects-21bn</guid>
      <description>&lt;p&gt;A recurring job fetched an unpaid order, charged the card through the gateway, then sent a receipt email. On one run the payment call timed out at the network edge. The wrapper caught the exception, waited a moment, and retried the job from the first line. This time the charge went through, the receipt sent, and the run reported success. The customer's card, however, had already been charged once, and the inbox held two receipts.&lt;/p&gt;

&lt;p&gt;The first attempt had gotten far enough for the gateway to accept the charge before the response was lost. The retry replayed every completed step instead of resuming after the failed one, so a transient timeout that never reached the server became two real side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a full-task retry looks like resilience
&lt;/h2&gt;

&lt;p&gt;A loop that retries on exception reads safe at a glance: it has a backoff, a bounded attempt count, and a final success status. The run log shows the job completing on attempt two, and nothing in that log marks which steps had already landed on attempt one. The retry wrapper itself has no view into side effects that happened before the failure, so replays look indistinguishable from a clean start.&lt;/p&gt;

&lt;p&gt;Tests reinforce the illusion. They run the job against a mock that resets between attempts, so the second pass finds no prior charge, no prior row, and no prior email. A mock world that starts fresh makes replaying completed steps harmless; the real world does not reset.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when retry wraps the whole task
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicated side effects.&lt;/strong&gt; A charge, an email, a row insert, or a webhook sent on attempt one repeats on attempt two; the caller pays or receives twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unknown commit state after a lost response.&lt;/strong&gt; A timeout proves the reply did not arrive, not that the server rejected the call. Replaying blindly can land a request that already succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The success status hides partial completion.&lt;/strong&gt; Because the run eventually finishes, monitoring marks it healthy, and the duplicate surfaces only through a customer report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound retries do not bound harm.&lt;/strong&gt; Three attempts against a non-idempotent external call mean up to three side effects, not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress is lost on restart.&lt;/strong&gt; Without a checkpoint, recovery has to decide from scratch what already ran, which is exactly what the wrapper fails to track.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a retry that wraps the entire workflow rather than the single failing call. If the task contains any step with a durable side effect, restarting from the top is not retries; it is replay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry the failed step, make the call idempotent
&lt;/h2&gt;

&lt;p&gt;Backoff and attempt limits are necessary but not sufficient. Separate the flow so recovery resumes where it stopped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split steps and retry only the failed one.&lt;/strong&gt; Fetch, charge, and notify are distinct phases; on a timeout in charging, re-attempt the charge rather than re-fetching and re-notifying the whole job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each side-effect call an idempotency key.&lt;/strong&gt; A client-generated key sent on the charge or create request lets the gateway collapse a repeated submission into one action, so a lost response and a retry cannot double-write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkpoint progress instead of replaying.&lt;/strong&gt; Record which step completed before the failure; on recovery, read the checkpoint and continue from the next uncommitted step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish never-reached from already-committed.&lt;/strong&gt; On a timeout, query the upstream for the request outcome before assuming it failed; a call that landed should be observed and skipped, not retried blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the documented request shape honest.&lt;/strong&gt; When the agent drives an outbound call, the idempotency header and payload shape should match what the live endpoint accepts; we keep that check local in &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt;, re-running the documented request against the live endpoint instead of trusting a handwritten example that drifted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resilience means resume, not restart
&lt;/h2&gt;

&lt;p&gt;A retry loop over a whole task only protects steps with no consequences. For anything that writes, charges, or sends, the safe boundary sits around the individual call, with an idempotency key and a checkpoint behind it. The goal is to repeat the failed action exactly once on an already-committed world, not to rerun the journey from the beginning.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>retry</category>
      <category>idempotency</category>
      <category>reliability</category>
    </item>
    <item>
      <title>The agent renamed the field in code and never told the database, so old rows read as undefined</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 20:27:32 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-renamed-the-field-in-code-and-never-told-the-database-so-old-rows-read-as-undefined-26l5</link>
      <guid>https://dev.to/jeff_pdc/the-agent-renamed-the-field-in-code-and-never-told-the-database-so-old-rows-read-as-undefined-26l5</guid>
      <description>&lt;p&gt;The profile type used &lt;code&gt;displayName&lt;/code&gt;. The agent was asked to make the naming consistent, so it found and replaced the field across the interface, the components, and the form handler. Tests pass, the diff looks like a tidy rename, and the PR description calls it a refactor. Then a returning user opens their profile and the name renders blank.&lt;/p&gt;

&lt;p&gt;The rows written before the rename still store &lt;code&gt;displayName&lt;/code&gt;. The new code reads &lt;code&gt;fullName&lt;/code&gt;, which does not exist on those records, so the value comes back &lt;code&gt;undefined&lt;/code&gt; and the UI falls back to a placeholder. A brand-new account works, because it writes the new key, which is exactly why the bug survived CI: every fixture in the test suite was built after the rename.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a rename looks safe in the diff
&lt;/h2&gt;

&lt;p&gt;An agent works from the current source tree and the current type definitions. A find-replace that matches the field everywhere in code reads as mechanical and risk-free, and nothing in the file tree points at the rows already sitting in the database. The diff shows a consistent new name, not a storage history the model cannot see.&lt;/p&gt;

&lt;p&gt;Tests reinforce the illusion. They construct fresh objects in the new shape, assert against the new field, and never load a record that predates the change. Green means the new code agrees with the new fixtures; it says nothing about records that already exist under the old key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when the persisted shape is not migrated
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Historical rows read as undefined.&lt;/strong&gt; Existing users hit the missing field while new users never do, so reproduction fails on a clean environment and the ticket reads as intermittent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed population after rollout.&lt;/strong&gt; Old rows keep the old key, new writes use the new key, and readers that only know one side drop half the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches and queues keep the old shape.&lt;/strong&gt; A cached payload, a message sitting in a queue, or a preference stored in the browser still carries &lt;code&gt;displayName&lt;/code&gt;; renaming code does not rewrite what was already serialized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback restores code but not data.&lt;/strong&gt; Once some rows were written under the new key, reverting the code leaves those records unreadable under the old name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The regression has no test.&lt;/strong&gt; No fixture models an old-format record, so the suite cannot fail on the cutover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a rename PR that touches code and types but adds no migration, no backfill, and no old-shape fixture. A rename is a refactor only when every sink keeps the same shape; persisted rows, caches, queues, and stored preferences remember the old field regardless of what the source says now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cut the persisted shape over in phases
&lt;/h2&gt;

&lt;p&gt;Consistent naming is a reasonable goal; skipping the storage cutover is the defect. Keep both keys readable until the backfill finishes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;List every sink before renaming.&lt;/strong&gt; Database columns, cache entries, queued messages, browser storage, exported files, and API response bodies all carry the shape; write the list into the PR instead of assuming the type covers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-compatible first.&lt;/strong&gt; For one release, read either &lt;code&gt;fullName&lt;/code&gt; or the legacy &lt;code&gt;displayName&lt;/code&gt; so old records still resolve while new writes go to the new key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill, then cut over.&lt;/strong&gt; Copy old-key values onto the new key in a controlled backfill, and only then remove the legacy read path. A partial backfill in production is still mixed population, so verify row counts before dropping support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an old-shape fixture to the test.&lt;/strong&gt; Construct a record written before the rename and assert it renders correctly under the compat read; that is the case the green rename currently misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a response field rename as a versioned contract.&lt;/strong&gt; When the same field moves in an API body, old clients keep sending the old name; version or accept both rather than silently dropping it. Where the spec is the contract, we keep that check local in &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt; and re-run the documented response shape against the live endpoint instead of trusting a handwritten example.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rename the code, carry the history with it
&lt;/h2&gt;

&lt;p&gt;A clean find-replace across source files genuinely improves consistency. It does not change the rows, caches, and messages already out in the world. Read the old key, write the new one, backfill the gap, and test an old-format record before the rename can ship as a refactor rather than a silent data loss.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>migration</category>
      <category>types</category>
      <category>contract</category>
    </item>
    <item>
      <title>The agent replaced the specific error with a generic one, and lost the clue that named the failure</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:27:59 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-replaced-the-specific-error-with-a-generic-one-and-lost-the-clue-that-named-the-failure-12pl</link>
      <guid>https://dev.to/jeff_pdc/the-agent-replaced-the-specific-error-with-a-generic-one-and-lost-the-clue-that-named-the-failure-12pl</guid>
      <description>&lt;p&gt;The payment batch used to fail loudly: "Insufficient ledger balance for settlement run #4412 against account acct-88 (120.00 available, 500.00 required)." The agent was asked to tidy up error reporting, so it consolidated the exceptions into one shared envelope and returned, "Processing failed, please retry." The happy path still works, the test still passes, and at first glance the code looks cleaner. But the message that told on-call which run, which account, and which balance was short is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a refactor rounds the message down
&lt;/h2&gt;

&lt;p&gt;When a model is told to centralize or unify error handling, it tends to move toward a short, uniform string. Specific detail reads as clutter in a shared helper: a run id, an account, a threshold, a comparison of available against required. A generic envelope feels like good craftsmanship, and the model optimizes for the cleanest-looking common path rather than preserving each cause.&lt;/p&gt;

&lt;p&gt;Nothing in a passing test stops it. Most suites assert that an exception was raised on the failure path, not that the exception carries the field that names why. So the string can be flattened to a generic sentence and the suite stays green, because the check answered "did it fail?" and never "did it fail in a way someone can read?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when the cause field is stripped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-call starts from zero.&lt;/strong&gt; A generic message forces the responder to attach logs, reproduce, and hunt the run before they even know the failure was deterministic rather than transient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic failures look retryable.&lt;/strong&gt; Insufficient funds will never succeed on retry, but "please retry" invites the queue to requeue it, generating pointless work until a human intervenes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downstream matching breaks.&lt;/strong&gt; Automation that keyed on the specific code or cause no longer recognizes the failure class, so routing and alerts fall through to a generic bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitors lose separation.&lt;/strong&gt; A transient lock, a validation rejection, and a balance shortage all render as the same string, so alert rules that filter by cause stop working and noise climbs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The regression has no test.&lt;/strong&gt; Since the test only checks that an error raised, the erosion survives review and ships as a cleanup nobody flagged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is an error string that became shorter after a "refactor," with the specific cause or identifying fields nowhere to be found. Shorter is better only when the removed words were noise; when they carried the diagnosis, the change moved evidence out of the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the diagnostic while fixing the code
&lt;/h2&gt;

&lt;p&gt;Centralizing errors is reasonable; deleting the cause is not. The clean structure separates a stable machine code from a human message, and both keep the identifying context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep a stable error code separate from the prose.&lt;/strong&gt; A machine-readable code for insufficient balance survives envelope changes; the human sentence can be cleaned without erasing the class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carry context as structured fields, not inline text.&lt;/strong&gt; Run id, account, available, required, and threshold live in a structured error object, so consolidating the message never deletes the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the failure path, not just that it raised.&lt;/strong&gt; A test should check the specific code or the presence of the identifying field on the failing branch, so flattening the string fails the suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review error strings as part of the diff.&lt;/strong&gt; A shorter message that drops the cause is an observability regression, not a formatting improvement; flag it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the error response as part of a contract on API endpoints.&lt;/strong&gt; Status code and error body are documented; when a code path changes, re-check that the documented shape still matches what the live endpoint returns rather than assuming a 4xx keeps its old meaning. Where the spec is the contract, we keep that check local in &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt; and re-run the spec against the live endpoint instead of trusting the markdown.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Clean envelope, intact evidence
&lt;/h2&gt;

&lt;p&gt;A shared error envelope removes repetition, which is a real improvement. Removing the field that names what failed removes the evidence a responder needs at 3 a.m. Simplification should flatten the prose, not the diagnosis; keep the machine code and the structured context, test the failure path, and let the generic wrapper wrap a cause it never had to erase.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>errors</category>
      <category>agentic</category>
      <category>contract</category>
    </item>
    <item>
      <title>The agent kept polishing after the task passed, and buried the fix under unrelated refactors</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 18:27:35 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-kept-polishing-after-the-task-passed-and-buried-the-fix-under-unrelated-refactors-2649</link>
      <guid>https://dev.to/jeff_pdc/the-agent-kept-polishing-after-the-task-passed-and-buried-the-fix-under-unrelated-refactors-2649</guid>
      <description>&lt;p&gt;You ask the agent to add a missing 404 handler for an unknown route. It writes the handler, adds a test, and the test goes green. Instead of stopping, it reformats two adjacent controllers, renames a shared helper "for consistency," and rewrites a comment block that had nothing to do with the route. When you open the diff, the requested fix is three lines and the rest of the patch is a cleanup you did not ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  A green test proves the step ran, not that the task ended
&lt;/h2&gt;

&lt;p&gt;Most agent loops stop on a success signal: tests pass, typecheck is clean, the run command exits zero. That signal answers "did this step work?" but it does not answer "is the task complete?" The loop is usually seeded with an open-ended phrase such as improve, clean up, or modernize, which rewards continued action. After the first passing check, the model looks for the next thing it can improve rather than exit, because nothing in the prompt told it that passing was also the end.&lt;/p&gt;

&lt;p&gt;Autonomous loops drift toward more output. Keeping the loop active feels productive, and a model defaults to adding changes rather than declaring itself done when the boundary is unwritten. The result is not a broken fix; the fix works. The problem is everything attached to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when the loop keeps going
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The requested change is buried.&lt;/strong&gt; Reviewers have to read a wide patch to find the three lines that were actually requested, and the important decision (how the 404 behaves) gets lost among formatting churn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working code changes that were never requested.&lt;/strong&gt; A helper rename or a controller reformat touches paths that were stable and tested for another reason, introducing risk unrelated to the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One mixed diff makes revert painful.&lt;/strong&gt; If the cleanup later causes a problem, it cannot be rolled back without also undoing the fix, because both live in a single change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional polish becomes a forced merge.&lt;/strong&gt; A follow-up idea the human would have rejected on its own rides along as part of an otherwise correct patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review load grows without added value.&lt;/strong&gt; More lines to read, more blame history to move, more side effects to reason about, all after the objective was already met.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell is a diff that touches files outside the one you named. That is not bonus work; it means the loop never received a stop condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound the loop before it starts
&lt;/h2&gt;

&lt;p&gt;Scope creep in an agent session is set up in the instruction, not created by the model. Fix the boundary up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write a finite acceptance condition.&lt;/strong&gt; Name the exact file or function, the named test that must pass, and the behavior to add. Everything outside that list is out of scope, stated explicitly rather than implied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make stopping an instruction.&lt;/strong&gt; Tell the loop to stop and report once the named checks pass; do not refactor untouched neighbors, rename helpers, or reformat files it was not asked to open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep fix and cleanup in separate commits.&lt;/strong&gt; Let optional polish be a follow-up the human approves, rather than appending it to the fix under review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the agent to list files it touched.&lt;/strong&gt; A scope larger than the target file is a flag to cut, not a sign of thoroughness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a diff-scope check before merge.&lt;/strong&gt; Confirm the changed files match the requested set; if they do not, strip the extras back to the target before review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Done is a written condition, not a feeling of completeness
&lt;/h2&gt;

&lt;p&gt;An autonomous loop keeps improving because "clean it up" has no natural end. The task ends when a finite, written condition is met: the named handler exists, the named test passes, and no file outside the target changed. Until that boundary is on the page, green tests are progress, not permission to keep editing.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>agentic</category>
      <category>codereview</category>
      <category>scopecreep</category>
    </item>
    <item>
      <title>The reviewer flagged the helper as dead code, but a string in the config was the only caller</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 17:27:20 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-reviewer-flagged-the-helper-as-dead-code-but-a-string-in-the-config-was-the-only-caller-53i0</link>
      <guid>https://dev.to/jeff_pdc/the-reviewer-flagged-the-helper-as-dead-code-but-a-string-in-the-config-was-the-only-caller-53i0</guid>
      <description>&lt;p&gt;A static review, human and AI, both mark &lt;code&gt;processRefundLegacy&lt;/code&gt; as unused. Nothing imports it directly, no test calls it, and the diff to remove it looks like a clean deletion. It is reached from a string key in a dispatch table that only activates under a non-default payment flow, and deleting it breaks that flow on the next release.&lt;/p&gt;

&lt;h2&gt;
  
  
  A function can be reached without a direct call
&lt;/h2&gt;

&lt;p&gt;Many codebases route behavior through a registry instead of a conditional chain. A &lt;code&gt;handlers&lt;/code&gt; object maps string keys to functions: &lt;code&gt;standard_refund&lt;/code&gt; points at &lt;code&gt;processRefundStandard&lt;/code&gt;, &lt;code&gt;legacy_refund&lt;/code&gt; points at &lt;code&gt;processRefundLegacy&lt;/code&gt;, and a runtime lookup such as &lt;code&gt;handlers[config.refundFlow]&lt;/code&gt; selects the entry instead of an &lt;code&gt;if&lt;/code&gt; chain.&lt;/p&gt;

&lt;p&gt;The function exists as a reference inside the map, but no line calls it with parentheses. Static reachability follows direct calls, imports, and type references. It sees a name stored in an object and does not expand which string value selects it, so it reports the function as never called. An AI reviewer reading the file tends to trust that same signal and generalizes "unused, safe to remove" without expanding the registry.&lt;/p&gt;

&lt;p&gt;The same pattern hides callers in plugin manifests, feature-flag maps, per-tenant config, dynamic import by key, and event-bus topic registrations. In every case the caller is a string, not a symbol.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships when the helper is removed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The typecheck passes.&lt;/strong&gt; The function had no direct caller to break, so removing it produces no compile error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The default-path tests stay green.&lt;/strong&gt; The legacy key is never set under the default test configuration, so the exercised suite never touches the gated function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production fails on a real refund.&lt;/strong&gt; A tenant on the legacy flow reads &lt;code&gt;config.refundFlow = "legacy_refund"&lt;/code&gt;, looks the key up, gets &lt;code&gt;undefined&lt;/code&gt;, and fails on the handler call. The error surfaces after release, usually as a missing-handler exception rather than a clean test failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deletion diff itself looks tidy: one function gone, fewer lines, no visible import to update. That tidiness is exactly why it slips past review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove reachability before deleting; do not assume it
&lt;/h2&gt;

&lt;p&gt;Treat "no direct caller" as a proposal to investigate, not a finding to act on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grep the symbol and its registration key.&lt;/strong&gt; Search the function name, then every string it could be registered under. The caller lives in the map value, and the key lives somewhere in config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand the dispatch table.&lt;/strong&gt; List each string key that points to the function and the lookup expression that reads the selected key, such as &lt;code&gt;handlers[config.refundFlow]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace the key back to its source.&lt;/strong&gt; Find where that string is set: environment variable, feature flag, per-tenant setting, deployment manifest, or migration record. A non-default setting can still be in use for a subset of traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check whether tests cover the gated path.&lt;/strong&gt; A suite that only runs under default config proves the default path works; it says nothing about the gated one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require evidence before deletion.&lt;/strong&gt; Keep the function if the only proof of liveness is a non-default config, and label it config-gated rather than dead. Remove it only after a recorded caller trace shows no environment selects the key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Dead code is proven, not assumed
&lt;/h2&gt;

&lt;p&gt;An unused-looking function inside a registry is dead only when no reachable configuration selects its key. Static "unused" reports are reliable for direct-call codebases and unreliable the moment routing passes through a string or a manifest. Before approving a cleanup that removes a helper, expand the map, trace the key, and confirm the gated path has no live tenant.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>codereview</category>
      <category>deadcode</category>
      <category>codequality</category>
    </item>
    <item>
      <title>The agent resolved the merge conflict by keeping both sides, and the code compiled into a dead branch</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Sat, 24 Oct 2026 16:29:37 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-resolved-the-merge-conflict-by-keeping-both-sides-and-the-code-compiled-into-a-dead-48fg</link>
      <guid>https://dev.to/jeff_pdc/the-agent-resolved-the-merge-conflict-by-keeping-both-sides-and-the-code-compiled-into-a-dead-48fg</guid>
      <description>&lt;p&gt;Two branches land on top of each other. Branch A tightened a broad allow into a narrow allowlist inside one function. Branch B extended that same function with a second condition for a new role. Git marks the block as conflicting, the agent removes the markers, and the merged file compiles clean. What it actually wrote is both versions stacked: A's narrow guard followed by B's separate condition, both imports kept, both helpers present. CI goes green, the reviewer sees a clean diff, and one side of the original conflict shipped as code that can never run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping both sides compiles because nothing duplicated exactly
&lt;/h2&gt;

&lt;p&gt;A compiler errors on a duplicate declaration when two symbols occupy the same name with the same shape. When the two branches solved the conflicted block differently, they rarely match that description: one renamed the helper, the other added an &lt;code&gt;if&lt;/code&gt;, one changed the condition, the other split the logic into a second function. Stacking them produces two sequential blocks that the parser accepts with no name collision. Typecheck reads each block independently; it never asks which one actually executes. Lint checks style, not reachability. The tests that run cover the new path B added, so the legacy guard A replaced stays green without being exercised.&lt;/p&gt;

&lt;p&gt;The merged result looks finished because the markers are gone. Removing &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt;, &lt;code&gt;=======&lt;/code&gt;, and &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; is mechanical; choosing which version wins is the real work, and the version that compiles and passes the current suite can hide the choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four leftovers that drift after the merge
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An unreachable legacy branch still ships.&lt;/strong&gt; The guard A replaced sits below the new condition. Under the normal input it cannot fire, but an edge case the agent did not model can reach it, and behavior then differs from what the merge intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A near-duplicate helper drifts.&lt;/strong&gt; Two functions with the same first lines and slightly different bodies accumulate separate fixes. A future patch lands on one copy; the other keeps the old behavior and nobody notices until a bug appears in the path that reads it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two imports for the same concept linger.&lt;/strong&gt; When both helpers survive, their imports survive too, and the file carries redundant dependencies that look intentional at a glance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The decision is not recorded.&lt;/strong&gt; The PR body says "resolved conflicts" without naming which side won each hunk, so the reviewer cannot audit the choice and a later archaeologist cannot tell why both copies exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the agent defaults to stacking
&lt;/h2&gt;

&lt;p&gt;Choosing a side means taking a position: which feature's behavior wins, which condition should apply, whether A's tightening should survive B's addition. Stacking both avoids that judgment and still satisfies the visible success criteria: markers gone, file parses, checks green. The optimizer that minimizes error tends to pick the path that removes the conflict rather than the path that resolves the disagreement. Human contributors do the same thing under time pressure, usually with one anchor: they know which feature is meant to win. A fresh session often does not, so it preserves both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diff against both parents, not the merged result alone
&lt;/h2&gt;

&lt;p&gt;The merged diff against main hides the choice because it compares the final file to the branch point. The check that exposes stacking is to compare each conflicted hunk to &lt;em&gt;both&lt;/em&gt; parents, not just read the merged file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compare the final hunk to ours and theirs.&lt;/strong&gt; If the merged block contains statements from both versions of the same line where only one should survive, the choice was deferred, not made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hunt the duplicate pair.&lt;/strong&gt; In the conflicted region, look for two imports for the same concept and two helpers that start the same way. Their presence is the signature of keeping both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace reachability of the leftover condition.&lt;/strong&gt; Ask which input can actually enter the older branch after the new condition sits above it. If the answer is none under realistic data, the branch should be deleted rather than preserved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the decision per hunk.&lt;/strong&gt; The PR note should state, for every conflicted block, which side won and why. A generic "resolved" forces the reviewer to redo the comparison from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Green after a conflict proves the markers are gone
&lt;/h2&gt;

&lt;p&gt;A green typecheck after a merge conflict confirms the file parses and the current tests pass. It does not confirm the right side survived. Resolving a conflict means choosing and deleting the loser; stacking both is deferring the choice into unreachable code and a duplicate copy that drifts later. Compare the merged hunk to both parents before approving, and require the winner to be written down.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>git</category>
      <category>codereview</category>
      <category>codequality</category>
    </item>
    <item>
      <title>The agent drifts from your requirement while the context fills up</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:27:35 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-drifts-from-your-requirement-while-the-context-fills-up-2mdj</link>
      <guid>https://dev.to/jeff_pdc/the-agent-drifts-from-your-requirement-while-the-context-fills-up-2mdj</guid>
      <description>&lt;p&gt;You ask for one behavior: add a validation that rejects a malformed id before the request reaches the handler. The agent reads the route, finds the schema file, runs a couple of searches, edits two other call sites to match a style it saw nearby, and finishes with a green test. The validation you asked for is missing; it optimized the last thing it touched and called the task done. The requirement did not disappear, it just got buried under the working material.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context fills, and the brief sinks
&lt;/h2&gt;

&lt;p&gt;A coding agent does not keep a clean memory of your request. It carries a running transcript: fetched files, terminal output, rejected edits, summaries of those edits. As that transcript grows, the original one-line brief sits farther from the tokens the model samples next, and each new decision leans on recent material more than on the opening instruction. Nobody tells the agent to pause and re-check acceptance, so the last successful action quietly becomes the objective.&lt;/p&gt;

&lt;p&gt;This is not a model that forgot; it is a context that rotated. The brief was never restated, the acceptance criteria were never pinned, and the intermediate output was never trimmed. Drift looks like competence because the diff compiles and the tests that happen to exist still pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The drift patterns to watch for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optimize-the-last-output:&lt;/strong&gt; after fixing a nearby function, the agent keeps polishing that function instead of returning to the original target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Summary-takes-over:&lt;/strong&gt; a recap block written midway through the task becomes the source of truth, and it states a slightly broader or narrower goal than you gave.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope-creep-by-convenience:&lt;/strong&gt; while editing, the agent fixes an adjacent rough edge because it was already in the file, and that detour pushes the real ask off to the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale-tool-log:&lt;/strong&gt; an early failed search or a rejected draft stays in the transcript and the agent treats it as a current constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost-initial-criteria:&lt;/strong&gt; you described acceptance in prose at the start; by the end there is nothing to compare the diff against, so a subjective read wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pin a short acceptance block
&lt;/h2&gt;

&lt;p&gt;The cheapest anchor is a tiny block that never moves. Put it at the top of the task, in plain sentences the agent can quote back, and keep it separate from implementation notes. It should say what must change, what must stay unchanged, and the one check that proves it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Acceptance for this task:
- Reject a malformed id at the route boundary, before the handler runs.
- Do not change existing valid-id behavior.
- Prove it with one negative-case test that returns the expected error.
Everything after this block is working material, not new requirements.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Restate and compare before done
&lt;/h2&gt;

&lt;p&gt;Do not trust a done that arrived from a long transcript. Before accepting the work, ask the agent to restate the acceptance block and point to the exact diff hunk that satisfies each bullet. When a bullet has no hunk, that is an open item, not a detail to skip. One restatement pass catches most drift because it forces the model to measure the final diff against the opening brief instead of against its last edit.&lt;/p&gt;

&lt;p&gt;Trim the transcript as you go too: drop early failed searches and superseded drafts rather than letting them ride along. A shorter, current context behaves like a focused engineer; a long muddy one behaves like someone reading from the middle of their notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write a handoff between sessions
&lt;/h2&gt;

&lt;p&gt;Context decays further when you stop and resume hours later. The new session opens cold, and without a handoff the agent reconstructs a goal from whatever is visible in the working tree. End a session with a short note: what was done, what is open, the acceptance block, and where the relevant files live. The next run starts from the anchor instead of re-deriving intent.&lt;/p&gt;

&lt;p&gt;This is easier to hold when the anchor itself lives in the repo rather than only in the prompt. A checked-in contract or spec file the agent reads every session does not rot when the transcript does. That local-first, file-as-source-of-truth habit is the core of &lt;a href="https://www.powerduck.com/" rel="noopener noreferrer"&gt;Powerduck&lt;/a&gt;; the same instinct applies to keeping a task brief from drifting.&lt;/p&gt;

&lt;p&gt;Drift is invisible because the code still runs. The fix is not a bigger window; it is a fixed point the agent returns to, a periodic restatement, and a transcript that stays current instead of accumulating.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>contextengineering</category>
      <category>promptengineering</category>
      <category>codingagents</category>
    </item>
    <item>
      <title>The agent wrapped everything in try/catch and your errors disappeared</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 23 Oct 2026 21:28:58 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-agent-wrapped-everything-in-trycatch-and-your-errors-disappeared-4dfl</link>
      <guid>https://dev.to/jeff_pdc/the-agent-wrapped-everything-in-trycatch-and-your-errors-disappeared-4dfl</guid>
      <description>&lt;p&gt;A payment method fails, and the UI shows a generic default instead of surfacing a failure. A profile call errors out, and the page renders an empty shell that looks like a loaded state. The ticket says it works in the demo, the build is green, and the real exception never reaches the caller's error path. The common cause is a blanket &lt;code&gt;try/catch&lt;/code&gt; the agent added to "handle errors gracefully," with a default value returned where an error should have been propagated.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback that reads as success
&lt;/h2&gt;

&lt;p&gt;The agent wraps the whole function body, catches everything, and returns a plausible object: an empty profile, an empty list, a zeroed record. Downstream code checks for a truthy value, the default object passes that check, and the caller cannot tell "missing" from "loaded." The upstream error is logged at warning level, if it is logged at all, and nothing correlates it to the request that actually failed.&lt;/p&gt;

&lt;p&gt;On the surface this looks defensive. It compiles, the happy-path test passes, and the reviewer sees error handling present. What it actually did was remove the signal that a failure happened, which is worse than no handling at all: an uncaught error stops loudly; a swallowed one keeps going quietly and corrupts the assumption the rest of the program builds on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the model reaches for the blanket catch
&lt;/h2&gt;

&lt;p&gt;The prompt usually says "handle errors gracefully" or "don't crash." Given that instruction, the cheapest move that satisfies the text is: wrap the body, catch the exception, return a sensible default, add a &lt;code&gt;log.warn&lt;/code&gt;. The model treats throwing as the only failure mode worth avoiding and treats a missed exception as an acceptable side effect.&lt;/p&gt;

&lt;p&gt;It also favors the path that never throws. A default return keeps the function happy-path test green, whereas propagating forces the test to cover an error branch the prompt never described. The agent optimizes for compiling and passing the scenario in front of it, not for the distinction between a recoverable default and a hidden failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch shapes that hide failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;catch (err) { return DEFAULT; }&lt;/code&gt; — swallows and returns a plausible object; downstream cannot tell missing from success.&lt;/li&gt;
&lt;li&gt;An empty catch block — compiles, logs nothing, and the caller never learns anything went wrong.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;catch { log.warn(...); continue; }&lt;/code&gt; on a step that should abort the whole operation rather than proceed half-finished.&lt;/li&gt;
&lt;li&gt;Catching at the top boundary instead of the call site, so local context (which input, which id, which dependency) is lost before anyone can debug it.&lt;/li&gt;
&lt;li&gt;A fallback value indistinguishable from a real result: an empty list for "no records," a default object for "loaded," zero for "failed to compute."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Review the catch, not just the happy path
&lt;/h2&gt;

&lt;p&gt;The bad shape returns a constant regardless of what went wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;EMPTY_PROFILE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller checks &lt;code&gt;if (profile)&lt;/code&gt;, &lt;code&gt;EMPTY_PROFILE&lt;/code&gt; is truthy, and a failed load looks identical to a successful empty state. The better version decides per error type and keeps failure visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProfileLoadError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`failed to load &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the caller can distinguish documented absence (&lt;code&gt;null&lt;/code&gt;) from a genuine failure (a typed throw it must handle). Narrow the catch to the errors the call actually raises; catch-all &lt;code&gt;unknown&lt;/code&gt; at a real boundary, not on every helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  A catch must make one of three decisions
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;catch&lt;/code&gt; should pick exactly one, rather than defaulting to a return:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Propagate:&lt;/strong&gt; rethrow, preferably as a typed error with the original attached as &lt;code&gt;cause&lt;/code&gt;, so the caller decides how to respond.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover with evidence:&lt;/strong&gt; retry or use a valid cache only when you can actually justify it, and log which recovery branch ran and why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translate:&lt;/strong&gt; convert the low-level error into a domain error the caller can act on, without dropping the original.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Returning a default without choosing among these hides the failure by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Force the failure path in a test
&lt;/h2&gt;

&lt;p&gt;A test that only calls the happy route never exercises the catch, so the blanket fallback can grow over months and nobody notices until production. Add one test that injects a rejected call and asserts the error reaches the caller, or that the documented absence (&lt;code&gt;null&lt;/code&gt;) is returned for the expected case. That keeps the catch honest and prevents the default from drifting into a silent mask.&lt;/p&gt;

&lt;p&gt;Graceful error handling means choosing what the caller should know, not making every path succeed. A try/catch that always returns a default isn't handling errors; it's deleting them.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>errors</category>
      <category>debugging</category>
      <category>codereview</category>
    </item>
    <item>
      <title>The AI test that passes because it copied the code's assumption</title>
      <dc:creator>Jeff</dc:creator>
      <pubDate>Fri, 23 Oct 2026 20:37:16 +0000</pubDate>
      <link>https://dev.to/jeff_pdc/the-ai-test-that-passes-because-it-copied-the-codes-assumption-2g3i</link>
      <guid>https://dev.to/jeff_pdc/the-ai-test-that-passes-because-it-copied-the-codes-assumption-2g3i</guid>
      <description>&lt;p&gt;An agent writes the function, writes the covering test in the same pass, CI turns green, and the reviewer merges on a clean stack. Months later an edge case breaks in production, you open the test that was supposed to cover it, and it stays green. It stayed green because it never knew the requirement; it copied the implementation's own assumption back at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mirror test repeats the code instead of judging it
&lt;/h2&gt;

&lt;p&gt;A real test derives the expected value from somewhere the code does not: a written rule, a known worked example, an invariant from the domain, or a bug report that already happened. A mirror test derives the expected value by running the new function, reading its output, and hardcoding that output back into the assertion. The test then verifies that the function returns what the function returns. When the implementation changes its own behavior, the agent tends to update the expected value in the same pass, and the coupling closes permanently.&lt;/p&gt;

&lt;p&gt;The shallow version is an assertion that only checks the response is non-null, or that the call does not throw. It proves the function ran, nothing about whether the answer is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a single pass makes the blind spot shared
&lt;/h2&gt;

&lt;p&gt;When a human writes a test after thinking about the requirement, the test writer can disagree with the implementation and catches a wrong branch. When the same model generates code and test from one prompt, both inherit the same unstated assumption: how empty input behaves, what counts as a valid identifier, which timezone the date means, whether an out-of-range value should clamp or reject. The model has no independent oracle, so the test cannot contradict the code it just wrote. Green in that case is a syntax and plumbing check, not evidence of correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the expected value come from outside the code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Derive expectations from the requirement.&lt;/strong&gt; Use a worked example from the spec, a known customer case, or a domain invariant, and hardcode that answer independently of the function. If the test author must open the implementation to compute the expected value, it has already become a mirror.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the boundaries the model tends to skip.&lt;/strong&gt; Empty input, maximum size, malformed shape, negative or zero, timezone shift, and a value that falls outside the accepted set. Write those cases as explicit expected failures or explicit clamps rather than leaving them to the happy path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the result type and error, not just presence.&lt;/strong&gt; A non-null object or a caught exception proves almost nothing; assert the specific value, the thrown error class, or the rejected status the requirement names.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cheap mutation probe
&lt;/h2&gt;

&lt;p&gt;On functions that sit on a trust boundary, flip one boundary condition in the implementation deliberately: clamp instead of reject, drop the empty-input guard, shift a rounding rule. Run the suite. If every test still passes, the test is not touching that branch and it provides no safety net. This is a lightweight version of mutation testing; you do not need a full framework for a handful of risky functions, just one local flip per probe and a clean revert after. A test that survives a mutation it should kill is advertising coverage it does not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let real bugs pin the behavior
&lt;/h2&gt;

&lt;p&gt;When a bug does arrive, write the failing characterization test for the exact reported input first, before touching the fix. That test carries the behavior from outside the codebase, so it cannot mirror the repaired implementation. It also becomes the permanent guard that prevents a future agent from smoothly reverting the fix while keeping the suite green.&lt;/p&gt;

&lt;p&gt;Tests generated in the same pass as the code are a smoke check. They confirm the wiring runs; they do not confirm the answer is right. Separate the oracle from the implementation, and green starts to mean something again.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>testing</category>
      <category>codereview</category>
      <category>codequality</category>
    </item>
  </channel>
</rss>
