<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://vatluri-tools.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://vatluri-tools.github.io/" rel="alternate" type="text/html" /><updated>2026-09-29T17:16:12+00:00</updated><id>https://vatluri-tools.github.io/feed.xml</id><title type="html">It Ran. Did It Work?</title><subtitle>Measured findings about infrastructure — what the platform actually does, as opposed to what it documents. Every claim tested against a live system with a control.</subtitle><author><name>Vamsi Atluri</name></author><entry><title type="html">create-stack exits 0 when your guardrail rejects the stack</title><link href="https://vatluri-tools.github.io/2026/09/29/create-stack-exits-0-when-your-guardrail-rejects-the-stack/" rel="alternate" type="text/html" title="create-stack exits 0 when your guardrail rejects the stack" /><published>2026-09-29T00:00:00+00:00</published><updated>2026-09-29T00:00:00+00:00</updated><id>https://vatluri-tools.github.io/2026/09/29/create-stack-exits-0-when-your-guardrail-rejects-the-stack</id><content type="html" xml:base="https://vatluri-tools.github.io/2026/09/29/create-stack-exits-0-when-your-guardrail-rejects-the-stack/"><![CDATA[<p>I spent a week telling people that a check must probe the invariant rather than trust an API
response. Then I ran a measurement that trusted an API response, reached the wrong conclusion,
wrote it up, and published it.</p>

<p>This is what the measurement should have been, and the finding that came out when I did it
properly — which is more useful than the one I thought I had.</p>

<h2 id="the-thing-i-was-about-to-publish">The thing I was about to publish</h2>

<p>Four CloudFormation templates with a <code class="language-plaintext highlighter-rouge">Rules:</code> block. Assertions on the parameters: no <code class="language-plaintext highlighter-rouge">$Latest</code>
for a version pin, no empty certificate ARN, no SSH from <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code>.</p>

<p>I fed them ten deliberately invalid parameter sets. All ten came back clean — <code class="language-plaintext highlighter-rouge">create-stack</code>
returned success, exit 0, every time. Conclusion: <code class="language-plaintext highlighter-rouge">Rules:</code> is a Service Catalog feature that
does nothing in plain CloudFormation, and anyone relying on it has no guardrails.</p>

<p>That conclusion was wrong.</p>

<h2 id="what-the-measurement-should-have-looked-like">What the measurement should have looked like</h2>

<p>Six cases, each one there to kill a specific alternative explanation. The fixture is a single
<code class="language-plaintext highlighter-rouge">AWS::CloudFormation::WaitConditionHandle</code> — no VPC, no compute, nothing billable.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>what it tests</th>
      <th><code class="language-plaintext highlighter-rouge">create-stack</code></th>
      <th><strong>final stack state</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>A</strong></td>
      <td>violates the rule, no other guard</td>
      <td>ACCEPTED</td>
      <td><strong>ROLLBACK_COMPLETE</strong></td>
    </tr>
    <tr>
      <td><strong>B</strong></td>
      <td>satisfies the rule — control</td>
      <td>ACCEPTED</td>
      <td>CREATE_COMPLETE</td>
    </tr>
    <tr>
      <td><strong>C</strong></td>
      <td><code class="language-plaintext highlighter-rouge">Rules</code> block deleted — control</td>
      <td>ACCEPTED</td>
      <td>CREATE_COMPLETE</td>
    </tr>
    <tr>
      <td><strong>D</strong></td>
      <td><code class="language-plaintext highlighter-rouge">AllowedValues</code> instead — instrument check</td>
      <td><strong>REFUSED</strong></td>
      <td>—</td>
    </tr>
    <tr>
      <td><strong>E</strong></td>
      <td>no <code class="language-plaintext highlighter-rouge">RuleCondition</code>, so it always applies</td>
      <td>ACCEPTED</td>
      <td><strong>ROLLBACK_COMPLETE</strong></td>
    </tr>
    <tr>
      <td><strong>F</strong></td>
      <td>JSON, long-form functions, no YAML tags</td>
      <td>ACCEPTED</td>
      <td><strong>ROLLBACK_COMPLETE</strong></td>
    </tr>
  </tbody>
</table>

<p>And from <code class="language-plaintext highlighter-rouge">describe-stack-events</code> on A:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Parameter validation failed: assertion error: For a production environment,
the instance type must be t3.large. Rollback requested by user.
</code></pre></div></div>

<p>That is my own <code class="language-plaintext highlighter-rouge">AssertDescription</code>, read back to me. <strong>The guardrail fired.</strong> It fired in every
case that violated it and in none that didn’t.</p>

<p>Each control is there for a reason. <strong>C</strong> proves the rollback came from the rule and not from
something else in the template. <strong>E</strong> kills “your <code class="language-plaintext highlighter-rouge">RuleCondition</code> evaluated false so the
assertion never ran.” <strong>F</strong> kills “your YAML shorthand tags didn’t parse.” And <strong>D</strong> is the one
that matters most — it’s the instrument check, an <code class="language-plaintext highlighter-rouge">AllowedValues</code> constraint that is known to be
enforced. If D had also been accepted, my harness couldn’t observe a refusal at all and none of
the other rows would mean anything.</p>

<p>My original ten-probe run had no D. That’s the whole story.</p>

<h2 id="the-actual-finding">The actual finding</h2>

<p>CloudFormation has <strong>two parameter-validation mechanisms with completely different observable
signatures</strong>, and nothing in the docs puts them side by side:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>when it rejects</th>
      <th>what your shell sees</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AllowedValues</code>, <code class="language-plaintext highlighter-rouge">AllowedPattern</code>, <code class="language-plaintext highlighter-rouge">MinLength</code></td>
      <td><strong>synchronously</strong>, at the API call</td>
      <td><code class="language-plaintext highlighter-rouge">create-stack</code> exits <strong>non-zero</strong> with <code class="language-plaintext highlighter-rouge">ValidationError</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Rules:</code> assertions</td>
      <td><strong>asynchronously</strong>, during creation</td>
      <td><code class="language-plaintext highlighter-rouge">create-stack</code> exits <strong>0</strong>, then the stack rolls back</td>
    </tr>
  </tbody>
</table>

<p>So this line is a lie for half of CloudFormation’s own validation features:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws cloudformation create-stack ... <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"guardrails passed"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">create-stack</code> returning success means CloudFormation <strong>accepted your request</strong>. For a <code class="language-plaintext highlighter-rouge">Rules:</code>
assertion the evaluation happens after that, during stack creation, and the failure surfaces as
a rollback. If your pipeline checks <code class="language-plaintext highlighter-rouge">$?</code> and moves on, a rejected stack looks exactly like an
accepted one — and if you’re checking whether a <em>guardrail works</em>, an accepted stack looks
exactly like a guardrail that never fired.</p>

<p>That’s the trap, and it’s worse than a guardrail that doesn’t exist. A missing guardrail fails
open and you find out. This one fails closed and reports open.</p>

<h2 id="two-things-id-do-differently">Two things I’d do differently</h2>

<p><strong>Every probe needs a case that must fail.</strong> Ten inputs that all came back the same told me
nothing, and I read it as a finding because the finding was interesting. One <code class="language-plaintext highlighter-rouge">AllowedValues</code>
control would have shown me my harness could see refusals — which would have made the ten
silent results a question rather than an answer.</p>

<p><strong>“The command succeeded” and “the thing I wanted happened” are different observations.</strong>
I know this. I’d written it down. I checked <code class="language-plaintext highlighter-rouge">$?</code> anyway, because checking <code class="language-plaintext highlighter-rouge">$?</code> is what your
fingers do.</p>

<p>Worth adding: when I first doubted the result, I went looking for documentation and found an
AWS sample repo that contradicted me. I nearly settled it there. That would have been right by
luck — a doc search is not a measurement, and the experiment took four minutes and cost nothing.
If the question is empirical and the fixture is cheap, build the fixture.</p>

<h2 id="the-bits-that-held-up">The bits that held up</h2>

<p>Measured the same way, with controls that discriminated:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">AllowedValues</code> / <code class="language-plaintext highlighter-rouge">AllowedPattern</code> / <code class="language-plaintext highlighter-rouge">MinLength</code> <strong>are</strong> enforced synchronously. <code class="language-plaintext highlighter-rouge">AllowedPattern</code>
also works on <code class="language-plaintext highlighter-rouge">CommaDelimitedList</code> — an empty member is refused with <em>“Each value of parameter …
failed to satisfy constraint.”</em></li>
  <li><code class="language-plaintext highlighter-rouge">cfn-lint</code> returned <strong>exit 0</strong> on a template that <code class="language-plaintext highlighter-rouge">aws cloudformation validate-template</code> rejects
outright — <code class="language-plaintext highlighter-rouge">Fn::Join</code> isn’t legal inside <code class="language-plaintext highlighter-rouge">Rules</code>. The linter is not the oracle. The service is.</li>
  <li>Legal intrinsics in <code class="language-plaintext highlighter-rouge">Rules</code>: <code class="language-plaintext highlighter-rouge">And</code>, <code class="language-plaintext highlighter-rouge">Contains</code>, <code class="language-plaintext highlighter-rouge">EachMemberEquals</code>, <code class="language-plaintext highlighter-rouge">EachMemberIn</code>, <code class="language-plaintext highlighter-rouge">Equals</code>,
<code class="language-plaintext highlighter-rouge">If</code>, <code class="language-plaintext highlighter-rouge">Not</code>, <code class="language-plaintext highlighter-rouge">Or</code>, <code class="language-plaintext highlighter-rouge">RefAll</code>, <code class="language-plaintext highlighter-rouge">ValueOf</code>, <code class="language-plaintext highlighter-rouge">ValueOfAll</code>, <code class="language-plaintext highlighter-rouge">Ref</code>. Not <code class="language-plaintext highlighter-rouge">Fn::Join</code>, <code class="language-plaintext highlighter-rouge">Fn::Sub</code>,
<code class="language-plaintext highlighter-rouge">Fn::Select</code>.</li>
</ul>

<p>If you want synchronous rejection, use parameter constraints — failing at the API beats creating
a stack and rolling it back. But do it because the failure mode is better, not because <code class="language-plaintext highlighter-rouge">Rules:</code>
doesn’t work. It works. I just couldn’t see it.</p>]]></content><author><name>Vamsi Atluri</name></author><summary type="html"><![CDATA[CloudFormation has two kinds of parameter validation. One fails your CLI immediately; the other returns success and rolls the stack back. I learned the difference by nearly publishing the opposite.]]></summary></entry></feed>