<?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://bluntmachetti.github.io/incidentgym/feed.xml" rel="self" type="application/atom+xml" /><link href="https://bluntmachetti.github.io/incidentgym/" rel="alternate" type="text/html" /><updated>2026-06-25T12:44:58+00:00</updated><id>https://bluntmachetti.github.io/incidentgym/feed.xml</id><title type="html">IncidentGym — Field Log</title><subtitle>The build journey of IncidentGym — a multi-tenant B2B incident-response training gym where security teams drill realistic breaches and get a readiness score that&apos;s computed from their environment and audited in Amazon Aurora, not graded on vibes by a language model. The decisions that made the score defensible: deterministic scoring, composite-FK tenant isolation, and a blast-radius replay over the real service-dependency graph. Built for the #H0Hackathon (Vercel + AWS Databases).</subtitle><author><name>Kenny Ademolu</name></author><entry><title type="html">Watch the blast radius go from 46% to 10%.</title><link href="https://bluntmachetti.github.io/incidentgym/2026/06/25/watch-the-blast-radius.html" rel="alternate" type="text/html" title="Watch the blast radius go from 46% to 10%." /><published>2026-06-25T09:00:00+00:00</published><updated>2026-06-25T09:00:00+00:00</updated><id>https://bluntmachetti.github.io/incidentgym/2026/06/25/watch-the-blast-radius</id><content type="html" xml:base="https://bluntmachetti.github.io/incidentgym/2026/06/25/watch-the-blast-radius.html"><![CDATA[<p>Two logs ago I argued that a security product can’t grade you on vibes — that the outcome of a decision has to be <em>computed</em> from what would happen in a model of your environment, not judged by a language model on how plausible your prose sounds. We built that: a pure graph-cascade resolver that owns the numbers while the LLM is confined to narrative and personas. It works. It’s auditable. And on screen, a bare number like a blast-radius percentage is completely inert. A CISO doesn’t <em>feel</em> a decimal. So this log is about the other half of the job — making the defensible number visceral without letting the rendering touch the math.</p>

<p>The answer is the <strong>Blast-Radius Replay</strong>: we take the environment’s real service-dependency graph — the same one the resolver reads — and animate the incident moving across it, turn by turn. Red spreads down the dependencies that fell. Cyan lights up what your decision saved. The run opens with the blast spreading from the origin and, turn by turn, contracts as your moves contain it — from 46% of the criticality-weighted estate on turn one down to 10% by turn three. Same engine that scores you, now drawing you a map of the damage.</p>

<blockquote>
  <p>The number says you were better off. The replay shows you <em>which services</em> you saved, and which call did it.</p>
</blockquote>

<h2 id="the-resolver-is-a-graph-cascade-and-thats-the-whole-trick">The resolver is a graph cascade, and that’s the whole trick</h2>

<p>Every environment in IncidentGym is a service-dependency graph — nodes are services, edges are “depends on,” and each node carries a criticality weight. When an incident lands, the resolver propagates the decision through that graph: starting at the compromised node, the cascade walks the dependency edges to everything downstream that is now exposed. Sum the criticality weights over what’s affected, normalize against the whole environment, and you get the criticality-weighted blast radius — the headline number.</p>

<p>Your moves are graph operations on that cascade. <strong>Contain</strong> cuts downstream dependencies: a contained service stops propagating, so the subtree hanging off it drops out of the exposed set on the next turn. That pruning is exactly what the animation renders as a node going from red back toward cyan. The resolver doesn’t have a separate “score formula” bolted onto a separate “visualization” — the blast radius <em>is</em> a property of how far the cascade reaches, and the replay <em>is</em> that same cascade drawn over time. One source of truth, two faces.</p>

<p>From that cascade, the engine reports three scoring axes — <code class="language-plaintext highlighter-rouge">technical</code>, <code class="language-plaintext highlighter-rouge">speed</code>, and <code class="language-plaintext highlighter-rouge">riskMitigation</code>. How fast you contain the right node and how much criticality-weighted capacity stays up across the incident come straight off the turn-by-turn cascade. Contain the right node early and recovery moves in your favor; chase a low-criticality service while a payments dependency burns and it doesn’t. The axes aren’t vibes about your judgment — they’re arithmetic over the graph you actually changed.</p>

<h2 id="why-pure-is-the-load-bearing-word">Why “pure” is the load-bearing word</h2>

<p>The resolver is <strong>pure</strong> in the engineering sense: no hidden randomness, stable ordering, same inputs always produce the same outputs. I keep saying it because almost every nice property in this post falls out of that one constraint.</p>

<p>The most obvious payoff is auditability — a CISO can put the score in front of a board and an auditor can re-derive it. But purity buys something I didn’t fully appreciate until we shipped the debrief: <strong>you can re-run the engine over hypotheticals for free.</strong> If the function has no side effects and no hidden randomness, then evaluating “what if I had contained the database instead of failing over the gateway” is just another call with different arguments. No new game, no new LLM turn, no write to Aurora. Those counterfactual re-runs happen in-memory inside the serverless function — zero writes back to Aurora, zero LLM API calls. The engine is a function, so the debrief gets to ask it questions.</p>

<h2 id="the-debrief-asks-the-questions-you-didnt">The debrief asks the questions you didn’t</h2>

<p>That’s where the counterfactuals come from. When you finish a turn, the debrief re-runs the resolver over the moves you <strong>did not make</strong> and surfaces the <strong>best alternative for that turn</strong> — the single decision that would have contained the most criticality-weighted blast. Not an LLM speculating about what a better responder might have done; the actual deterministic engine, scoring the road not taken with the same arithmetic that scored you.</p>

<p>Paired with that is the <strong>“why each number moved” causal chain</strong>: for every metric that changed turn-over-turn, the debrief traces it back to the graph operation that caused it. Blast dropped because the contain cut a high-criticality subtree out of the cascade. Recovery improved because the failover kept a dependency online. It reads like a diff for your incident.</p>

<p>Here’s the contained run as a readout — the three turns the replay animates:</p>

<table>
  <thead>
    <tr>
      <th>Turn</th>
      <th>Move</th>
      <th>Criticality-weighted blast</th>
      <th>What the replay shows</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>incident lands</td>
      <td><strong>46%</strong></td>
      <td>red cascades from the origin down the dependency graph</td>
    </tr>
    <tr>
      <td>2</td>
      <td>contain the origin</td>
      <td><strong>26%</strong></td>
      <td>a high-criticality subtree drops out; nodes flip toward cyan</td>
    </tr>
    <tr>
      <td>3</td>
      <td>contain the exposed dependency</td>
      <td><strong>10%</strong></td>
      <td>the cascade collapses; most of the graph is back to cyan</td>
    </tr>
  </tbody>
</table>

<p>And the part that matters for trust: <strong>every one of those numbers, every counterfactual, every causal-chain entry is computed on read.</strong> The debrief is not loading stored verdicts the LLM wrote earlier. It re-derives them from the persisted decisions and the topology pinned to that session — each session pins its topology at start, so reopening it reproduces the debrief identically even if the live environment graph has since changed. No model is in the loop, nothing is written back. The ledger in Aurora persists the committed per-turn <em>facts</em> — the action you took, the metrics, the causal effects per turn, scoped to your org by the composite-FK invariant from Log 003 (<code class="language-plaintext highlighter-rouge">Turn(sessionId, orgId)</code>, cross-tenant write rejected as Postgres <code class="language-plaintext highlighter-rouge">23503</code>). The counterfactuals and the replay frames are not stored; they’re re-derived on read as a pure projection of those persisted facts.</p>

<h2 id="determinism-makes-the-whole-debrief-reproducible">Determinism makes the whole debrief reproducible</h2>

<p>The last piece is determinism end to end. Because the resolver has no hidden randomness and produces the same outputs from the same inputs, the entire debrief — your run, the best-alternative counterfactual, the causal chain, the frame-by-frame blast values the replay animates — reconstructs with <strong>zero model calls</strong>. Because each session pins its topology at start, reopening the same session tomorrow runs the resolver over the identical inputs and the replay draws the identical cascade. Hand the same pinned inputs to a teammate and they reconstruct your exact debrief on their own machine. Reproducing a finished incident review costs a graph walk, not an API bill.</p>

<p>And it doesn’t have to be a person in the seat. Point an autonomous responder at the same incident and the engine scores its run, replays its blast radius across the same graph, and shows the better move it left on the table — the same reproducible evidence, whether the responder is a human or a bot. The more of the response that gets automated, the more that’s the line between <em>trusting</em> an agent and <em>measuring</em> one.</p>

<p>That’s the line we’ve been holding since the start of this build, restated for the debrief: the LLM writes the <em>story</em> — the situation, the CEO and security-lead and payments-lead personas in the room, the pressure. The engine owns the <em>numbers</em>, and now it owns the <em>picture</em> of the numbers too. The replay is the deterministic engine’s output made visceral, and because the engine is pure, the most persuasive thing in the debrief — <em>here’s the better move you didn’t make, and here’s exactly why your score moved</em> — costs nothing and reproduces forever.</p>

<p>A computed score is the honest foundation. Rendering it on the real dependency graph is what finally makes someone <em>feel</em> the distance between the blast that spread and the blast you contained.</p>

<p>Live demo: <strong><a href="https://incidentgym.redoubtlabs.dev">https://incidentgym.redoubtlabs.dev</a></strong></p>]]></content><author><name>Kenny Ademolu</name></author><summary type="html"><![CDATA[A computed readiness score is honest but invisible. So we render it. The Blast-Radius Replay animates an incident across the service-dependency graph for your environment, persisted in Amazon Aurora PostgreSQL — red where it spread, cyan where you saved — turn by turn. Here's how the pure graph-cascade resolver works, and why a pure engine lets the debrief re-run your unmade moves with no model call.]]></summary></entry><entry><title type="html">A cross-tenant write should be a database error, not a code review.</title><link href="https://bluntmachetti.github.io/incidentgym/2026/06/23/cross-tenant-write-is-a-database-error.html" rel="alternate" type="text/html" title="A cross-tenant write should be a database error, not a code review." /><published>2026-06-23T09:00:00+00:00</published><updated>2026-06-23T09:00:00+00:00</updated><id>https://bluntmachetti.github.io/incidentgym/2026/06/23/cross-tenant-write-is-a-database-error</id><content type="html" xml:base="https://bluntmachetti.github.io/incidentgym/2026/06/23/cross-tenant-write-is-a-database-error.html"><![CDATA[<p>The scariest line in any multi-tenant codebase is the one that <em>isn’t</em> there. A <code class="language-plaintext highlighter-rouge">where: { orgId }</code> that some future commit forgets to add. A new query path that ships without it. A clever join that quietly reaches across tenants because nobody on the review thread held the whole schema in their head at 1 a.m. In a B2B security product, that omission isn’t a bug — it’s a breach, and it’s the kind a CISO will ask you about before they ask about anything else. So when we built IncidentGym’s tenancy, I refused to let “a careful reviewer noticed the missing clause” be the thing standing between two customers’ data. I wanted the <em>database</em> to refuse.</p>

<p>This log is about how we made cross-tenant isolation a structural property of the schema instead of a discipline we have to maintain in application code — and how that lets us put a button in the product that <em>proves</em> it, live, in front of an auditor.</p>

<h2 id="the-failure-mode-we-refused-to-ship">The failure mode we refused to ship</h2>

<p>The default way to do multi-tenancy is: stamp every row with an <code class="language-plaintext highlighter-rouge">orgId</code>, and add <code class="language-plaintext highlighter-rouge">where: { orgId }</code> to every query. It works right up until the moment one query doesn’t. The enforcement lives entirely in code you have to remember to write, on every read and every write, forever, including in code that doesn’t exist yet. The guarantee is therefore exactly as strong as your least careful future diff. “We’re isolated” really means “we’re isolated as long as nobody forgets,” which is not a sentence you want to say to a security buyer.</p>

<p>There’s a second, sneakier version of the same hole. Even if every <em>top-level</em> query is scoped, child rows can still drift. A <code class="language-plaintext highlighter-rouge">Turn</code> belongs to a <code class="language-plaintext highlighter-rouge">GameSession</code>. If you write a <code class="language-plaintext highlighter-rouge">Turn</code> with <code class="language-plaintext highlighter-rouge">sessionId</code> pointing at session <code class="language-plaintext highlighter-rouge">S</code> but stamp it with the <em>wrong</em> <code class="language-plaintext highlighter-rouge">orgId</code> — through a bug, a confused caller, a malicious request that lies about its tenant — a naive schema is perfectly happy to store it. Now you have a row that claims to belong to org A while hanging off a parent owned by org B. The application-code check that “should” have caught it is, again, just code someone wrote and someone else might not.</p>

<p>I didn’t want to be one forgotten clause away from that. So we moved the invariant down a layer.</p>

<h2 id="make-the-relationship-carry-the-tenant">Make the relationship carry the tenant</h2>

<p>The core idea is small and, once you see it, almost obvious: <strong>don’t let a child row reference a parent by id alone — make it reference the parent by (id, orgId) together.</strong></p>

<p>Concretely, in our Prisma schema:</p>

<ul>
  <li>Every tenant-owned table carries an <code class="language-plaintext highlighter-rouge">orgId</code> column. Nothing is tenant-ambiguous.</li>
  <li>Every <em>parent</em> table declares a composite unique key on <code class="language-plaintext highlighter-rouge">@@unique([id, orgId])</code>. The id is already unique on its own; this extra key exists purely so the pair <code class="language-plaintext highlighter-rouge">(id, orgId)</code> is a thing a foreign key can point at.</li>
  <li>Every <em>child</em> table binds <code class="language-plaintext highlighter-rouge">(sessionId, orgId)</code> as a <strong>composite foreign key</strong> into that parent’s <code class="language-plaintext highlighter-rouge">(id, orgId)</code>.</li>
</ul>

<p>Concretely:</p>

<pre><code class="language-prisma">model GameSession {
  id    String @id @default(cuid())
  orgId String
  turns Turn[]

  @@unique([id, orgId]) // the composite key a child FK can point at
}

model Turn {
  id        String      @id @default(cuid())
  sessionId String
  orgId     String
  // composite FK: a Turn can only attach to a session in the SAME org
  session   GameSession @relation(fields: [sessionId, orgId], references: [id, orgId])

  @@unique([id, orgId])
}
</code></pre>

<p>That last line is the whole game. The relationship from <code class="language-plaintext highlighter-rouge">Turn</code> to <code class="language-plaintext highlighter-rouge">GameSession</code> is no longer “this <code class="language-plaintext highlighter-rouge">sessionId</code> exists” — it’s “a session with <em>this id and this orgId</em> exists.” Which means <code class="language-plaintext highlighter-rouge">child.orgId == parent.orgId</code> is no longer something application code asserts and hopes for. It’s something Postgres <em>checks on every insert and update</em>, because it’s the referential-integrity rule the constraint encodes.</p>

<blockquote>
  <p>A cross-tenant write isn’t a missed code review anymore. It’s a foreign-key violation — Postgres error <code class="language-plaintext highlighter-rouge">23503</code> — raised by the database before the row ever lands.</p>
</blockquote>

<p>Try to write a <code class="language-plaintext highlighter-rouge">Turn</code> for session <code class="language-plaintext highlighter-rouge">S</code> (owned by org B) while stamping it with org A’s id, and there is no row <code class="language-plaintext highlighter-rouge">GameSession(id = S, orgId = A)</code> for the composite FK to satisfy. The insert is rejected. Not flagged in a linter, not caught in review, not maybe-caught in a test — <em>rejected</em>, at the storage layer, deterministically, every time, including in code paths none of us have written yet. The guarantee stopped depending on anyone’s memory.</p>

<h2 id="belt-suspenders-and-one-place-to-decide-tenancy">Belt, suspenders, and one place to decide tenancy</h2>

<p>The composite FK is the floor — the thing that holds even when everything above it is wrong. But we still build the layers above it correctly, because defense in depth means each layer assumes the others might fail.</p>

<p>The first layer is query scoping. We don’t sprinkle <code class="language-plaintext highlighter-rouge">where: { orgId }</code> by hand and pray. Every application query path goes through <code class="language-plaintext highlighter-rouge">db.scoped(orgId)</code>, which injects the <code class="language-plaintext highlighter-rouge">orgId</code> filter onto every query it issues. There’s one place where scoping is decided, not hundreds of call sites where it can be forgotten. A reader can’t accidentally see another tenant’s rows because the scoped client won’t emit an unscoped query in the first place.</p>

<p>The second layer is the one I care about most on the write path: <strong>the finalize step is exactly-once and idempotent.</strong> When a turn is scored and committed, scoring and persistence happen as one resumable operation, so a refresh or a second tab can’t double-advance or double-charge a turn. And if a write ever tried to land a child row whose tenant disagreed with its parent’s, the composite FK underneath is waiting to reject the mismatch anyway. The structural guarantee doesn’t lean on the finalize logic being perfect — it’s there precisely for the case where it isn’t.</p>

<p>Here’s the same idea stated as before/after, because the shift is the whole point:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Isolation in app code only</th>
      <th>Isolation in the schema</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Enforced by</td>
      <td>every <code class="language-plaintext highlighter-rouge">where</code> clause you remember to write</td>
      <td>a composite FK Postgres checks every write</td>
    </tr>
    <tr>
      <td>A cross-tenant write is</td>
      <td>a bug that <em>might</em> be caught in review</td>
      <td>error <code class="language-plaintext highlighter-rouge">23503</code>, a foreign-key violation</td>
    </tr>
    <tr>
      <td>Strength</td>
      <td>= your least careful future diff</td>
      <td>= a referential-integrity rule</td>
    </tr>
    <tr>
      <td>How you prove it</td>
      <td>“trust us, we scope everything”</td>
      <td>run an insert and watch the DB reject it</td>
    </tr>
  </tbody>
</table>

<p>The bottom-right cell is what turns this from an architecture decision into a demo.</p>

<h2 id="provable-live-in-the-audit-console">Provable, live, in the audit console</h2>

<p>A guarantee you can only describe is a claim. A guarantee you can <em>trigger on stage</em> is evidence. So the CISO-facing audit console has a <strong>one-click isolation proof</strong>, and it does exactly what a skeptic would do if you handed them a psql prompt: it attempts a cross-tenant write — a child row stamped with the wrong tenant — and shows you what the database says back.</p>

<p>The database rejects it with foreign-key error <code class="language-plaintext highlighter-rouge">23503</code> on the <code class="language-plaintext highlighter-rouge">Turn_orgId_fkey</code> constraint, surfaced right there in the console. It’s a live demonstration that the isolation boundary is real and is enforced where we say it is: in Aurora, not in a slide. You don’t have to take “we scope everything” on faith — you can watch Postgres refuse the write.</p>

<p>And because the boundary lives in the schema, it doesn’t weaken in the public demo. The live deployment runs in single-org mode — a shared demo org, no sign-in — so judges can click straight in without signing up, but the <em>same</em> composite-FK scoping is in force. The same composite-FK constraints are active either way. The demo isn’t a watered-down version that “would” be isolated in production. It’s the same wall, with one door open for visitors, and the proof works either way.</p>

<h2 id="why-this-was-worth-the-schema-care">Why this was worth the schema care</h2>

<p>Composite foreign keys cost something. You have to add the redundant <code class="language-plaintext highlighter-rouge">@@unique([id, orgId])</code> to every parent, thread <code class="language-plaintext highlighter-rouge">orgId</code> through every child relation, and resist the urge to take the easy single-column FK. It’s more schema to hold in your head than <code class="language-plaintext highlighter-rouge">where: { orgId }</code>.</p>

<p>But it buys the one property an application-code check can never have: it’s true even when the code above it is wrong. The whole pitch of a security product is that you’ve thought about the failure where <em>you</em> are the adversary’s path in — the forgotten clause, the confused write, the next engineer who doesn’t know the rule. Pushing tenant isolation into a referential-integrity constraint means that path is closed by the database, not by vigilance. And a constraint doesn’t get tired, doesn’t miss a clause at 1 a.m., and doesn’t care whether the request testing it is a confused service or an adversary probing at machine speed — it either satisfies the rule or it’s rejected. That matters more, not less, as attacks get faster and more automated. The strongest thing I can say about IncidentGym’s tenancy isn’t “we’re careful.” It’s: a cross-tenant write is a Postgres error, and there’s a button that’ll show you.</p>

<p>Live demo: <strong><a href="https://incidentgym.redoubtlabs.dev">https://incidentgym.redoubtlabs.dev</a></strong></p>]]></content><author><name>Kenny Ademolu</name></author><summary type="html"><![CDATA[App-code-only multi-tenant isolation is one forgotten WHERE clause from a leak. In IncidentGym we pushed isolation into the schema: every row carries orgId, every parent declares @@unique([id, orgId]), every child binds (sessionId, orgId) as a composite foreign key into the parent's (id, orgId). A cross-tenant write is rejected by Postgres itself — error 23503 on the Turn_orgId_fkey constraint — not by a reviewer. And it's provable live in the audit console.]]></summary></entry><entry><title type="html">The LLM writes the story. A pure function writes the score.</title><link href="https://bluntmachetti.github.io/incidentgym/2026/06/22/the-llm-writes-the-story.html" rel="alternate" type="text/html" title="The LLM writes the story. A pure function writes the score." /><published>2026-06-22T09:00:00+00:00</published><updated>2026-06-22T09:00:00+00:00</updated><id>https://bluntmachetti.github.io/incidentgym/2026/06/22/the-llm-writes-the-story</id><content type="html" xml:base="https://bluntmachetti.github.io/incidentgym/2026/06/22/the-llm-writes-the-story.html"><![CDATA[<p>The opener made the bet: plausible prose is not a contained breach, so a security trainer can’t grade you on how convincing your writing sounds. This post is about how we engineered the split that makes that bet real. The first design decision in IncidentGym wasn’t a UI choice or a database schema. It was a refusal. The obvious path — the path a lot of the new AI graders take — is to hand the player’s answer to a language model and ask it: <em>how good is this?</em> The model reads your prose, weighs how plausible it sounds, and returns a number. It’s fast, it’s cheap, and it is exactly the thing we decided we would not ship. A response that <em>reads</em> like a competent isolation-and-failover can score beautifully and still be the wrong call for the environment it’s running in. If the grader can be talked into a good score, the score is worth nothing to the person who has to defend it.</p>

<p>So we drew a line straight through the middle of the product, and everything else is downstream of it: <strong>the LLM writes the story; a pure function writes the score.</strong></p>

<h2 id="what-the-llm-is-allowed-to-do">What the LLM is allowed to do</h2>

<p>Plenty, actually — just not the thing that decides whether you pass. The language model owns the <em>narrative</em>: it sets the scene, escalates the situation as the clock runs, and plays the cast of AI advisor personas in the room with you — the CEO who wants a customer statement, the security lead pushing for containment, the payments lead worried about the cutover, the comms officer drafting the holding line. That’s real work, and it’s work LLMs are genuinely good at: producing fluent, in-character, situation-aware text under pressure. When you isolate a service or escalate or fail over, the advisors react, the world moves, and it <em>feels</em> alive.</p>

<p>What the model never touches is the number. It doesn’t see a rubric it can be argued into. It doesn’t get to decide that a confident-sounding answer “deserves” a higher technical score. The story it writes is flavor and pressure and texture — and flavor, by design, has no vote.</p>

<h2 id="what-a-pure-function-does-instead">What a pure function does instead</h2>

<p>The score is computed by a pure, deterministic graph-cascade resolver, and the word that matters there is <em>pure</em>. No LLM call. No clock. No randomness. It takes two inputs: your decision, and your organization’s environment — a service-dependency graph with criticality weights, read from the database as a fixed input to the computation. It propagates the decision through that graph and computes concrete outcomes: blast radius — criticality-weighted blast radius = (sum of criticality weights of impacted services) ÷ (sum over all services) — which services were impacted, what recovered, and a causal trace of exactly what your choice changed. From those it derives the score’s deterministic axes: <strong>technical</strong>, <strong>speed</strong>, and <strong>riskMitigation</strong>.</p>

<p>Because the resolver is pure over those inputs, it has a property an LLM grader can never have: <strong>the same inputs produce the same score.</strong> Run the same decision against the same topology a thousand times and you get the same number a thousand times. There’s no temperature, no seed drift, no “the model was in a mood.” The number isn’t an opinion that happened to land near the truth; it’s a computation, and you can re-run the computation.</p>

<blockquote>
  <p>A score you can re-derive isn’t a grade. It’s a receipt.</p>
</blockquote>

<p>That distinction — grade versus receipt — is the whole reason the line exists. A grade is what someone gives you. A receipt is something you can hand to an auditor and they can check it themselves.</p>

<h2 id="the-one-axis-we-couldnt-make-pure--and-what-we-did-about-it">The one axis we couldn’t make pure — and what we did about it</h2>

<p>Honesty time, because a build log that only lists wins is just marketing with line numbers. One axis genuinely needs language understanding: <strong>communication</strong>. Whether you told the right stakeholders the right thing at the right moment is a judgment about <em>text</em>, and you can’t compute it from a dependency graph. So communication is the one axis where an LLM assists the evaluation.</p>

<p>We didn’t hide that. We did the opposite — we <strong>fenced it and labeled it.</strong> Communication is the only LLM-assisted axis in the score, and the split is explicit by design: technical, speed, and riskMitigation come out of the pure resolver; communication is the one with a model in the loop. The fence is the point. Instead of one undifferentiated “AI score” where you can’t tell signal from vibes, you get three axes you can re-derive and one axis that is openly marked as model-assisted. The buyer decides how much to trust the labeled one. We don’t launder it through the deterministic ones.</p>

<p>That’s why this carries a <code class="language-plaintext highlighter-rouge">warn</code> flag and the other two carry <code class="language-plaintext highlighter-rouge">ok</code>. It’s a real caveat, stated plainly, in the place a skeptic would look.</p>

<h2 id="the-split-in-one-table">The split, in one table</h2>

<p>Here’s the line, stated as a table so it’s hard to misread:</p>

<table>
  <thead>
    <tr>
      <th>Score axis</th>
      <th>Computed by</th>
      <th>Reproducible</th>
      <th>Provenance</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>technical</td>
      <td>deterministic resolver (pure)</td>
      <td>same inputs → same score</td>
      <td>deterministic</td>
    </tr>
    <tr>
      <td>speed</td>
      <td>deterministic resolver (pure)</td>
      <td>same inputs → same score</td>
      <td>deterministic</td>
    </tr>
    <tr>
      <td>riskMitigation</td>
      <td>deterministic resolver (pure)</td>
      <td>same inputs → same score</td>
      <td>deterministic</td>
    </tr>
    <tr>
      <td>communication</td>
      <td>LLM-assisted</td>
      <td>not deterministic</td>
      <td><strong>model-assisted (fenced)</strong></td>
    </tr>
  </tbody>
</table>

<p>One dependency worth naming for the skeptic: the deterministic path needs the topology to be there. The turn it’s scoring — <code class="language-plaintext highlighter-rouge">Turn(sessionId, orgId)</code> — has to resolve to an organization whose environment graph exists, because that graph <em>is</em> the input the resolver runs on. No topology, no deterministic computation. The three deterministic axes are only ever computed against a real environment graph, never conjured.</p>

<h2 id="why-auditable--reproducible-is-the-pitch-not-a-footnote">Why “auditable + reproducible” is the pitch, not a footnote</h2>

<p>It would be easy to file all of this under engineering hygiene — nice internals, move on. But for the buyer we care about, it <em>is</em> the product. Picture a CISO putting a readiness number in front of a board, or an auditor asked to sign off on it. The first question either of them asks is the same: <em>where did this number come from, and can you show me?</em></p>

<p>If the answer is “a language model read the team’s answers and felt they were about an 81,” the conversation is over. That number can’t be defended, can’t be reproduced, can’t be distinguished from a number the model would have given to confident nonsense. But if the answer is “three of the four axes are computed by a pure resolver from your own environment, here are the inputs, run it yourself and you’ll get the same number — and the fourth axis is explicitly marked as model-assisted” — now you have something a regulated buyer can actually stand behind. The deterministic line isn’t there to make the demo look rigorous. It’s there because <strong>auditable and reproducible is what a security score has to be</strong>, and an LLM-judges-the-prose grader is structurally incapable of being either.</p>

<p>The whole architecture follows from that one refusal at the start. The LLM makes the incident feel real. The pure function makes the score real. We kept them on opposite sides of a line we can point to — and the one place they had to touch, we fenced, labeled, and put a <code class="language-plaintext highlighter-rouge">warn</code> on so nobody has to take our word for it.</p>

<p>And the same property reaches past the people in the seat. The day an autonomous IR agent runs the response instead of a human, you hit the same question — <em>is it any good?</em> — and the same trap: you can’t grade one model’s output with another model and call it evidence. A deterministic ruler is the only honest answer, which is why this split isn’t internal hygiene; it’s what lets you benchmark an agent on a number it can be held to, exactly the way you’d benchmark a person. (We run that same bet in a different domain — disaster response — over at <a href="https://bluntmachetti.github.io/aftershock">Aftershock</a>.)</p>

<p>That’s the line. The story is written by a model. The score is written by a function. And only one of those gets a vote on whether you passed.</p>

<p>Live demo: <strong><a href="https://incidentgym.redoubtlabs.dev">https://incidentgym.redoubtlabs.dev</a></strong></p>]]></content><author><name>Kenny Ademolu</name></author><summary type="html"><![CDATA[Most AI graders ask a language model how plausible your answer sounds — and for a security product, plausible prose is not a contained breach. This dispatch draws IncidentGym's hard line: the LLM narrates the incident and plays the advisors; a pure deterministic resolver computes the score from the environment, reproducibly. Here's why auditable + reproducible is the entire pitch to a regulated buyer.]]></summary></entry><entry><title type="html">Why another cyber incident-response training tool?</title><link href="https://bluntmachetti.github.io/incidentgym/2026/06/21/why-another-incident-response-tool.html" rel="alternate" type="text/html" title="Why another cyber incident-response training tool?" /><published>2026-06-21T09:00:00+00:00</published><updated>2026-06-21T09:00:00+00:00</updated><id>https://bluntmachetti.github.io/incidentgym/2026/06/21/why-another-incident-response-tool</id><content type="html" xml:base="https://bluntmachetti.github.io/incidentgym/2026/06/21/why-another-incident-response-tool.html"><![CDATA[<p>There are already a lot of ways to “train” an incident-response team, so the first honest question to answer is the one in the title: why build another one? I didn’t set out to add a tool to a crowded shelf. I set out because the two dominant ways teams rehearse for a breach both have the same flaw — they produce a feeling of readiness without a number you can stand behind. This is the opening dispatch of IncidentGym’s field log, so before the technical posts go deep on the engine, I want to be precise about the problem we’re actually solving and the bet we’re making. To be clear about what this is: I started IncidentGym as my entry for the <strong>#H0Hackathon</strong> (Vercel + AWS Databases), and this field log is its public build journal.</p>

<h2 id="why-this-matters-now-the-attack-side-is-speeding-up">Why this matters now: the attack side is speeding up</h2>

<p>The case for <em>measuring</em> readiness instead of feeling it gets sharper every quarter, because the other side is accelerating. The UK’s National Cyber Security Centre assessed that AI will <a href="https://www.ncsc.gov.uk/report/impact-of-ai-on-cyber-threat">almost certainly increase the volume and heighten the impact of cyber attacks</a> — lowering the barrier for less-skilled actors and sharpening reconnaissance and social engineering. And it isn’t only a forecast: in late 2025 Anthropic reported <a href="https://www.anthropic.com/news/disrupting-AI-espionage">disrupting the first documented <em>AI-orchestrated</em> espionage campaign</a>, in which an agentic system ran reconnaissance, vulnerability discovery, exploitation, and data exfiltration across roughly thirty organizations — with the AI doing an estimated 80–90% of the hands-on work.</p>

<p>Whatever you make of the exact figures, the direction is hard to argue with: incidents are getting faster and more frequent, and the gap between “something’s off” and “it’s everywhere” is compressing. That’s the world an annual tabletop can’t keep up with — and, with no small irony, a strange moment to let <em>plausible-sounding AI prose</em> be the thing that tells a security team it’s ready. The faster the attack, the more a readiness number has to be real.</p>

<h2 id="the-first-break-training-thats-episodic">The first break: training that’s episodic</h2>

<p>Most IR rehearsal is a tabletop. A consultant flies in once a year, walks your team through a scenario over an afternoon, and leaves you a slide deck and a warm feeling. It’s not worthless — it surfaces gaps, it gets people in a room. But it tells you almost nothing about whether your team is ready <em>today</em>. Readiness isn’t a thing you have on the day of the workshop and keep for twelve months; it drifts as people leave, as the environment changes, as the runbook quietly goes stale. An annual snapshot of a moving target is not a measurement. It’s a memory.</p>

<p>What a CISO actually needs is continuous and comparable: a way to drill regularly and watch a number move, so “are we more ready than last quarter?” has an answer instead of an anecdote.</p>

<h2 id="the-second-break-graded-on-vibes">The second break: graded on vibes</h2>

<p>The newer wave of tools fixes the cadence problem by putting an AI in the loop — drill any time, get instant feedback. Good instinct. But look closely at <em>how a lot of them score you</em>: you write your response in prose, and a language model judges how plausible that prose sounds. The grade is a measure of how convincing your writing is.</p>

<p>For most domains that’s a fine approximation. For a security product it’s a credibility hole, because the whole job of an attacker is to make the wrong thing look right.</p>

<blockquote>
  <p>Plausible prose is not a contained breach.</p>
</blockquote>

<p>A confident, well-structured paragraph about isolating the affected service can score beautifully and still describe a move that, in your actual environment, would have taken down the payment path it depended on. The model grading you doesn’t know your dependency graph. It’s rewarding fluency. And a readiness score built on fluency is exactly the score an auditor — or an attacker — should not trust.</p>

<h2 id="what-were-actually-building">What we’re actually building</h2>

<p>IncidentGym is incident-response training that’s continuous, measurable, and above all <em>defensible</em>. The design goal, stated as plainly as I can: produce a readiness score a CISO can put in front of the board, and an auditor can’t wave away.</p>

<p>The thing that makes that possible isn’t a better prompt or a sterner AI judge. It’s a different architecture for where the number comes from. The outcome of a decision in IncidentGym is <strong>computed</strong> — propagated through a model of your environment’s service dependencies — not <em>graded</em>. When you isolate a service, fail over, or escalate, a deterministic engine works out what would happen in a model of your environment: what cascades, what’s contained, which criticality-weighted services you saved and which you lost. Same decision, same environment, same score, every time. That reproducibility is the entire point. A number you can re-derive is a number you can audit; a number that depends on a model’s mood that afternoon is not.</p>

<p>So the honest “why us” is not “we added another chatbot to incident response.” There’s a language model in IncidentGym, and it does real work — it narrates the unfolding incident, it voices the advisor personas you argue with under pressure, the CEO and the security lead and the comms officer in the room. But it does not own the score’s deterministic core — the axes a board and an auditor can re-derive. (One axis, communication, is model-assisted; we fence it and label it openly, and a later dispatch shows exactly how.) The story is generated; the defensible score is computed. Keeping that line clean — narrative on one side, deterministic resolver on the other — is the part I’m proudest of, and it’s what makes the readiness number mean something.</p>

<h2 id="what-the-next-dispatches-go-deep-on">What the next dispatches go deep on</h2>

<p>This is the opener, so I’m framing the bet, not unpacking the machinery. Three threads run through everything that follows, and each one gets its own dispatch:</p>

<ul>
  <li><strong>The LLM narrates, but it never judges.</strong> The split between the model that tells the story and the pure engine that scores the decision is the spine of the product. A future log walks through exactly where that boundary sits and why “same inputs, same score” is non-negotiable for a security tool.</li>
  <li><strong>Tenant isolation enforced by the database, not by hope.</strong> IncidentGym is multi-tenant, and we didn’t want isolation to be a <code class="language-plaintext highlighter-rouge">where</code> clause someone could forget. It’s a composite foreign-key invariant in the schema, so a cross-tenant write is rejected by PostgreSQL itself — a database error, not a passed application check. A dispatch covers how that turns “trust us” into something you can demonstrate live.</li>
  <li><strong>The blast-radius replay over the real dependency graph.</strong> The debrief doesn’t just hand you a number. It animates the incident spreading and being contained across your environment’s service-dependency graph, turn by turn — red along the dependencies that fell, cyan for what you saved — next to the counterfactual showing what a different call would have done. It’s the deterministic engine’s output, made visceral. That one earns a full post.</li>
</ul>

<h2 id="where-this-is-today">Where this is today</h2>

<p>A field log that blurs shipped and not-shipped isn’t worth much, so: today you drill <strong>solo</strong> against a set of <strong>built-in, fictional environments</strong> — the dependency graphs are ours, not yet yours. Two things are in testing and not live: <strong>bring-your-own-topology</strong>, so you can drill on a model of <em>your</em> real estate instead of a stand-in, and <strong>team-vs-incident multiplayer</strong>, so a whole response team can run the same authoritative engine together. When they ship, this log keeps the receipts.</p>

<h2 id="the-forward-bet">The forward bet</h2>

<p>Here’s the part I’m most willing to be held to. Because the scoring is deterministic and grounded in a topology rather than in a model’s opinion, the same engine doesn’t only have to grade people. Point it at an autonomous IR agent and it answers a question the industry is about to need badly: <em>how good is our agent, on our environment, against this incident?</em> As response gets more agentic, “is the bot any good?” needs a reproducible answer, and a ruler that’s already environment-grounded and auditable is the natural place to get one.</p>

<p>And this isn’t hypothetical for us. It’s the same conviction behind a sibling project — <a href="https://bluntmachetti.github.io/aftershock">Aftershock</a> — where a society of AI agents responds to a simulated disaster and is scored on a deterministic, reproducible ruler instead of graded on how good its plan <em>sounds</em>. Different domain (a disaster in a simulated town, not a breach in your estate), same refusal: if an agent is going to run the response, you measure it with a number you can re-derive.</p>

<p>That’s the wager this whole field log is here to test in public. The dispatches that follow keep the receipts — what shipped, what we got wrong, what we walked back. If there’s one line that justifies building another incident-response tool, it’s this: don’t grade the prose, compute the outcome.</p>

<p>Live demo: <strong><a href="https://incidentgym.redoubtlabs.dev">https://incidentgym.redoubtlabs.dev</a></strong></p>]]></content><author><name>Kenny Ademolu</name></author><summary type="html"><![CDATA[Cyber incident-response training is broken two ways — it's episodic, and the new AI trainers grade you on how plausible your prose sounds — at exactly the moment AI is accelerating the attack side. IncidentGym computes the outcome of a decision from a model of your environment: a readiness score a board and an auditor can re-derive.]]></summary></entry></feed>