<?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: Cristian Diaz Koziuk</title>
    <description>The latest articles on DEV Community by Cristian Diaz Koziuk (@crdkzk).</description>
    <link>https://dev.to/crdkzk</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%2F4004085%2Fdb443368-3610-451b-afa4-3a7e881e34ac.png</url>
      <title>DEV Community: Cristian Diaz Koziuk</title>
      <link>https://dev.to/crdkzk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/crdkzk"/>
    <language>en</language>
    <item>
      <title>What actually breaks when you let an LLM execute real actions</title>
      <dc:creator>Cristian Diaz Koziuk</dc:creator>
      <pubDate>Sat, 15 Aug 2026 06:28:43 +0000</pubDate>
      <link>https://dev.to/crdkzk/what-actually-breaks-when-you-let-an-llm-execute-real-actions-187f</link>
      <guid>https://dev.to/crdkzk/what-actually-breaks-when-you-let-an-llm-execute-real-actions-187f</guid>
      <description>&lt;h1&gt;
  
  
  What actually breaks when you let an LLM execute real actions
&lt;/h1&gt;

&lt;p&gt;I built a control plane that sits between an LLM and anything that costs money&lt;br&gt;
or has side effects — the model proposes an action, hard rules and a business&lt;br&gt;
validator decide what actually runs. I wrote up the architecture&lt;br&gt;
&lt;a href="https://dev.to/crdkzk/let-your-llm-take-real-world-actions-without-giving-it-the-last-word-3ab6"&gt;here&lt;/a&gt;&lt;br&gt;
a while back; the pattern itself is open source&lt;br&gt;
(&lt;a href="https://github.com/cristiandkzk/SACP" rel="noopener noreferrer"&gt;SACP&lt;/a&gt;, MIT).&lt;/p&gt;

&lt;p&gt;This isn't that post. This is the list of things that actually broke — found&lt;br&gt;
by running it against a real database and a real model provider, not by&lt;br&gt;
reading the spec. A few of these got a one-line mention last time; here they&lt;br&gt;
get the full symptom → cause → fix. None of them are exotic. That's the point:&lt;br&gt;
they're the bugs you get for free the moment a non-deterministic component&lt;br&gt;
sits inside a system that has to be deterministic and auditable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision engine
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;Date.parse()&lt;/code&gt; accepts strings that aren't dates
&lt;/h3&gt;

&lt;p&gt;A schema validation test passed with &lt;code&gt;expiresAt: "tomorrow at 3"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;V8's &lt;code&gt;Date.parse()&lt;/code&gt; accepts strings that aren't ISO 8601 and happily returns a&lt;br&gt;
timestamp instead of &lt;code&gt;NaN&lt;/code&gt;. The validator trusted &lt;code&gt;Date.parse()&lt;/code&gt; alone to&lt;br&gt;
decide whether a field was a valid date.&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;ISO_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;T&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;/&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;isIso&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ISO_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never validate ISO format with &lt;code&gt;Date.parse()&lt;/code&gt; alone. Regex first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The policy engine silently allowed everything
&lt;/h3&gt;

&lt;p&gt;14 of 22 integration tests failed — every policy returned &lt;code&gt;allowed: true&lt;/code&gt;, for&lt;br&gt;
every input, including the ones that should have been blocked.&lt;/p&gt;

&lt;p&gt;The test suite required the engine but never called &lt;code&gt;registerAll()&lt;/code&gt;. With a&lt;br&gt;
lazy registry and zero policies actually registered, the engine's default&lt;br&gt;
behavior was to allow. Not a bug in the policy logic — a bug in what happens&lt;br&gt;
when there's no policy logic at all.&lt;/p&gt;

&lt;p&gt;If a registry is lazy, the test has to activate it explicitly. And a&lt;br&gt;
fail-open default is a loaded gun: it works fine right up until someone forgets&lt;br&gt;
to load the policies, and then it works &lt;em&gt;silently&lt;/em&gt; wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  Model IDs copied from a marketing page
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;400 json_validate_failed&lt;/code&gt; on every call to the provider.&lt;/p&gt;

&lt;p&gt;The model selector listed IDs copied from a marketing page. They&lt;br&gt;
weren't real, callable model IDs — marketing names and API names aren't&lt;br&gt;
guaranteed to match, and in this case they didn't. Verified against the&lt;br&gt;
provider's live model list and replaced them. Marketing names lie; verify&lt;br&gt;
against the real API before anything goes in the registry.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model invents an expiry date in the past
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;decision: allow&lt;/code&gt;, but the final state was &lt;code&gt;blocked&lt;/code&gt;, with&lt;br&gt;
&lt;code&gt;businessValidationResult.failures = [DECISION_EXPIRED]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model generated a date from somewhere near its training cutoff, not from&lt;br&gt;
the actual moment of the call — it doesn't know what time it is. The business&lt;br&gt;
validator did exactly its job and blocked an already-expired decision. The fix&lt;br&gt;
is two-sided: normalize server-side after schema validation, and tell the&lt;br&gt;
model the real time in the system prompt so it stops guessing.&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&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;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;DECISION_TTL_MS&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="c1"&gt;// system prompt: `The current date and time is: ${new Date().toISOString()}`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model does not know real time. Any time-dependent field it produces has to&lt;br&gt;
be normalized server-side — never trust it to compute one.&lt;/p&gt;

&lt;h3&gt;
  
  
  An ObjectId leaking into the event contract
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tenantId: expected string, got object&lt;/code&gt;, thrown by the event contract&lt;br&gt;
validator.&lt;/p&gt;

&lt;p&gt;The tenant id was a &lt;code&gt;mongoose.Types.ObjectId&lt;/code&gt;. The event contract expected a&lt;br&gt;
plain string. Implicit serialization didn't convert it — it just failed at the&lt;br&gt;
boundary. &lt;code&gt;tenantId: String(snapshot.tenantId)&lt;/code&gt; fixed it, but the actual rule&lt;br&gt;
is broader: at any boundary between an ORM and an event system — an outbox,&lt;br&gt;
a queue, a webhook — convert ids to strings explicitly. Don't rely on implicit&lt;br&gt;
serialization to do it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outbound gateway
&lt;/h2&gt;

&lt;p&gt;Four smaller ones, each a one-liner once you've hit it once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys that are too generic.&lt;/strong&gt; &lt;code&gt;idempotencyKey = sourceId&lt;/code&gt;
collides across different actions on the same entity. Use
&lt;code&gt;provider + action + sourceId + version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persisting endpoints with concrete IDs.&lt;/strong&gt; &lt;code&gt;/123456/messages&lt;/code&gt; makes metrics
impossible to group by endpoint. Persist the normalized template instead:
&lt;code&gt;/{accountId}/messages&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging headers.&lt;/strong&gt; Headers usually carry tokens. Never persist
&lt;code&gt;Authorization&lt;/code&gt;, cookies, &lt;code&gt;accessToken&lt;/code&gt;, &lt;code&gt;refreshToken&lt;/code&gt;, or any secret in an
attempt log — even in a debug snapshot nobody's supposed to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrying a permanent 4xx.&lt;/strong&gt; A &lt;code&gt;400&lt;/code&gt; from a malformed payload isn't fixed by
retrying it. Only retry what the provider's manifest explicitly lists as
retryable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The refresh-token race — the expensive one
&lt;/h3&gt;

&lt;p&gt;Two workers independently notice a token is expired and both call the&lt;br&gt;
refresher. For providers that rotate the refresh token on use, the second call&lt;br&gt;
consumes a token the first one just invalidated. Net result: the account is&lt;br&gt;
left with &lt;em&gt;no&lt;/em&gt; valid token, and nothing recovers it automatically — a human&lt;br&gt;
has to go reconnect it.&lt;/p&gt;

&lt;p&gt;The fix is an atomic lease with a TTL: one worker wins the refresh, the others&lt;br&gt;
poll and re-read instead of racing in. The TTL exists so a worker that dies&lt;br&gt;
mid-refresh doesn't leave the lease held forever.&lt;/p&gt;

&lt;p&gt;No downtime — the account just sat there with no valid token until someone&lt;br&gt;
noticed and reconnected it by hand. Which is its own kind of expensive:&lt;br&gt;
concurrency bugs in token refresh don't show up in a demo, they show up as a&lt;br&gt;
silent gap in an integration nobody's watching until a customer asks why their&lt;br&gt;
messages stopped sending.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta-lesson
&lt;/h2&gt;

&lt;p&gt;Look back at that list. Almost none of it is an "AI bug." It's the bug tax of&lt;br&gt;
putting a non-deterministic component inside a system that has to stay&lt;br&gt;
deterministic and auditable: time it doesn't actually know, identifiers it&lt;br&gt;
mangles at a boundary, formats it invents when you don't constrain it hard&lt;br&gt;
enough, concurrency it has no concept of.&lt;/p&gt;

&lt;p&gt;The control plane exists for exactly this reason. Not to make the model smarter&lt;br&gt;
— to make sure that when it's wrong (and per the list above, it will be, in&lt;br&gt;
ways you didn't predict), the failure degrades into a conservative fallback&lt;br&gt;
instead of an unauthorized action. A blocked campaign send is an annoyance. An&lt;br&gt;
approved campaign send that shouldn't have been is an incident.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The pattern is &lt;a href="https://github.com/cristiandkzk/SACP" rel="noopener noreferrer"&gt;MIT-licensed on GitHub&lt;/a&gt;,&lt;br&gt;
with the reference core on npm as &lt;code&gt;sacp-core&lt;/code&gt;. I also sell a kit that adds the&lt;br&gt;
approval queue, dashboard and audit trail on top of it, for anyone who'd rather&lt;br&gt;
not build that part from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>node</category>
    </item>
    <item>
      <title>Let your LLM take real-world actions — without giving it the last word</title>
      <dc:creator>Cristian Diaz Koziuk</dc:creator>
      <pubDate>Fri, 26 Jun 2026 13:35:02 +0000</pubDate>
      <link>https://dev.to/crdkzk/let-your-llm-take-real-world-actions-without-giving-it-the-last-word-3ab6</link>
      <guid>https://dev.to/crdkzk/let-your-llm-take-real-world-actions-without-giving-it-the-last-word-3ab6</guid>
      <description>&lt;p&gt;Most "AI agent" tutorials wire the model straight to execution:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;user asks → model decides → system runs&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's fine for a demo. It's dangerous the moment an action can charge a card,&lt;br&gt;
send over a paid channel, publish content, or breach a plan limit. "The model&lt;br&gt;
decided" is not an acceptable audit trail.&lt;/p&gt;

&lt;p&gt;I kept rebuilding the same guardrails across projects, so I extracted the&lt;br&gt;
pattern: &lt;strong&gt;Safe Automation Control Plane (SACP)&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea, in one line
&lt;/h2&gt;

&lt;p&gt;The AI proposes.&lt;br&gt;
Hard rules decide what's allowed.&lt;br&gt;
Validators decide what may execute.&lt;br&gt;
Executors only run validated decisions.&lt;/p&gt;

&lt;p&gt;The model never has authority. It optimizes inside a box that deterministic&lt;br&gt;
rules draw for it, and every decision is validated, cached, costed and audited&lt;br&gt;
before anything runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three composable pieces
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decision Engine&lt;/strong&gt; — turns any action into a validated, audited decision.
Rules first, AI second, validators last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Model Layer&lt;/strong&gt; — the only place the LLM lives: model selection, caching,
usage metering, circuit breaking, schema validation, prompt-injection defense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound Gateway&lt;/strong&gt; — one controlled door for every external API call:
tokens, idempotency, retries, breaker, rate limit, cost ledger.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part worth reading first: what broke
&lt;/h2&gt;

&lt;p&gt;This came out of a production system, so there's a &lt;a href="https://github.com/cristiandkzk/SACP/blob/main/docs/lessons-learned.md" rel="noopener noreferrer"&gt;lessons-learned doc&lt;/a&gt;&lt;br&gt;
of real bugs, not theory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model returned &lt;code&gt;expiresAt&lt;/code&gt; dates from its &lt;strong&gt;training cutoff&lt;/strong&gt; — already in
the past. Lesson: the AI doesn't know real time; normalize time fields
server-side.&lt;/li&gt;
&lt;li&gt;The policy engine &lt;strong&gt;silently allowed everything&lt;/strong&gt; because a lazy registry was
never initialized in tests. A "fail open" default is a loaded gun.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;refresh-token race&lt;/strong&gt;: two workers refreshing in parallel, the second
consuming a token the first already rotated, leaving the account dead. Fixed
with an atomic lease.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these aren't AI bugs — they're the bugs of putting a non-deterministic&lt;br&gt;
component inside a deterministic, audited system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
bash
npm install sacp-core

import { DecisionEngine, PolicyEngine } from 'sacp-core';

const policy = new PolicyEngine();
policy.register('router_ai.campaign_send', (snap) =&amp;gt; {
  const ctx = snap.context as { balance: number; cost: number };
  return ctx.balance &amp;gt;= ctx.cost
    ? { allowed: true }
    : { allowed: false, reasonCode: 'BALANCE_INSUFFICIENT' };
});

// No model wired yet → a conservative rule-only decision, never an exception.
const engine = new DecisionEngine({ policy });
const { output } = await engine.decide({
  tenantId: 't_123',
  action: { type: 'campaign_send' },
  risk: { riskLevel: 'low' },
  context: { balance: 1000, cost: 200 },
});
// output.decision → 'allow' | 'block' | 'require_approval' | 'split'
Zero runtime dependencies, ports &amp;amp; adapters — your database and model provider
stay yours. There's a runnable Claude adapter example
with structured outputs and refusal handling.

Honest caveat: the token-savings numbers in the docs are an illustrative
cost model, not a measured benchmark — they ship with a formula you plug your
own rates into.

Repo (MIT, EN/ES docs): https://github.com/cristiandkzk/SACP

I'd love to hear how others handle "AI proposes, rules dispose."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
