<?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: Joshua Hernandez</title>
    <description>The latest articles on DEV Community by Joshua Hernandez (@jblaz6335).</description>
    <link>https://dev.to/jblaz6335</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%2F4090548%2F761ed0f7-f133-4616-aa20-0763a2620155.png</url>
      <title>DEV Community: Joshua Hernandez</title>
      <link>https://dev.to/jblaz6335</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jblaz6335"/>
    <language>en</language>
    <item>
      <title>The HTTP 429 That Turned Seven Minutes Into Zero Work</title>
      <dc:creator>Joshua Hernandez</dc:creator>
      <pubDate>Sun, 23 Aug 2026 07:53:42 +0000</pubDate>
      <link>https://dev.to/jblaz6335/the-http-429-that-turned-seven-minutes-into-zero-work-3ppl</link>
      <guid>https://dev.to/jblaz6335/the-http-429-that-turned-seven-minutes-into-zero-work-3ppl</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;ARGUS is a data-catalog governance swarm. Its agents use structured model calls to draft descriptions, classify sensitive fields, and review repairs before anything can be written.&lt;/p&gt;

&lt;p&gt;During a 45-entity sweep, the system spent seven minutes producing zero proposals. The report called the result &lt;code&gt;review failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The endpoint was reachable. Health probes returned 200. The logs showed only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;429 Too Many Requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry loop was working exactly as written. That was the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different failures shared one status code
&lt;/h2&gt;

&lt;p&gt;The model provider enforced both a short burst limit and a daily token quota. Both failures arrived as HTTP 429 with the same useful headers.&lt;/p&gt;

&lt;p&gt;Their remedies are opposites:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Correct response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-minute burst limit&lt;/td&gt;
&lt;td&gt;Wait, then retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily token quota exhausted&lt;/td&gt;
&lt;td&gt;Stop using that endpoint for this sweep&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only reliable distinction was in the response body. The provider's daily-quota response explained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on tokens per day (TPD): Limit 100000, Used 99299, Requested 1139
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ARGUS never logged that text. The OpenAI-compatible client raised an &lt;code&gt;httpx&lt;/code&gt; status exception whose default message contained the status and URL, but not the response body.&lt;/p&gt;

&lt;p&gt;The information needed to fix the failure was present on the wire and discarded at the error boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the health probe lied
&lt;/h2&gt;

&lt;p&gt;The account was close to its cap, not necessarily at exactly zero remaining tokens. A tiny probe could still fit under the allowance and return 200.&lt;/p&gt;

&lt;p&gt;The real structured request included a schema, column context, lineage context, and reserved output. It needed more than the remaining budget and failed.&lt;/p&gt;

&lt;p&gt;That created a misleading picture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connectivity looked healthy&lt;/li&gt;
&lt;li&gt;authentication looked healthy&lt;/li&gt;
&lt;li&gt;small calls looked healthy&lt;/li&gt;
&lt;li&gt;every useful call failed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A probe only proves that the probe can run. It does not prove that the real workload fits inside the remaining quota.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retry loop amplified the outage
&lt;/h2&gt;

&lt;p&gt;ARGUS treated every 429 as temporary. Each model call could wait through six backoffs. A sweep could need roughly one hundred calls.&lt;/p&gt;

&lt;p&gt;For a rate limit that clears in seconds, this is patient behavior. For a daily quota that resets tomorrow, it turns one deterministic failure into hours of waiting.&lt;/p&gt;

&lt;p&gt;The retry policy had no concept of recoverability. It knew that a request was throttled, but not whether time inside the current operation could change the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: preserve the provider's explanation
&lt;/h2&gt;

&lt;p&gt;The HTTP boundary now reads and carries a bounded copy of the response body when it raises an error.&lt;/p&gt;

&lt;p&gt;Instead of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;429 Too Many Requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the log can say which limit was hit, how much was used, and what the failed request needed.&lt;/p&gt;

&lt;p&gt;That single change identified the root cause on the next run.&lt;/p&gt;

&lt;p&gt;The broader lesson is simple: an error abstraction should hide irrelevant transport details, but it must not erase the only field that distinguishes two operational states.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: model exhaustion as its own outcome
&lt;/h2&gt;

&lt;p&gt;I added a &lt;code&gt;BudgetExhausted&lt;/code&gt; exception rather than routing a daily quota through the ordinary retry path.&lt;/p&gt;

&lt;p&gt;When the response identifies a long-lived token or request quota:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The call fails immediately.&lt;/li&gt;
&lt;li&gt;The endpoint is retired for the current sweep.&lt;/li&gt;
&lt;li&gt;A breaker prevents the remaining agents from repeating the same doomed call.&lt;/li&gt;
&lt;li&gt;The breaker resets at the start of the next sweep.&lt;/li&gt;
&lt;li&gt;The CLI labels the totals as partial.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reset matters. A process-wide flag that survives its cause would make tomorrow's healthy quota look permanently dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix three: do not turn an outage into a verdict
&lt;/h2&gt;

&lt;p&gt;The most important change was not in the HTTP client. It was in the Arbiter.&lt;/p&gt;

&lt;p&gt;ARGUS already had a deliberate no-model mode. If the operator starts a run without a key, mechanical grounding becomes the declared review gate. A proposal that passes that gate can be approved.&lt;/p&gt;

&lt;p&gt;Budget exhaustion halfway through a modeled run is different. Falling into the same branch would silently remove the review gate at the exact moment it stopped working.&lt;/p&gt;

&lt;p&gt;The new outcome is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;not reviewed: model budget exhausted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not &lt;code&gt;rejected&lt;/code&gt;, because no reviewer made a judgment. It is not &lt;code&gt;approved&lt;/code&gt;, because the expected review never happened. Unreviewed proposals are never written.&lt;/p&gt;

&lt;p&gt;This distinction protects both the data catalog and the truthfulness of the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix four: pace and rotate
&lt;/h2&gt;

&lt;p&gt;The provider reports remaining tokens and reset timing on successful responses. ARGUS now uses those headers to pace before hitting the wall instead of discovering the wall by repeatedly crashing into it.&lt;/p&gt;

&lt;p&gt;I also reduced reserved output from 4096 tokens to 900. The structured responses are short descriptions, tag lists, and verdicts. On free tiers, reserving 4096 tokens can consume rate-limit capacity even when the reply uses far less.&lt;/p&gt;

&lt;p&gt;When multiple endpoints are configured, the retry and rotation loops are now separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retry when the same endpoint may answer next time&lt;/li&gt;
&lt;li&gt;rotate when the endpoint will not answer during this sweep&lt;/li&gt;
&lt;li&gt;do not rotate on unrelated errors such as malformed output or HTTP 500&lt;/li&gt;
&lt;li&gt;trip the breaker only when every configured endpoint is unavailable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last negative test matters. Rotating on every error would burn the entire provider chain because of one bad prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Duration strings were another hidden bug
&lt;/h2&gt;

&lt;p&gt;The original reset parser stripped a trailing &lt;code&gt;s&lt;/code&gt; and called &lt;code&gt;float()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Real providers return values such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1h30m43.2s
205ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both values failed the old parser and silently fell back to guessed delays. The replacement parser handles hours, minutes, seconds, milliseconds, decimals, and compound durations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily quota&lt;/td&gt;
&lt;td&gt;Six backoffs per call&lt;/td&gt;
&lt;td&gt;One classified failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider explanation&lt;/td&gt;
&lt;td&gt;Discarded&lt;/td&gt;
&lt;td&gt;Preserved and logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remaining calls&lt;/td&gt;
&lt;td&gt;Repeated the same failure&lt;/td&gt;
&lt;td&gt;Short-circuited or rotated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proposal outcome&lt;/td&gt;
&lt;td&gt;&lt;code&gt;review failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;not reviewed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writes after exhaustion&lt;/td&gt;
&lt;td&gt;Ambiguous fallback risk&lt;/td&gt;
&lt;td&gt;Never written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sweep totals&lt;/td&gt;
&lt;td&gt;Looked complete&lt;/td&gt;
&lt;td&gt;Explicitly partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reset header parsing&lt;/td&gt;
&lt;td&gt;Simple float only&lt;/td&gt;
&lt;td&gt;Compound duration support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider fallback&lt;/td&gt;
&lt;td&gt;Stopped on exhausted endpoint&lt;/td&gt;
&lt;td&gt;Rotates after bounded retries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real exhausted endpoint that previously took seven minutes now produced a clear partial-run report in seconds and committed nothing.&lt;/p&gt;

&lt;p&gt;The regression suite grew dedicated coverage for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;daily quota versus temporary rate limit&lt;/li&gt;
&lt;li&gt;no retry after confirmed exhaustion&lt;/li&gt;
&lt;li&gt;breaker reset between sweeps&lt;/li&gt;
&lt;li&gt;no automatic approval after mid-run exhaustion&lt;/li&gt;
&lt;li&gt;unreviewed proposals never reaching a write&lt;/li&gt;
&lt;li&gt;duration parsing for &lt;code&gt;1h30m43.2s&lt;/code&gt; and &lt;code&gt;205ms&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;rotation after persistent unnamed throttling&lt;/li&gt;
&lt;li&gt;no rotation on non-throttle failures&lt;/li&gt;
&lt;li&gt;stopping clearly when every endpoint is spent&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Status codes are categories, not diagnoses
&lt;/h3&gt;

&lt;p&gt;HTTP 429 says the request cannot run now. It does not say whether waiting inside the current job can help.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability must preserve decision-making evidence
&lt;/h3&gt;

&lt;p&gt;Logging more bytes is not automatically useful. Logging the one bounded provider field that distinguishes retryable from terminal failure is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Degraded mode and outage mode are different contracts
&lt;/h3&gt;

&lt;p&gt;Starting without a reviewer is an explicit operating mode. Losing the reviewer halfway through is a failed assumption. They should not share a fallback branch merely because both lack a model response.&lt;/p&gt;

&lt;h3&gt;
  
  
  A partial success must call itself partial
&lt;/h3&gt;

&lt;p&gt;The sweep produced real numbers for fewer entities than requested. Presenting those totals without qualification would overstate coverage even if every individual number were correct.&lt;/p&gt;

&lt;p&gt;The best part of this fix is that it did not merely reduce latency. It stopped an infrastructure outage from being misreported as an AI judgment and preserved the review boundary when the budget disappeared.&lt;/p&gt;

&lt;p&gt;AI assistance was used during code and draft iteration. Joshua Hernandez is the sole contest entrant and is responsible for the submitted implementation and evidence.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>When a Safety Reviewer Rejected Everything and Still Passed Its Test</title>
      <dc:creator>Joshua Hernandez</dc:creator>
      <pubDate>Sun, 23 Aug 2026 07:50:12 +0000</pubDate>
      <link>https://dev.to/jblaz6335/when-a-safety-reviewer-rejected-everything-and-still-passed-its-test-1795</link>
      <guid>https://dev.to/jblaz6335/when-a-safety-reviewer-rejected-everything-and-still-passed-its-test-1795</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;ARGUS is a data-catalog governance swarm. Specialist agents find missing descriptions, untagged sensitive columns, broken lineage assumptions, and other metadata defects. An Arbiter reviews each proposed repair before anything can be written.&lt;/p&gt;

&lt;p&gt;That last reviewer is supposed to be the safety boundary. It must reject unsupported claims without blocking repairs that the evidence actually supports.&lt;/p&gt;

&lt;p&gt;One live sweep exposed a failure that looked exactly like diligence.&lt;/p&gt;

&lt;p&gt;The Arbiter rejected 86 of 112 proposals. Among the rejected repairs were classifications such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cust_first_name&lt;/code&gt; is PII&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;billing_zipcode&lt;/code&gt; is PII&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shipping_address_line1&lt;/code&gt; is PII&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PII governance finished at 0.0 percent. The flagship capability had produced nothing, but the report was full of confident explanations about why each repair was unsafe.&lt;/p&gt;

&lt;p&gt;The reviewer had not become safer. It had learned to refuse responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the prompt caused the failure
&lt;/h2&gt;

&lt;p&gt;The original prompt explained what the Arbiter must reject. It said that schema and recorded lineage were established evidence and warned against inventing ownership, row counts, refresh cadence, business meaning, and downstream consumers.&lt;/p&gt;

&lt;p&gt;What it did not explain was the difference between interpreting evidence and claiming a new fact about the world.&lt;/p&gt;

&lt;p&gt;A strong model filled that gap with common sense. A weaker model followed the instructions literally and demanded outside corroboration that a column named &lt;code&gt;cust_first_name&lt;/code&gt; contained a first name.&lt;/p&gt;

&lt;p&gt;That meant the same prompt appeared to work until the swarm rotated to another model family. The model change did not create the ambiguity. It revealed ambiguity that had always been present.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test rewarded the failure
&lt;/h2&gt;

&lt;p&gt;The more embarrassing bug was in my regression test.&lt;/p&gt;

&lt;p&gt;The live adversarial check had four cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Expected decision&lt;/th&gt;
&lt;th&gt;Cases&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Approve&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reject&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A reviewer that rejected every proposal scored 3 out of 4, or 75 percent.&lt;/p&gt;

&lt;p&gt;That number looked close enough to healthy during a quick review. The test designed to catch a broken reviewer was giving partial credit for the exact failure mode that broke it.&lt;/p&gt;

&lt;p&gt;This is a common problem in safety-oriented systems. If negative cases dominate the benchmark, a component can look accurate by always choosing the conservative label. The aggregate score hides whether errors are false approvals or false rejections, even though those failures have opposite causes and opposite remedies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: define three kinds of claims
&lt;/h2&gt;

&lt;p&gt;I rewrote the Arbiter instructions around three explicit categories.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Established by the evidence
&lt;/h3&gt;

&lt;p&gt;The schema establishes which columns exist and their types. Recorded lineage establishes the listed upstream and downstream relationships. A description that restates those facts can be approved.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Interpretation of the evidence
&lt;/h3&gt;

&lt;p&gt;This is the judgment the Arbiter is being asked to make.&lt;/p&gt;

&lt;p&gt;A column named &lt;code&gt;cust_first_name&lt;/code&gt; can reasonably be classified as personal data. &lt;code&gt;billing_zipcode&lt;/code&gt; is a postal code associated with a customer record. &lt;code&gt;order_id&lt;/code&gt; identifies an order.&lt;/p&gt;

&lt;p&gt;The reviewer does not need an external document to agree that a clear name means what it says. It should reject the interpretation only when the name is genuinely ambiguous. For example, &lt;code&gt;region&lt;/code&gt; is not automatically PII, and &lt;code&gt;account_number&lt;/code&gt; on a warehouse table may not identify a person.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Claims about the world
&lt;/h3&gt;

&lt;p&gt;Ownership, refresh cadence, row counts, trustworthiness, and an unrecorded downstream consumer are not established by a schema. Those claims still require evidence and should be rejected when it is absent.&lt;/p&gt;

&lt;p&gt;The new prompt also states the cost of both error directions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Approving a fabrication puts a lie into a system people rely on. Rejecting a correct repair leaves the catalog broken and a sensitive column ungoverned.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Rejection is no longer described as the safe default. It is a decision with consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: make the test discriminate
&lt;/h2&gt;

&lt;p&gt;I replaced the four-case check with nine balanced cases.&lt;/p&gt;

&lt;p&gt;Five must be approved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A description grounded in the schema and real lineage&lt;/li&gt;
&lt;li&gt;A description that only restates the columns&lt;/li&gt;
&lt;li&gt;Tag &lt;code&gt;cust_first_name&lt;/code&gt; as PII&lt;/li&gt;
&lt;li&gt;Tag &lt;code&gt;billing_zipcode&lt;/code&gt; as PII&lt;/li&gt;
&lt;li&gt;Tag &lt;code&gt;email&lt;/code&gt; as PII&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four must be rejected:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tag the generic &lt;code&gt;region&lt;/code&gt; column as PII&lt;/li&gt;
&lt;li&gt;Invent a refresh cadence and row count&lt;/li&gt;
&lt;li&gt;Invent ownership and data quality&lt;/li&gt;
&lt;li&gt;Invent a downstream consumer absent from lineage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The false-positive trap matters. A prompt that approves every PII proposal is just as broken as one that rejects every proposal.&lt;/p&gt;

&lt;p&gt;The harness now reports three outcomes separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FALSE APPROVAL
Fabrication would reach the catalog.

FALSE REJECTION
A correct repair was refused.

ERROR
No judgment was produced.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It no longer compresses opposite failure modes into one flattering percentage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clear schema-derived description&lt;/td&gt;
&lt;td&gt;Often rejected&lt;/td&gt;
&lt;td&gt;Approved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Obvious PII column&lt;/td&gt;
&lt;td&gt;Often rejected&lt;/td&gt;
&lt;td&gt;Approved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic &lt;code&gt;region&lt;/code&gt; column tagged as PII&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invented ownership or cadence&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test mix&lt;/td&gt;
&lt;td&gt;1 approve, 3 reject&lt;/td&gt;
&lt;td&gt;5 approve, 4 reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error reporting&lt;/td&gt;
&lt;td&gt;One aggregate score&lt;/td&gt;
&lt;td&gt;False approvals and false rejections separated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After the change, the nine-case harness scored 9 out of 9 on two model families that had not seen the revised prompt: &lt;code&gt;gpt-4o-mini&lt;/code&gt; through GitHub Models and &lt;code&gt;gemma-4-26b&lt;/code&gt; through OpenRouter.&lt;/p&gt;

&lt;p&gt;Most importantly, both models discriminated in both directions. They approved the grounded PII repairs, rejected the fabricated world claims, and preserved the &lt;code&gt;region&lt;/code&gt; false-positive trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A conservative classifier can still be unsafe
&lt;/h3&gt;

&lt;p&gt;Inaction has a cost. Refusing to tag an obvious sensitive column leaves the catalog ungoverned. A safety system must model the harm of false negatives, not only the harm of false positives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmarks encode incentives
&lt;/h3&gt;

&lt;p&gt;A reject-heavy test suite rewarded rejection. Balancing labels helped, but separating error types was the real fix because it made the failure direction visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model rotation is an adversarial test
&lt;/h3&gt;

&lt;p&gt;Changing providers exposed an underspecified instruction that a stronger model had been silently repairing. Cross-model evaluation is useful even when the production system normally uses one provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  A green report is not proof of useful work
&lt;/h3&gt;

&lt;p&gt;The broken sweep completed, produced explanations, and looked cautious. The business outcome, 0.0 percent PII governance, was the signal that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am proud of
&lt;/h2&gt;

&lt;p&gt;The fix was not "use a stronger model." It made the contract clearer and the regression test harder to game. That improved the behavior across two different model families and turned a vague reviewer into a component with measurable discrimination.&lt;/p&gt;

&lt;p&gt;The most valuable debugging question was not "why did it reject this proposal?" It was "how did rejecting everything still look like success?"&lt;/p&gt;

&lt;p&gt;AI assistance was used during code and draft iteration. Joshua Hernandez is the sole contest entrant and is responsible for the submitted implementation and evidence.&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Add a Human Review Gate to an n8n Lead Intake Workflow</title>
      <dc:creator>Joshua Hernandez</dc:creator>
      <pubDate>Sun, 23 Aug 2026 07:43:42 +0000</pubDate>
      <link>https://dev.to/jblaz6335/how-to-add-a-human-review-gate-to-an-n8n-lead-intake-workflow-2f8o</link>
      <guid>https://dev.to/jblaz6335/how-to-add-a-human-review-gate-to-an-n8n-lead-intake-workflow-2f8o</guid>
      <description>&lt;p&gt;Most lead intake automations work on the happy path. A form arrives, the workflow sends an email, and the lead appears in a CRM.&lt;/p&gt;

&lt;p&gt;The expensive failures happen at the edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same webhook is delivered twice&lt;/li&gt;
&lt;li&gt;a required field is missing&lt;/li&gt;
&lt;li&gt;a high-risk message is routed automatically&lt;/li&gt;
&lt;li&gt;an API accepts the request but drops a field&lt;/li&gt;
&lt;li&gt;a downstream system fails after the workflow has already sent a confirmation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reliable workflow needs more than connected nodes. It needs explicit states, a review boundary, and tests that prove what happens when something goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow shape
&lt;/h2&gt;

&lt;p&gt;A practical review-gated intake flow has seven stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive the event&lt;/li&gt;
&lt;li&gt;Normalize the fields&lt;/li&gt;
&lt;li&gt;Create a deterministic duplicate key&lt;/li&gt;
&lt;li&gt;Validate and classify the record&lt;/li&gt;
&lt;li&gt;Pause for review when the record is risky or incomplete&lt;/li&gt;
&lt;li&gt;Send only after approval&lt;/li&gt;
&lt;li&gt;Record the final state and any failure details&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important design choice is that "received" and "approved" are different states. Receiving a lead should never imply that a person has accepted it or that an appointment exists.&lt;/p&gt;

&lt;p&gt;A normalized record can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lead_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"form|alex@example.com|2026-08-23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"website_form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alex@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"routine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"review_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_sent"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives every later node a predictable contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalize before branching
&lt;/h2&gt;

&lt;p&gt;Do not scatter cleanup logic across five nodes. Normalize once, near the start.&lt;/p&gt;

&lt;p&gt;An n8n Code node can create a simple deterministic duplicate key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receivedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;json&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;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dedupe_key&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="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;email&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="nx"&gt;day&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="na"&gt;review_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;delivery_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_sent&lt;/span&gt;&lt;span class="dl"&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 exact key depends on the business. A contact form might use source, normalized email, and day. An order system should normally use the provider's event or order ID.&lt;/p&gt;

&lt;p&gt;The rule is simple: retries for the same logical event must produce the same key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the review gate explicit
&lt;/h2&gt;

&lt;p&gt;A review gate does not need a complicated dashboard.&lt;/p&gt;

&lt;p&gt;One useful n8n pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;store the normalized record&lt;/li&gt;
&lt;li&gt;route risky or incomplete records to a Wait node&lt;/li&gt;
&lt;li&gt;send a reviewer a one-time decision link or internal form&lt;/li&gt;
&lt;li&gt;resume the workflow with an explicit decision&lt;/li&gt;
&lt;li&gt;branch on &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, or &lt;code&gt;needs_changes&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the decision payload small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lead_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"form|alex@example.com|2026-08-23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"approved"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewed_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"operations"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-23T18:14:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not let arbitrary fields from the review request overwrite the stored lead. Load the stored record by &lt;code&gt;lead_id&lt;/code&gt;, validate the allowed decision values, and update only the review fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send after approval, not before
&lt;/h2&gt;

&lt;p&gt;The delivery branch should check both conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;review_status == approved
delivery_status == not_sent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the downstream API or email succeeds, write the provider message ID and set &lt;code&gt;delivery_status&lt;/code&gt; to &lt;code&gt;sent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the workflow retries, the second run sees &lt;code&gt;sent&lt;/code&gt; and does not send again. This is the small piece of state that prevents duplicate customer messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log failures as states
&lt;/h2&gt;

&lt;p&gt;A generic "workflow failed" alert is rarely enough to repair the problem.&lt;/p&gt;

&lt;p&gt;Capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;lead ID&lt;/li&gt;
&lt;li&gt;workflow execution ID&lt;/li&gt;
&lt;li&gt;stage that failed&lt;/li&gt;
&lt;li&gt;normalized error category&lt;/li&gt;
&lt;li&gt;retryable or permanent&lt;/li&gt;
&lt;li&gt;downstream status code&lt;/li&gt;
&lt;li&gt;provider request ID&lt;/li&gt;
&lt;li&gt;timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid putting API keys, full authorization headers, or unnecessary customer data in logs.&lt;/p&gt;

&lt;p&gt;A retryable timeout and a permanent validation error should not share the same path. Timeouts can enter a bounded retry queue. Invalid records should return to review with a specific reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five tests worth running
&lt;/h2&gt;

&lt;p&gt;Before connecting the workflow to production data, test these cases with synthetic records:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A valid approved lead is sent exactly once.&lt;/li&gt;
&lt;li&gt;A duplicate event does not create a second send.&lt;/li&gt;
&lt;li&gt;A missing required field enters review and does not send.&lt;/li&gt;
&lt;li&gt;A rejected record never reaches the delivery node.&lt;/li&gt;
&lt;li&gt;A downstream timeout creates a retryable failure record with no secret data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also test the recovery path. Fix the simulated downstream failure and confirm that the record can resume without duplicating earlier side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  A working reference
&lt;/h2&gt;

&lt;p&gt;I published a tested n8n lead-intake implementation with validation, duplicate protection, human review, failure paths, and automated checks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jblaz6335/n8n-review-gated-lead-intake" rel="noopener noreferrer"&gt;View the workflow and tests on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to adapt the pattern to an existing form, webhook, email tool, or CRM, &lt;a href="https://blazalgosystems.netlify.app/services/" rel="noopener noreferrer"&gt;Blaz Algo Systems offers fixed-scope automation and reliability work&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is also a downloadable &lt;a href="https://ko-fi.com/s/fd75e9be8a" rel="noopener noreferrer"&gt;Python API Reliability Kit&lt;/a&gt; for teams that want reusable validation and debugging material.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>automation</category>
      <category>testing</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
