<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Skaldborn devlog</title><description>Long-form engineering posts on building a generational life-simulation MMO.</description><link>https://www.skaldborn.com/</link><language>en-us</language><item><title>The colleague who hallucinates (and the fences that keep it honest)</title><link>https://www.skaldborn.com/devlog/06-the-colleague-who-hallucinates/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/06-the-colleague-who-hallucinates/</guid><description>One morning in April, an agent I&apos;d sent to audit my own governance came back certain that four rule files were broken and a CI gate was failing. None of it was true — the gate passed twenty-seven of twenty-seven. The only thing that caught it was me happening to look. This is how I stopped relying on that, and built fences that make a careless collaborator&apos;s mistakes cheap and loud instead of expensive and silent.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One morning in April I almost shipped a fix for a problem that didn’t exist.&lt;/p&gt;
&lt;p&gt;I’d sent three agents off to audit the project’s own governance — read the rules, run the checks, report what’s broken. Routine housekeeping. One of them came back certain: four of my rule files were missing a required field, and a check that was supposed to guard exactly that was failing in CI. It wrote this up cleanly. Specific files. Specific field. A failing gate. It even proposed the four commits to fix it.&lt;/p&gt;
&lt;p&gt;None of it was true. The four files had the field. The gate passed — twenty-seven of twenty-seven. The agent had run an audit, looked at green, and reported red. Not maliciously. It just produced the shape of a finding that sounded right, the way the tavern-keeper in my &lt;a href=&quot;https://www.skaldborn.com/devlog/01-simulation-owns-reality/&quot;&gt;first post&lt;/a&gt; produced a dead brother who never existed.&lt;/p&gt;
&lt;p&gt;I’d already started consolidating its report into a patch plan when something felt off and I went and looked myself.&lt;/p&gt;
&lt;h2 id=&quot;the-thing-that-saved-me-was-a-human-glance&quot;&gt;The thing that saved me was a human glance&lt;/h2&gt;
&lt;p&gt;Sit with that, because it’s the whole problem.&lt;/p&gt;
&lt;p&gt;The only reason a fabricated bug report didn’t turn into four real commits against my codebase is that I happened to be paying attention that morning. I read the agent’s confident paragraph, felt a flicker of &lt;em&gt;wait, didn’t I just fix that?&lt;/em&gt;, and pulled the file up by hand.&lt;/p&gt;
&lt;p&gt;That is not a control. That’s luck wearing the costume of a control. It doesn’t scale past the days I’m sharp, it doesn’t survive me being tired, and it definitely doesn’t survive the thing I actually want — more agents, doing more work, while I’m not watching every keystroke. If my safety net is “Carlos notices,” then every hour the machine works unsupervised is an hour with no net at all.&lt;/p&gt;
&lt;p&gt;So the question stopped being &lt;em&gt;how do I get the AI to stop hallucinating&lt;/em&gt; — you don’t; that’s like asking the dice to stop rolling — and became something I could actually build against:&lt;/p&gt;
&lt;h2 id=&quot;you-dont-make-it-honest-you-make-it-checkable&quot;&gt;You don’t make it honest. You make it checkable.&lt;/h2&gt;
&lt;p&gt;This is the same move I made when I deleted two months of working code. Back then the lesson was about contracts: an &lt;a href=&quot;https://www.skaldborn.com/devlog/03-aspirational-vs-mechanical/&quot;&gt;&lt;em&gt;aspirational&lt;/em&gt; contract&lt;/a&gt; is one you wrote down and hope is true; a &lt;strong&gt;mechanical&lt;/strong&gt; contract is one that breaks the build when it’s violated. I’d been trusting aspirational contracts and calling it engineering.&lt;/p&gt;
&lt;p&gt;The collaborator is the same story, one level up. I can’t audit the AI’s &lt;em&gt;intentions&lt;/em&gt; — it doesn’t really have any, and even if it did I can’t see them. What I can do is build the workspace so that the dangerous things it might do are either impossible or self-reporting. Not “please be careful.” &lt;strong&gt;Structurally unable to be careless without it showing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An honest colleague you can’t verify is worth less than a careless one you can. Skaldborn is built by an AI I assume is careless, inside fences that make carelessness bounce off.&lt;/p&gt;
&lt;p&gt;Here’s what those fences actually are.&lt;/p&gt;
&lt;h2 id=&quot;what-an-ai-colleague-actually-is-on-this-project&quot;&gt;What an AI colleague actually is on this project&lt;/h2&gt;
&lt;p&gt;There isn’t &lt;em&gt;an&lt;/em&gt; AI on Skaldborn. There are several, and they’re deliberately not the same actor.&lt;/p&gt;
&lt;p&gt;The work is split into roles, each with its own context and its own blinders:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;architect&lt;/strong&gt; plans and writes the decision records. It never implements.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;writer&lt;/strong&gt; implements exactly one scoped slice. It never plans.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;reviewer&lt;/strong&gt; reads the writer’s diff against the contract. It never writes code.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;verifier&lt;/strong&gt; runs the scenario and checks pass/fail. It doesn’t reason about implementation.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;steward&lt;/strong&gt; keeps the trackers and manifests honest.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;orchestrator&lt;/strong&gt; runs the loop — and is forbidden, in writing, from reading source code at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last one sounds insane until you see why. The orchestrator’s job is to decide &lt;em&gt;what gets dispatched to whom&lt;/em&gt;. The moment it starts reading source to “just check something,” it’s no longer scoping work — it’s doing the work, in the one context that’s supposed to stay clean enough to catch when a piece of work is mis-scoped. So its rule file says, flatly: contracts, logs, health output, packet history — never source. If it can’t figure out a dispatch from those, the correct output isn’t a guess. It’s the sentence &lt;em&gt;“contracts are underspecified,”&lt;/em&gt; and a stop.&lt;/p&gt;
&lt;p&gt;I launch these as separate sessions with a one-line script. The role isn’t a vibe — it’s bound at launch, down to which model runs it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ start-claude.sh writer-query-api --role writer -p &amp;quot;Implement the bounded slice in the packet ...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Remote-control name: sklaude-writer-query-api-04417&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Claude Code session &amp;#39;skaldborn-writer-query-api-04417&amp;#39; started.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Role: writer (model: claude-opus-4-8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Expected handoff: state/coordination/writer-query-api-handoff.md&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;start-claude.sh&lt;/code&gt; spins up a detached session, pins the model to the role, drops a sentinel file that says &lt;em&gt;this slug is running&lt;/em&gt;, and tells me exactly which file will appear when the session is done. Completion isn’t “the agent said it finished.” Completion is &lt;em&gt;a handoff file exists on disk&lt;/em&gt;. The session can crash, run out of context, or wander off — the only thing that counts as done is the artifact landing where the script said it would.&lt;/p&gt;
&lt;p&gt;None of this matters, though, without the part that makes the writer’s blinders real.&lt;/p&gt;
&lt;h2 id=&quot;the-fence-around-the-writer&quot;&gt;The fence around the writer&lt;/h2&gt;
&lt;p&gt;A writer agent is handed a &lt;strong&gt;packet&lt;/strong&gt; — a scoped work order. The packet names the component it’s allowed to touch and, critically, lists &lt;code&gt;allowed_source_roots&lt;/code&gt; and &lt;code&gt;forbidden_source_roots&lt;/code&gt;: the exact directories it may read and write, and the ones it may not.&lt;/p&gt;
&lt;p&gt;I used to hope the agent would respect that. Now a hook enforces it, and the agent’s own good intentions are irrelevant.&lt;/p&gt;
&lt;p&gt;Before any writer is dispatched, the orchestrator stages a tiny scope file naming the component contract. The first time the writer tries to touch a file, a &lt;code&gt;PreToolUse&lt;/code&gt; hook — &lt;code&gt;enforce-component-boundary.sh&lt;/code&gt; — wakes up, reads that contract’s allowed roots, and checks the path. If the file is outside the fence, the tool call doesn’t happen:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;hookSpecificOutput&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;hookEventName&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;PreToolUse&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;permissionDecision&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;deny&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;permissionDecisionReason&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Component boundary violation: &amp;lt;path&amp;gt; is outside allowed roots for component &amp;#39;&amp;lt;name&amp;gt;&amp;#39;.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s not a log line written after the fact. It’s a refusal &lt;em&gt;before&lt;/em&gt; the edit. A writer scoped to the query API physically cannot open a simulation file, no matter how reasonable its plan to do so sounds. And there’s a meaner case the hook handles too: if a writer is dispatched with &lt;strong&gt;no&lt;/strong&gt; scope staged at all — the orchestrator forgot — it doesn’t shrug and allow everything. It denies the very first file operation, with a reason that says exactly what went wrong: &lt;em&gt;component scope missing — the orchestrator must stage scope before calling the writer.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is the whole philosophy in one design choice: &lt;strong&gt;fail closed.&lt;/strong&gt; A misconfigured dispatch — the exact kind of mistake a busy orchestrator makes at 1am — doesn’t quietly grant an agent the run of the repo. It stops the agent at the door. The unsafe default is the one that can’t happen.&lt;/p&gt;
&lt;p&gt;(People ask why I don’t just give each writer its own git worktree. I tried. Eleven stale worktrees once stranded eighteen commits, and I went looking for them like lost luggage. The boundary hook plus packet scope gives me the isolation without the graveyard. Writers commit straight to main, inside their fence.)&lt;/p&gt;
&lt;h2 id=&quot;the-fence-around-the-investigator&quot;&gt;The fence around the investigator&lt;/h2&gt;
&lt;p&gt;The boundary hook guards &lt;em&gt;writing&lt;/em&gt;. But my April near-miss wasn’t a writer. It was an investigator — an agent sent to look around and report. Those are the dangerous ones, because their output isn’t a diff you can review line by line. It’s a confident paragraph, and confident paragraphs are exactly what these models are best at producing whether or not they’re true.&lt;/p&gt;
&lt;p&gt;So that incident got its own fence — and it’s my favorite one, because of &lt;em&gt;what&lt;/em&gt; it forces.&lt;/p&gt;
&lt;p&gt;Now, before any investigation-class agent can be dispatched, the dispatcher has to write a sidecar that says, in advance, how the finding will be proven. If it doesn’t, the hook refuses the dispatch outright — no sidecar staged at &lt;code&gt;state/coordination/pending-subagent-audit.json&lt;/code&gt; (or its freeform sibling), no Agent call fires at all.&lt;/p&gt;
&lt;p&gt;The audit sidecar isn’t bureaucracy. It makes the claim falsifiable &lt;em&gt;by construction&lt;/em&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;packet_type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;audit&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;canonical_commands&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;make validate-governance-enforcement-declared&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;canonical_files&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;docs/governance/playbooks/...&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;expected_evidence_format&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;raw_stdout&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;canonical_commands&lt;/code&gt; is the exact command whose output is the evidence. (In this case, a check that walks every governance rule in the repo and asserts each one declares &lt;em&gt;how&lt;/em&gt; it’s enforced — a real script, a hook, a CI target — versus being a nice paragraph nobody runs. It’s the gate the April agent claimed was failing.) &lt;code&gt;expected_evidence_format: raw_stdout&lt;/code&gt; means the agent’s report has to &lt;em&gt;quote that command’s actual output&lt;/em&gt;, not summarize it, not characterize it — paste it. You can’t claim a gate is red if the gate’s own stdout, sitting in your report, says green.&lt;/p&gt;
&lt;p&gt;Think about what this would have done in April. The agent that fabricated a failing check would have been forced, before it ever ran, to declare: &lt;em&gt;the proof of this finding is the verbatim output of this command.&lt;/em&gt; And the verbatim output was twenty-seven of twenty-seven, passing. The lie and its own disproof would have been in the same report. I’d have caught it in a glance — but now the glance is guaranteed to have something to catch &lt;em&gt;on&lt;/em&gt;, every time, whether or not I’m sharp that morning.&lt;/p&gt;
&lt;p&gt;There’s an escape hatch, because not every look-around is a fact-claim. “Find me the usages of this function” isn’t an audit; it’s a search. For those, the dispatcher writes a &lt;em&gt;freeform&lt;/em&gt; sidecar with one required field — a plain-English &lt;code&gt;reason&lt;/code&gt; — and the hook lets it through. That’s deliberate. The discipline I want isn’t “fill out forms forever.” It’s: &lt;strong&gt;if you’re going to assert a fact about my repo, name your proof up front, and if you’re just poking around, say so out loud.&lt;/strong&gt; The escape hatch is visible in the audit trail too, so “freeform everything” can’t quietly become the lazy default.&lt;/p&gt;
&lt;h2 id=&quot;how-the-fences-come-together-the-loop&quot;&gt;How the fences come together: the loop&lt;/h2&gt;
&lt;p&gt;Day to day, the orchestrator runs a loop that’s almost boring, which is the point. Boring is what you want from the thing holding the leashes.&lt;/p&gt;
&lt;p&gt;It reads the next unblocked packet. It stages the writer’s scope. It dispatches the writer — which can now only touch its fence. When the writer’s done, it dispatches a &lt;em&gt;separate&lt;/em&gt; reviewer against the diff and the contract. When the reviewer approves, a verifier runs the regression profile. Only then does the packet close.&lt;/p&gt;
&lt;p&gt;No single agent both writes the code and blesses it. No agent that’s reasoning about the system is also the one searching it and reporting facts about it. The investigator that could hallucinate can’t dispatch without declaring its proof. The writer that could overreach can’t open a file outside its lines. And the orchestrator coordinating all of it can’t read source to form opinions of its own.&lt;/p&gt;
&lt;p&gt;Every one of those is a &lt;em&gt;structural&lt;/em&gt; separation, not a polite request. The agents are good colleagues. I just don’t build as though they are.&lt;/p&gt;
&lt;h2 id=&quot;what-it-feels-like-to-work-this-way&quot;&gt;What it feels like to work this way&lt;/h2&gt;
&lt;p&gt;I won’t pretend it’s frictionless. It isn’t.&lt;/p&gt;
&lt;p&gt;Every writer dispatch needs its scope staged first. Every fact-finding mission needs its proof named first. There are mornings the boundary hook denies something I genuinely wanted, and I have to stop and ask whether the fence is wrong or my plan is — and embarrassingly often, the fence is right and I was about to let an agent reach across a boundary I’d drawn for a reason.&lt;/p&gt;
&lt;p&gt;But here’s the trade I actually made. The friction is paid by &lt;em&gt;me&lt;/em&gt;, up front, in small visible amounts — a scope file, a named command. The alternative cost is paid &lt;em&gt;later&lt;/em&gt;, invisibly, in fabricated bug reports that become real commits, in an agent quietly rewriting a file three components away, in the slow rot of a codebase edited by something I trusted instead of checked. I’ve paid the second kind. It’s much more expensive, and you never see the bill until it’s overdue.&lt;/p&gt;
&lt;p&gt;The fences don’t make the AI smarter. They make its mistakes cheap and loud instead of expensive and silent. That’s the entire deal.&lt;/p&gt;
&lt;h2 id=&quot;what-id-tell-myself-in-february&quot;&gt;What I’d tell myself in February&lt;/h2&gt;
&lt;p&gt;If you’re about to hand real write access to an agent — and more of us are, every month — here’s what I wish I’d known before the April morning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Assume carelessness, not malice, and definitely not competence.&lt;/strong&gt; Design for the agent that confidently reports red on green. It will happen. Plan for it instead of being surprised by it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A human glance is not a control.&lt;/strong&gt; If your only safety net is that you’ll notice, you have no net the moment you look away — and the whole point of an agent is to look away.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; A misconfigured dispatch should stop the agent at the door, not silently grant it the keys. Make the unsafe default impossible, not merely discouraged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate the roles that could collude.&lt;/strong&gt; Don’t let one agent both write code and approve it, or both reason about the system and report facts about it. Cheap separations prevent expensive failures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make every factual claim name its own proof.&lt;/strong&gt; “The gate is failing” is worthless. “The gate is failing; here is its verbatim output” disproves itself when it’s wrong. Force the second shape.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pay the friction up front, on purpose.&lt;/strong&gt; A scope file and a named command are small, visible costs. The thing they prevent is a large, invisible one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The agents do real work on Skaldborn — most of it, by volume. I sleep fine about that, not because I trust them, but because I stopped needing to.&lt;/p&gt;
&lt;p&gt;The companion to this one is the technical build guide: &lt;a href=&quot;https://www.skaldborn.com/devlog/07-how-to-let-an-agent-write-your-code/&quot;&gt;How to let an agent write your code without giving it the keys&lt;/a&gt; — a line-by-line walk through the launch script, the two fail-closed hooks, and the dispatch loop, close enough to the real files that you could stand up your own version of these fences over a weekend. This post is the &lt;em&gt;why&lt;/em&gt;; that one’s the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;If you want to follow along, subscribe via the form at the bottom of any page — one short email when the next post lands. If you want to argue — or just tell me what you’re wiring up — write to &lt;a href=&quot;mailto:devlog@skaldborn.com&quot;&gt;devlog@skaldborn.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Everything else is the boring engineering of making it true.&lt;/p&gt;</content:encoded></item><item><title>How to let an agent write your code without giving it the keys</title><link>https://www.skaldborn.com/devlog/07-how-to-let-an-agent-write-your-code/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/07-how-to-let-an-agent-write-your-code/</guid><description>The build guide behind the fences: a Claude Code setup that hands real write access to coding agents and survives it. The PreToolUse deny-by-JSON contract, a launcher that binds role to model and makes &apos;done&apos; a file on disk, a boundary hook that confines each writer to its component, and a dispatch-discipline hook that won&apos;t let a fact-finder run until it has named — on disk, up front — how its finding will be proven. Trimmed-but-real bash you can port to any harness with a pre-execution hook.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the technical companion to &lt;a href=&quot;https://www.skaldborn.com/devlog/06-the-colleague-who-hallucinates/&quot;&gt;The colleague who hallucinates&lt;/a&gt;. That post told the story — an audit agent that reported a passing gate (twenty-seven of twenty-seven) as failing and nearly turned a hallucination into four real commits — and the principle that fell out of it: you don’t make a coding agent trustworthy, you make its output checkable, and you build the workspace so the dangerous moves are either impossible or self-reporting. This one is the build guide. It walks through the actual machinery — a ~150-line launcher, two fail-closed hooks, and the dispatch loop — close enough to the real thing that you can stand up your own version.&lt;/p&gt;
&lt;p&gt;It’s built on Claude Code (the CLI), and the specific mechanism it leans on — a hook that runs &lt;em&gt;before&lt;/em&gt; a tool call and can veto it — is Claude Code’s &lt;code&gt;PreToolUse&lt;/code&gt; hook. If your agent harness has an equivalent pre-execution interception point, the same shapes port over. Linux and bash are assumed. The snippets are trimmed for the page — error handling and a few escape hatches are elided — but the control flow is what runs.&lt;/p&gt;
&lt;p&gt;You don’t have to have read the companion to use this. The launch pattern, the deny-by-JSON hook contract, the scope-sidecar trick, and the audit-sidecar discipline generalize to any setup where you’re handing real write access to something that occasionally lies with confidence.&lt;/p&gt;
&lt;h2 id=&quot;why-this-is-hard&quot;&gt;Why this is hard&lt;/h2&gt;
&lt;p&gt;Here’s the asymmetry that makes the whole problem.&lt;/p&gt;
&lt;p&gt;When you hand an agent a &lt;em&gt;structured&lt;/em&gt; work order — implement this, in these files, validated by this command — you have a lot of surface to enforce against. The scope is a list of paths. The acceptance check is a command with an exit code. You can gate all of it.&lt;/p&gt;
&lt;p&gt;When you send an agent to &lt;em&gt;look around and tell you what it found&lt;/em&gt;, you have almost nothing. The input is a freeform prompt. The output is a paragraph. And a paragraph that says “the gate is failing” is indistinguishable, at the dispatcher’s end, from a paragraph that says “the gate is failing” when it isn’t. The model is &lt;em&gt;best&lt;/em&gt; at producing fluent, confident prose — which is exactly the output you can least verify.&lt;/p&gt;
&lt;p&gt;So the strategy splits in two:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;For agents that write&lt;/strong&gt; — fence the filesystem. Make it physically impossible to touch a file outside the work order, and fail closed when the work order is missing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For agents that investigate&lt;/strong&gt; — force every factual claim to name its own proof, up front, before the agent even runs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both are enforced at the same chokepoint: a hook that fires before the tool call and can refuse it. Everything below is variations on that one move.&lt;/p&gt;
&lt;h2 id=&quot;the-one-mechanism-everything-hangs-on&quot;&gt;The one mechanism everything hangs on&lt;/h2&gt;
&lt;p&gt;A Claude Code &lt;code&gt;PreToolUse&lt;/code&gt; hook is a command that runs before a tool executes. It receives the tool call as JSON on stdin, and it can allow the call (exit 0, no output) or deny it by printing a small JSON decision and exiting 0:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;deny&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  cat&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;EOF&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  &amp;quot;hookSpecificOutput&amp;quot;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    &amp;quot;hookEventName&amp;quot;: &amp;quot;PreToolUse&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    &amp;quot;permissionDecision&amp;quot;: &amp;quot;deny&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    &amp;quot;permissionDecisionReason&amp;quot;: &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;EOF&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s the entire contract. Print that, and the tool call never happens; the &lt;code&gt;permissionDecisionReason&lt;/code&gt; is fed back to the agent as the reason it was blocked. Note the convention: you &lt;code&gt;exit 0&lt;/code&gt; even when denying — the &lt;em&gt;decision&lt;/em&gt; is in the JSON, not the exit code. (Exiting 2 with a message on stderr does the same thing; the JSON form is just easier to read and lets you write a precise reason.)&lt;/p&gt;
&lt;p&gt;Every fence in this post is a hook that decides whether to call &lt;code&gt;deny&lt;/code&gt;. Hold onto that and the rest is detail.&lt;/p&gt;
&lt;h2 id=&quot;piece-1--the-launcher&quot;&gt;Piece 1 — the launcher&lt;/h2&gt;
&lt;p&gt;Before any fences matter, you need a clean way to start an agent in a known role. On Skaldborn that’s a ~150-line bash script, &lt;code&gt;start-claude.sh&lt;/code&gt;. It does three things worth copying.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It binds the role to a model at launch.&lt;/strong&gt; A “writer” and a “reviewer” aren’t the same actor with different instructions — they’re different sessions, on deliberately different models, so the thing that reviews the work was never the thing that wrote it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;role_to_model&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  case&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; in&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;    architect&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;steward&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;orchestrator&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;writer&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;claude-opus-4-8&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;    reviewer&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;verifier&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;                      echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;claude-opus-4-7&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    *)&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# unknown role → hard error, don&amp;#39;t guess&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  esac&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;It launches detached, with the role’s model and a remote-control handle:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;INNER&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;claude --dangerously-skip-permissions --remote-control &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$RC_NAME&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$MODEL&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  ]] &amp;amp;&amp;amp; INNER&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INNER&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; --model &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$MODEL&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PROMPT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; INNER&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INNER&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; $(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sq_escape&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PROMPT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;tmux&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; new-session&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -d&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -s&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SESSION&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -e&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;CLAUDE_SESSION_SLUG=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SLUG&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INNER&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;. That flag is what makes the agent able to run unattended — and it’s &lt;em&gt;also&lt;/em&gt; exactly why the hooks below have to be airtight. Skipping the interactive permission prompt doesn’t skip the hooks. The hooks are the permission system once the human is no longer in the loop. If you’re going to use that flag, the fences aren’t optional; they’re the replacement for the thing you turned off.&lt;/p&gt;
&lt;p&gt;(&lt;code&gt;sq_escape&lt;/code&gt; is a POSIX single-quote escaper. The prompt travels bash → tmux → &lt;code&gt;sh -c&lt;/code&gt; → &lt;code&gt;claude&lt;/code&gt;, and a prompt with a stray quote or newline will shred the argv chain. Wrap in &lt;code&gt;&amp;#39;...&amp;#39;&lt;/code&gt;, replace embedded &lt;code&gt;&amp;#39;&lt;/code&gt; with &lt;code&gt;&amp;#39;\&amp;#39;&amp;#39;&lt;/code&gt;, move on with your life.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It makes “done” a fact on disk, not a claim.&lt;/strong&gt; The launcher prints — and a sentinel file records — the exact path that will exist when the session finishes its work:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Role: writer (model: claude-opus-4-8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Expected handoff: state/coordination/writer-query-api-handoff.md&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Active-session sentinel: state/coordination/active-sessions/writer-query-api.json&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This matters more than it looks. An agent that says “I’m done!” and an agent that crashed mid-task produce the same silence from the outside. By contract, completion is &lt;em&gt;the handoff file landing at the declared path&lt;/em&gt; — nothing else counts. A caller can block on it without parsing any chat:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;until&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-f&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; state/coordination/writer-query-api-handoff.md ]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;do&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; sleep&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 60&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;DONE: writer-query-api&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sentinel (written at launch, removed by a tmux &lt;code&gt;session-closed&lt;/code&gt; hook when the session dies) answers the other question — &lt;em&gt;is it still running?&lt;/em&gt; Present means alive; absent means stopped. Together they give you a cheap, file-based view of a fleet of agents without a scheduler.&lt;/p&gt;
&lt;h2 id=&quot;piece-2--the-fence-around-the-writer&quot;&gt;Piece 2 — the fence around the writer&lt;/h2&gt;
&lt;p&gt;Now the important one. A writer is dispatched with a scope: the component it may touch, expressed as a set of allowed directory roots in that component’s contract file. The boundary hook (&lt;code&gt;enforce-component-boundary.sh&lt;/code&gt;, wired to fire before every &lt;code&gt;Read&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, and &lt;code&gt;Write&lt;/code&gt;) enforces it.&lt;/p&gt;
&lt;p&gt;The first problem is mechanical and worth knowing if you build this yourself: &lt;strong&gt;environment variables don’t propagate to subagents&lt;/strong&gt;, and at the moment the hook fires for a freshly spawned agent, &lt;em&gt;the agent doesn’t exist yet&lt;/em&gt; — so you can’t key the scope on an agent id you don’t have. The fix is a single-shot file. The dispatcher drops one &lt;code&gt;pending-scope.json&lt;/code&gt; before spawning the writer; the writer’s very first tool call claims it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# No scope in the environment? Try to claim the pending scope file.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-z&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;COMPONENT_CONTRACT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:-&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  AGENT_ID&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.agent_id // &amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;   &amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INPUT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  AGENT_TYPE&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.agent_type // &amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INPUT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  SIDECAR&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;state/coordination/active-scope-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AGENT_ID&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;.json&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  PENDING&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;state/coordination/pending-scope.json&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # First tool call claims the pending scope as this agent&amp;#39;s active scope.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; -f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SIDECAR&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; -f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PENDING&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;mv&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PENDING&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SIDECAR&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SIDECAR&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; COMPONENT_CONTRACT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.contract&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SIDECAR&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the part that makes it a fence and not a suggestion — &lt;strong&gt;fail closed.&lt;/strong&gt; A writer that arrives with no scope resolved is a misconfigured dispatch (the orchestrator forgot to stage it). The wrong move is to shrug and allow everything; that silently re-opens the exact hole the fence exists to close. So:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# A writer with no resolved scope is a misconfiguration. Deny, don&amp;#39;t default-open.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-z&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;COMPONENT_CONTRACT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:-&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AGENT_TYPE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; writer-&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  deny&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;Component scope missing for &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AGENT_TYPE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;. The orchestrator must stage &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;state/coordination/pending-scope.json before dispatching the writer.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With a scope resolved, the actual check is almost boring. Read the allowed roots out of the contract; if the target path is inside one, allow; otherwise, deny:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;ALLOWED_ROOTS&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;(.source_roots + .test_roots + .shared_dependencies)[]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$CONTRACT_PATH&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; IFS&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; read&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; root&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-z&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$root&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;continue&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$root&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; !=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; root&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PWD&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$root&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FILE_PATH&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$root&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FILE_PATH&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$root&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;/&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;          # inside the fence — allow&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;done&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$ALLOWED_ROOTS&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;COMPONENT_NAME&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.component // &amp;quot;unknown&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$CONTRACT_PATH&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;deny&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;Component boundary violation: &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FILE_PATH&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; is outside allowed roots for &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;component &amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$COMPONENT_NAME&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;#39;.&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s a small universal allowlist on top of this — every agent can always read the shared contracts, the coordination directory, the build files — so the fence constrains &lt;em&gt;source&lt;/em&gt;, not the agent’s ability to orient itself. And reads of the agent’s own component directory are always fine. But the spine is the loop above: a writer scoped to the query API cannot open a simulation file, full stop, no matter how reasonable its plan to “just check one thing” sounds.&lt;/p&gt;
&lt;p&gt;One deliberate non-choice: I don’t isolate writers in git worktrees. I tried; eleven stale worktrees once stranded eighteen commits and I spent an afternoon recovering them like lost luggage. The boundary hook plus the scope file gives the isolation without the graveyard. Writers commit straight to the main branch, inside their fence.&lt;/p&gt;
&lt;h2 id=&quot;piece-3--the-fence-around-the-investigator&quot;&gt;Piece 3 — the fence around the investigator&lt;/h2&gt;
&lt;p&gt;The boundary hook guards writing. It does nothing for the more insidious case: an agent sent to &lt;em&gt;investigate&lt;/em&gt;, which can’t damage a file but can hand you a confident, fabricated finding that you then act on. (That’s the failure the companion post opens with — an audit agent that reported a passing gate as failing and nearly turned a hallucination into four real commits.)&lt;/p&gt;
&lt;p&gt;The second hook (&lt;code&gt;enforce-subagent-dispatch-discipline.sh&lt;/code&gt;, wired to fire before the &lt;code&gt;Agent&lt;/code&gt; tool — the one that spawns sub-agents) gates exactly the investigation-class spawns and lets everything else through:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SUBAGENT_TYPE&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.tool_input.subagent_type // &amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$INPUT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;case&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$SUBAGENT_TYPE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; in&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;  Explore&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;general-purpose&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# the fact-finding agents — gated below&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  *)&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;                  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# writers, reviewers, etc. — not our problem here&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;esac&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rule for the gated ones: you may not dispatch a fact-finder without first declaring, on disk, how its finding will be proven. Two sidecars satisfy that. An &lt;strong&gt;audit&lt;/strong&gt; sidecar — for any dispatch that will assert a fact — or a &lt;strong&gt;freeform&lt;/strong&gt; sidecar with a stated reason, for genuine open-ended search (“find the usages of this function”) where there’s no fact to prove yet.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;AUDIT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;state/coordination/pending-subagent-audit.json&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;FREEFORM&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;state/coordination/pending-subagent-freeform.json&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AUDIT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; err&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;validate_audit&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AUDIT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    deny&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;audit sidecar invalid: &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$err&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  consume&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$AUDIT&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;      # claim succeeds — dispatch allowed&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FREEFORM&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  reason&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.reason // &amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FREEFORM&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$reason&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$reason&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; !=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;null&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; deny&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;freeform bypass requires a non-empty reason&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  consume&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$FREEFORM&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;deny&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;audit sidecar required for Explore/general-purpose dispatch. Write &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;pending-subagent-audit.json or pending-subagent-freeform.json before the call.&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole trick is in what &lt;code&gt;validate_audit&lt;/code&gt; insists on. An audit packet has to name &lt;em&gt;the evidence&lt;/em&gt;, and the evidence has to be the kind you can’t fake:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;validate_audit&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; file&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  jq&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; empty&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$file&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; 2&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;/dev/null&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;not valid JSON&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; fmt;  fmt&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.expected_evidence_format // &amp;quot;&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$file&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; nc;   nc&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;(.canonical_commands // []) | length&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$file&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; nf;   nf&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;(.canonical_files    // []) | length&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$file&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  case&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$fmt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; in&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt; raw_stdout&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;parsed_jsonl&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#DBEDFF&quot;&gt;file_read&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    *)&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;expected_evidence_format must be raw_stdout|parsed_jsonl|file_read; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  esac&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # Must name at least one evidence source.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  (( nc &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; nf &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; )) &amp;amp;&amp;amp; errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;name at least one canonical_command or canonical_file; &amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # &amp;quot;I read a file&amp;quot; must name the file; &amp;quot;I ran a command&amp;quot; must name the command.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$fmt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;file_read&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]]                            &amp;amp;&amp;amp; (( nf &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; )) &amp;amp;&amp;amp; errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;file_read needs canonical_files; &amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$fmt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;raw_stdout&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$fmt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;parsed_jsonl&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; (( nc &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; )) &amp;amp;&amp;amp; errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$fmt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; needs canonical_commands; &amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-n&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$errors&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;errors&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; }&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a real audit sidecar looks like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;packet_type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;audit&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;canonical_commands&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;make validate-governance-enforcement-declared&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;expected_evidence_format&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;raw_stdout&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;expected_evidence_format: raw_stdout&lt;/code&gt; is the load-bearing field. It means the agent’s report must &lt;em&gt;quote that command’s actual output verbatim&lt;/em&gt; — not summarize it, not characterize it, paste it. You cannot claim a gate is red when the gate’s own stdout, sitting three lines down in your own report, says green. The lie and its disproof end up in the same document. The fence doesn’t stop the agent from being wrong; it stops the agent from being wrong &lt;em&gt;invisibly&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;consume&lt;/code&gt; is the audit trail. The sidecar doesn’t get deleted — it’s moved, stamped with the tool-call id that claimed it, so afterward you can match “what did the dispatcher promise” against “which spawn actually consumed it”:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;consume&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  local&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; src&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;$1&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  mkdir&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -p&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; state/coordination/consumed&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  mv&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$src&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;state/coordination/consumed/${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;TOOL_USE_ID&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}-$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;basename&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$src&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;)&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The freeform escape hatch is deliberate, and deliberately visible. The discipline I want isn’t “fill out a form forever” — it’s “if you’re going to assert a fact about the repo, name your proof; if you’re just poking around, say so out loud.” Because every freeform reason is logged to the same trail, “freeform everything” can’t quietly become the path of least resistance without leaving fingerprints.&lt;/p&gt;
&lt;h2 id=&quot;wiring-it-together&quot;&gt;Wiring it together&lt;/h2&gt;
&lt;p&gt;The hooks are inert until you register them. In &lt;code&gt;.claude/settings.json&lt;/code&gt;, you attach each to the tool it guards via a matcher:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;hooks&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;PreToolUse&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;matcher&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Read|Edit|Write&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;hooks&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;          { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;.claude/hooks/enforce-component-boundary.sh&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;matcher&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Agent&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;hooks&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;          { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;command&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;.claude/hooks/enforce-subagent-dispatch-discipline.sh&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Read|Edit|Write&lt;/code&gt; is a regex over tool names — the boundary hook fires before any file op. &lt;code&gt;Agent&lt;/code&gt; is the spawn tool — the dispatch-discipline hook fires before any sub-agent is created. That’s the entire integration. (Skaldborn runs a few more hooks at each point — one that keeps the orchestrator out of source code, one that blocks destructive git, an audit logger — but those two are the load-bearing pair this post is about.)&lt;/p&gt;
&lt;h2 id=&quot;the-loop-that-drives-it&quot;&gt;The loop that drives it&lt;/h2&gt;
&lt;p&gt;The fences are passive — they only say no. Something has to do the dispatching, and on Skaldborn that’s the orchestrator: a session whose entire job is to run a phase of work by handing scoped packets to fresh agents. Its loop, stripped to the spine:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the next unblocked work packet.&lt;/li&gt;
&lt;li&gt;Stage the writer’s scope: write &lt;code&gt;pending-scope.json&lt;/code&gt; naming the component contract.&lt;/li&gt;
&lt;li&gt;Spawn the writer. The boundary hook now confines it to that component.&lt;/li&gt;
&lt;li&gt;When the handoff file lands, spawn a &lt;strong&gt;separate&lt;/strong&gt; reviewer against the diff and the contract.&lt;/li&gt;
&lt;li&gt;On approval, spawn a verifier to run the regression check.&lt;/li&gt;
&lt;li&gt;Only then mark the packet done.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Two constraints fall out of the mechanics. Writers are &lt;strong&gt;serial&lt;/strong&gt; — there’s one &lt;code&gt;pending-scope.json&lt;/code&gt; at a time, so you stage, spawn, wait for the first tool call to claim it, then stage the next. (Parallel writers need a per-spawn keying scheme the single-shot file doesn’t give you.) And the orchestrator itself is &lt;strong&gt;forbidden from reading source&lt;/strong&gt; — enforced by yet another boundary hook — because the moment the coordinator starts reading code to “just check,” it stops being the clean context that can notice when a packet is mis-scoped. If it can’t dispatch from contracts, logs, and packet history alone, the correct output isn’t a guess. It’s “the contract is underspecified,” and a stop.&lt;/p&gt;
&lt;p&gt;No single agent both writes code and approves it. No agent that reasons about the system is also the one reporting facts about it. Those separations are the point, and they’re structural, not polite.&lt;/p&gt;
&lt;h2 id=&quot;gotchas-worth-knowing-before-you-build-this&quot;&gt;Gotchas worth knowing before you build this&lt;/h2&gt;
&lt;p&gt;A few things cost me time. Skip the lessons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Env vars don’t reach sub-agents, and the sub-agent doesn’t exist when the parent’s spawn hook fires.&lt;/strong&gt; This is &lt;em&gt;the&lt;/em&gt; reason scope is passed by single-shot file instead of an environment variable. Don’t fight it — drop a file, claim it on first tool call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;exit 0&lt;/code&gt; is how you deny.&lt;/strong&gt; The decision lives in the JSON on stdout, not the exit code. An uncaught error that exits non-zero is &lt;em&gt;not&lt;/em&gt; a deny — depending on config it can fail open. Which is why the next point matters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decide your failure direction on purpose.&lt;/strong&gt; These hooks shell out to &lt;code&gt;jq&lt;/code&gt;. If &lt;code&gt;jq&lt;/code&gt; is missing, what happens? Skaldborn’s dispatch-discipline hook fails &lt;em&gt;closed&lt;/em&gt; — no &lt;code&gt;jq&lt;/code&gt;, no dispatch — with a narrow, explicit environment-variable escape hatch for the rare case you need to override. A security fence that fails open the moment a tool is missing isn’t a fence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-session tmux hooks don’t fire on session close&lt;/strong&gt; — by the time &lt;code&gt;session-closed&lt;/code&gt; runs, the session and its attached hooks are already gone. Use a global hook that extracts the session name from the hook context. (This is the bit that cleans up the sentinel files.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quote-escape the prompt across the whole chain.&lt;/strong&gt; bash → tmux → &lt;code&gt;sh -c&lt;/code&gt; → &lt;code&gt;claude&lt;/code&gt; will happily destroy a prompt containing a quote or newline. POSIX single-quote escaping survives it; bash-only tricks don’t, because tmux runs the inner command through &lt;code&gt;sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;how-skaldborn-consumes-this&quot;&gt;How Skaldborn consumes this&lt;/h2&gt;
&lt;p&gt;None of this is a side project — it’s how the game actually gets built. The substrate work, the content pipeline, the client: most of it, by volume, is written by agents running inside these fences, dispatched by the loop above, while I’m reviewing something else or asleep. The launcher starts them in role. The boundary hook keeps each one inside its component. The dispatch-discipline hook keeps the fact-finders honest. The handoff files tell me what finished. I read diffs and verdicts, not keystrokes.&lt;/p&gt;
&lt;p&gt;The fences don’t make the agents smarter. They make the agents’ mistakes cheap and loud instead of expensive and silent — a misconfigured dispatch stops at the door, an out-of-bounds edit never lands, a fabricated finding arrives stapled to its own disproof. That’s the whole trade, and it’s the only reason I’m comfortable handing real write access to something I’ve watched confidently report green as red.&lt;/p&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;There’s a piece I deferred and still owe: a post-run check that reads the agent’s &lt;em&gt;transcript&lt;/em&gt; and verifies each promised command’s output actually appears in its report — turning “detectable in review” into “mechanically impossible to fake.” The pre-dispatch sidecar forces the agent to &lt;em&gt;name&lt;/em&gt; its proof; it doesn’t yet force the agent to &lt;em&gt;quote it truthfully&lt;/em&gt;. That’s the next fence.&lt;/p&gt;
&lt;p&gt;If you want to follow along, subscribe via the form at the bottom of any page — one short email when the next post lands. If you build a version of this and something breaks — or you want to argue — write to &lt;a href=&quot;mailto:devlog@skaldborn.com&quot;&gt;devlog@skaldborn.com&lt;/a&gt;. I read everything.&lt;/p&gt;</content:encoded></item><item><title>Skaldborn&apos;s art content pipeline</title><link>https://www.skaldborn.com/devlog/04-art-content-pipeline/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/04-art-content-pipeline/</guid><description>An AI-generated pixel-art pipeline with no AI judging the AI. The recipe is the type object, I&apos;m the gate, and the manifest is deterministic on the way out — even though the generation in the middle isn&apos;t. Here&apos;s the stack, the saga, and the war stories.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There is no AI judging the AI. No vision-language model rates the output. No aesthetic-classifier auto-promotes the best of a batch. Every image the Skaldborn art pipeline ships passes my review before it reaches the manifest, and the pipeline is built so &lt;em&gt;only&lt;/em&gt; my review can promote it.&lt;/p&gt;
&lt;p&gt;The pipeline generates pixel art with two AI backends — a local Stable Diffusion stack and &lt;a href=&quot;https://www.pixellab.ai&quot;&gt;PixelLab&lt;/a&gt;’s hosted pixel-art API — under a governance discipline borrowed from the simulation side of the engine. This post is about why we built it that way, what’s underneath, and where the receipts are.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Want to skip straight to the technical bits?&lt;/strong&gt; &lt;a href=&quot;https://www.skaldborn.com/devlog/05-comfyui-setup/&quot;&gt;Set up ComfyUI for your own content pipeline&lt;/a&gt; walks the install end to end on a 4 GB consumer GPU, plus the full PixelLab API integration. This post is the architectural &lt;em&gt;why&lt;/em&gt;; that one’s the operational &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-thirty-second-version&quot;&gt;The thirty-second version&lt;/h2&gt;
&lt;p&gt;A &lt;em&gt;recipe&lt;/em&gt; is a JSON file that describes what to make. It declares a small set of &lt;em&gt;levers&lt;/em&gt; (constrained to enums) and a larger set of &lt;em&gt;locked fields&lt;/em&gt; (everything else — checkpoint, LoRA strengths, seeds, canvas size, anchor offsets). Calling the CLI with a recipe and lever values queues one job per backend into a Postgres-backed saga. The saga drives each job through a state machine: generate, stage to a content-addressed path, wait for human approval at two gates, copy the approved file into a frozen “approved” slot, and emit a manifest entry.&lt;/p&gt;
&lt;p&gt;Every randomness source in the pipeline lives upstream of my approval. By the time an asset is in the manifest, it’s a frozen file pinned to a content-addressed path. The simulation consumes the frozen manifest. It never sees the generation pipeline.&lt;/p&gt;
&lt;p&gt;That is the spine. The rest of this post is what’s underneath.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;p&gt;Everything in the pipeline is open source or publicly purchasable, so a reader who wants to rebuild a piece of it can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;.NET 9.0&lt;/strong&gt; — worker host, CLI, saga state machine, all pipeline C#&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; — durable &lt;code&gt;art_jobs&lt;/code&gt; queue (claim ordering via &lt;code&gt;SELECT FOR UPDATE SKIP LOCKED&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Npgsql&lt;/strong&gt; — Postgres driver&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ComfyUI&lt;/strong&gt; — local Stable Diffusion orchestrator; runs on a host GPU and is reachable from inside Docker via &lt;code&gt;host.docker.internal:8188&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.pixellab.ai&quot;&gt;PixelLab&lt;/a&gt;&lt;/strong&gt; — hosted pixel-art API; eight-direction character rotations, isometric tiles, map objects&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker + Compose&lt;/strong&gt; — the worker runs as a long-lived compose service against a hermetic multi-stage Dockerfile&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OpenTelemetry (OTLP)&lt;/strong&gt; — metrics out of the worker; a Datadog Agent receives them when the opt-in env flag is set&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;System.CommandLine&lt;/strong&gt; — CLI argument parsing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bash&lt;/strong&gt; — &lt;code&gt;validate-recipe-coverage.sh&lt;/code&gt;, &lt;code&gt;promote-assets.sh&lt;/code&gt;, &lt;code&gt;process-character-animations.sh&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;free-tex-packer&lt;/strong&gt; — sprite-atlas packing (queued, not yet wired)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the local Stable Diffusion side, the production stack is concrete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Checkpoint:&lt;/strong&gt; &lt;a href=&quot;https://civitai.com/models/8124?modelVersionId=251729&quot;&gt;aZovyaRPGArtistTools v4VAE&lt;/a&gt; — an SD 1.5 fine-tune by Zovya for illustrative RPG concept art (game art, tabletop, book covers). VAE baked in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Style LoRA:&lt;/strong&gt; &lt;a href=&quot;https://civitai.com/models/26568?modelVersionId=31804&quot;&gt;NorseViking_v10&lt;/a&gt; by Nontime — Norse warrior / berserker style, applied at &lt;code&gt;strength_model: 0.7 / strength_clip: 0.5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upscaler:&lt;/strong&gt; &lt;a href=&quot;https://civitai.com/models/147759?modelVersionId=164821&quot;&gt;4xFoolhardyRemacri&lt;/a&gt; by FoolhardyVEVO — a 4× ESRGAN, run after FaceDetailer to bring concept output up to 2048×3072.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Negative embedding:&lt;/strong&gt; &lt;a href=&quot;https://civitai.com/models/56519?modelVersionId=60938&quot;&gt;negative_hand-neg&lt;/a&gt; by Nerfgun3 — corrects bad hand anatomy without dragging the style with it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Face detector:&lt;/strong&gt; &lt;a href=&quot;https://huggingface.co/Bingsu/adetailer&quot;&gt;face_yolov8n.pt&lt;/a&gt; — Bingsu’s nano-scale YOLOv8 fine-tune (mAP50 0.660 across multiple face datasets), used inside FaceDetailer for the bounding box.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Segmenter:&lt;/strong&gt; &lt;a href=&quot;https://github.com/facebookresearch/segment-anything&quot;&gt;sam_vit_b&lt;/a&gt; — Meta AI’s smallest Segment Anything ViT (the &lt;code&gt;sam_vit_b_01ec64.pth&lt;/code&gt; checkpoint), used by FaceDetailer to mask the detected face for the inpainting pass.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All public, all linkable. Reproducing the stack is one shell script and one recipe; &lt;a href=&quot;https://www.skaldborn.com/devlog/05-comfyui-setup/&quot;&gt;Set up ComfyUI for your own content pipeline&lt;/a&gt; walks through it end to end.&lt;/p&gt;
&lt;h2 id=&quot;the-hero-artifact-the-recipe&quot;&gt;The hero artifact: the recipe&lt;/h2&gt;
&lt;p&gt;The single most useful file to understand the pipeline is a recipe. Here is one, with in-world strings genericized:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;recipe_id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;age&amp;gt;.&amp;lt;kind&amp;gt;.&amp;lt;slug&amp;gt;.v&amp;lt;N&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;kind&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;kind&amp;gt;.&amp;lt;sub-kind&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;lever_schema&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;object&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;additionalProperties&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;required&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;lever_a&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;lever_b&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;properties&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;&amp;lt;lever_a&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;enum&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;val1&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;val2&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;val3&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;&amp;lt;lever_b&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;enum&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;val1&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;val2&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;locked_fields&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;checkpoint&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;base-model&amp;gt;.safetensors&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;loras&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;style-lora&amp;gt;.safetensors&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;strength_model&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0.7&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;strength_clip&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:  &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0.5&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;cfg_scale&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;7.5&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;sampler&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;dpmpp_2m&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;scheduler&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;karras&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;steps&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;canvas_width&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;512&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;canvas_height&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;768&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;seed_production&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;31337&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;composition_mode&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;standalone&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;anchor_offsets&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;feet&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:      { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;n&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],   &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;s&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;                     &amp;quot;e&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],   &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;w&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;hand_main&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;n&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;16&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-24&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;], &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;s&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-16&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-24&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;                     &amp;quot;e&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;20&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-20&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;], &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&amp;quot;w&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-20&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;-20&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;prompt_templates_by_backend&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;pixellab&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;endpoint&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;/v2/...&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;description&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;..., {lever_a} ..., {lever_b} ...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    &amp;quot;comfyui&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;workflow_ref&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;content/recipes/workflows/&amp;lt;id&amp;gt;.workflow.json&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;positive_prompt_template&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;..., {lever_a} ..., {lever_b} ...&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;valid_output_kinds&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&amp;lt;kind&amp;gt;.&amp;lt;sub-kind&amp;gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;default_palette_enforcement&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Levers are the only caller-adjustable parameters, and they are constrained to enums.&lt;/strong&gt; A recipe with three hair colors and two moods has a total output space of exactly six combinations. The CLI rejects any value that isn’t in the enum. This makes the total surface enumerable — you can pre-compute every possible output path before generating a single image — and it makes the fan-out hash deterministic.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Everything else is locked.&lt;/strong&gt; Checkpoint, LoRA strengths, sampler, scheduler, steps, seed, canvas size, anchor offsets. The recipe author commits to a generation profile at recipe-authoring time and the profile doesn’t shift run-to-run. If you want to change a locked field, you create &lt;code&gt;recipe.v2.json&lt;/code&gt; next to &lt;code&gt;recipe.v1.json&lt;/code&gt;. In-place mutation of locked fields is forbidden by convention; the validator catches drift; the v1 directory tree stays addressable for audit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;prompt_templates_by_backend&lt;/code&gt; is the multi-backend fan-out.&lt;/strong&gt; A character recipe declares both a ComfyUI workflow (for concept art, generated by a local Stable Diffusion stack) and a hosted pixel-art API template (for the eight-direction sprite rotations the game actually renders). At queue time, the CLI reads the map keys and inserts one row per backend into the saga’s job table. The saga worker picks each row up and routes it to the matching runner via reflection-based registry discovery. A closed enum here would mean editing the engine to add a new backend; the registry means a new backend is a new class with an attribute.&lt;/p&gt;
&lt;p&gt;This last property — &lt;em&gt;adding a content type without editing engine code&lt;/em&gt; — is the rule that triggered &lt;a href=&quot;https://www.skaldborn.com/devlog/02-why-we-deleted-two-months/&quot;&gt;the rebuild covered in our previous post&lt;/a&gt;. Recipe-as-type-object is the same pattern, applied at the content layer.&lt;/p&gt;
&lt;p&gt;The receipt: when we added the second backend kind to the pipeline, it landed as a new JSON recipe and zero edits to the runner, the saga, the registry, the queue, or the dependency-injection wiring. Recipe-as-type-object passed the same extensibility test that closed-enum dispatch had failed.&lt;/p&gt;
&lt;h2 id=&quot;the-pipeline-top-to-bottom&quot;&gt;The pipeline, top to bottom&lt;/h2&gt;
&lt;p&gt;Here is what happens to an asset, end to end.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;mermaid&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;graph TD&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Recipe[&amp;quot;Recipe (JSON)&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    CLI[&amp;quot;CLI: art generate&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Queue[(&amp;quot;art_jobs (Postgres)&amp;quot;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Concept[&amp;quot;Concept generation&amp;lt;br/&amp;gt;(local Stable Diffusion)&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ConceptGate{&amp;quot;Concept gate&amp;lt;br/&amp;gt;(human)&amp;quot;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Rotation[&amp;quot;Rotation generation&amp;lt;br/&amp;gt;(hosted pixel-art API)&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Stage[&amp;quot;Art-pool staging&amp;lt;br/&amp;gt;(content-addressed path)&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ReviewGate{&amp;quot;Final review gate&amp;lt;br/&amp;gt;(human)&amp;quot;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Promotion[&amp;quot;Promotion&amp;lt;br/&amp;gt;(copy to approved/)&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Projection[&amp;quot;Manifest entry projection&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Manifest[(&amp;quot;Age manifest&amp;quot;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Recipe --&amp;gt; CLI&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    CLI --&amp;gt;|&amp;quot;one row per backend&amp;quot;| Queue&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Queue --&amp;gt; Concept&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Queue --&amp;gt; Rotation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Concept --&amp;gt; ConceptGate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ConceptGate -- &amp;quot;approved&amp;quot; --&amp;gt; Rotation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Rotation --&amp;gt; Stage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Stage --&amp;gt; ReviewGate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ReviewGate -- &amp;quot;approved&amp;quot; --&amp;gt; Promotion&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Promotion --&amp;gt; Projection&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Projection --&amp;gt; Manifest&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Walking the stages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Recipe authoring.&lt;/strong&gt; A human writes a JSON file under &lt;code&gt;content/recipes/&lt;/code&gt;. A bash validator (&lt;code&gt;validate-recipe-coverage.sh&lt;/code&gt;) runs on every push and refuses the commit if a required field is missing, a lever isn’t an enum, or a component contract declares a &lt;code&gt;required_recipe_id&lt;/code&gt; that doesn’t have a matching file. Human time: minutes.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Job submission.&lt;/strong&gt; The CLI command &lt;code&gt;art generate &amp;lt;recipe-id&amp;gt; --&amp;lt;lever&amp;gt; value&lt;/code&gt; validates the lever values against the recipe’s schema, computes a content-addressed hash of the lever binding, and inserts one &lt;code&gt;art_jobs&lt;/code&gt; row per declared backend. &lt;code&gt;--dry-run&lt;/code&gt; short-circuits the database write. Wall time: under a second.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;3a. &lt;strong&gt;Concept generation (characters).&lt;/strong&gt; The saga worker claims a pending row, substitutes lever values into the ComfyUI workflow template, posts it to the local ComfyUI daemon, polls until completion, and writes the resulting PNG to a content-addressed path. Wall time on a 4 GB consumer GPU: 30–90 seconds per concept (512×768 base + a face-detection inpainting pass + a 4× ESRGAN upscale).&lt;/p&gt;
&lt;p&gt;3b. &lt;strong&gt;Tile / object generation (terrain, environment).&lt;/strong&gt; Tiles and props skip the concept gate entirely. The saga worker posts directly to the hosted pixel-art API with the prompt template, polls the background-job endpoint, and writes the result. Wall time: 10–140 seconds depending on endpoint (tiles ~35 s, icons ~15 s, environment objects ~90 s).&lt;/p&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Concept gate (characters only).&lt;/strong&gt; I run &lt;code&gt;art review-concepts &amp;lt;brief-id&amp;gt;&lt;/code&gt;. The terminal displays each concept image; I approve or reject. Approved concepts advance to rotation generation. Rejected concepts go to a terminal &lt;code&gt;Rejected&lt;/code&gt; state and never reach the paid API. This is the most cost-load-bearing review step in the pipeline.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rotation generation (characters).&lt;/strong&gt; For each approved concept, the saga calls the hosted pixel-art API’s “create character with eight directions” endpoint, supplying the concept image as both reference and seed. Wall time: 60–120 seconds. Output is a ZIP containing eight 1×N rotation strips plus skeleton data.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Art-pool staging.&lt;/strong&gt; The runner copies the bytes to &lt;code&gt;art_pool/candidates/&amp;lt;recipe_id&amp;gt;/&amp;lt;hash16&amp;gt;/&amp;lt;backend&amp;gt;/000.png&lt;/code&gt;. The path is the content-addressed hash of the lever binding; multiple frames live as &lt;code&gt;000.png&lt;/code&gt;, &lt;code&gt;001.png&lt;/code&gt;, etc.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Final review gate.&lt;/strong&gt; &lt;code&gt;art review &amp;lt;job-id&amp;gt;&lt;/code&gt; shows me the staged candidate. I approve or reject.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Promotion.&lt;/strong&gt; &lt;code&gt;art promote &amp;lt;recipe-id&amp;gt; &amp;lt;hash16&amp;gt; --backend &amp;lt;name&amp;gt; --frame 0&lt;/code&gt; copies the approved candidate to an &lt;code&gt;approved/&lt;/code&gt; subdirectory via copy-then-atomic-rename. Once promoted, the file is frozen on disk. Re-runs of upstream generation can land new candidates next to it; they cannot overwrite it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Manifest entry projection.&lt;/strong&gt; &lt;code&gt;RecipeManifestProjector.Project()&lt;/code&gt; produces a &lt;code&gt;ManifestEntry&lt;/code&gt; record carrying &lt;code&gt;composition_mode&lt;/code&gt;, &lt;code&gt;anchor_offsets&lt;/code&gt;, &lt;code&gt;variant_sprites&lt;/code&gt;, and &lt;code&gt;asset_path&lt;/code&gt; (pointing at the frozen file). The projector ships today; the actual write into &lt;code&gt;content/ages/&amp;lt;age&amp;gt;/v&amp;lt;N&amp;gt;/manifest.json&lt;/code&gt; is the next thing queued for this surface area.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Age manifest already carries entries for characters and tiles whose v2 generation hasn’t run yet — those entries hold placeholder asset paths. When promotion completes for a recipe, the projection updates the matching entry in place rather than appending a new one. The manifest is an authored spine; the pipeline fills in cells.&lt;/p&gt;
&lt;p&gt;The sprite-atlas packing step lives in &lt;code&gt;package.json&lt;/code&gt; as a stub today (&lt;code&gt;pack:sprites&lt;/code&gt; is a placeholder). The plan is to wire &lt;code&gt;free-tex-packer&lt;/code&gt; into the manifest-write step so promotion → atlas → client load is one motion. It isn’t shipped yet, and saying so out loud is part of the discipline of these posts.&lt;/p&gt;
&lt;h2 id=&quot;i-am-the-art-director&quot;&gt;I am the art director&lt;/h2&gt;
&lt;p&gt;The repo has &lt;code&gt;assets/art-director.log&lt;/code&gt; and &lt;code&gt;assets/art-directed/&lt;/code&gt;. The naming was deliberate, and it does not mean what it looks like.&lt;/p&gt;
&lt;p&gt;There is no LLM judging the art. There is no vision model deciding which concept advances. The “art director” in this pipeline is a &lt;em&gt;governance structure&lt;/em&gt; — two human-review gates that sit between every generation step and the shipped manifest, enforced by the saga’s state machine. I’m the human in question.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;assets/art-directed/&lt;/code&gt; directory is a content-addressed cache. Each subdirectory is named by the SHA-256 hash of the inputs that produced it (&lt;code&gt;dc61df083f4a49c5/...&lt;/code&gt;) and contains stage-numbered output files: &lt;code&gt;stage-1-concept.png&lt;/code&gt; from the local stack, &lt;code&gt;stage-3-rotations.zip&lt;/code&gt; from the hosted API. The content addressing is what makes the pipeline survive process death — when the worker comes back up, it checks “is the output already at this path?” before re-issuing a vendor call. Two runs with identical inputs collapse into one set of files.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;assets/art-director.log&lt;/code&gt; is the append-only execution transcript from the v1 CLI pipeline that preceded the saga rebuild — 787 lines of timestamped per-asset outcomes, costs, and diagnostic detail from spring 2026. It exists because in the old pipeline, my eye was the &lt;em&gt;only&lt;/em&gt; place log information landed; in the v2 saga, every state transition is in Postgres and the log is supplementary.&lt;/p&gt;
&lt;p&gt;I exercise the directorial role at exactly two stops: the concept gate and the final review gate. Both are CLI commands that transition &lt;code&gt;art_jobs&lt;/code&gt; rows. Rejected jobs go to a terminal state without burning further vendor credits. The recipe’s levers (&lt;code&gt;hair=red&lt;/code&gt;, &lt;code&gt;mood=stoic&lt;/code&gt;) produce deterministic fan-outs that I evaluate as a batch — generate ten concept images by sweeping a lever’s enum, look at them, keep two, throw away eight. I’m the art director. The pipeline is my tool.&lt;/p&gt;
&lt;p&gt;This isn’t an ideological choice. The cost of a wrong vision-model judgment in this domain is a generation that doesn’t look like the world; the cost of a right vision-model judgment is asset throughput. Until I have a judge calibrated on my taste — not a public CLIP-style scorer trained on internet aesthetics — the throughput win isn’t worth the calibration risk. My eye is cheaper, faster, and accurate by construction in a way no off-the-shelf scorer can be for a generational life-sim’s pixel art. So I stay in the loop.&lt;/p&gt;
&lt;h2 id=&quot;bounding-the-random&quot;&gt;Bounding the random&lt;/h2&gt;
&lt;p&gt;The pipeline uses generative models, which are probabilistic by construction. The simulation side of Skaldborn is built on a determinism guarantee. These two things have to coexist or the architecture is a lie.&lt;/p&gt;
&lt;p&gt;They coexist through four mechanisms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Seed pinning.&lt;/strong&gt; Every recipe’s &lt;code&gt;locked_fields&lt;/code&gt; carry a production seed (&lt;code&gt;31337&lt;/code&gt; in the canonical example). The ComfyUI workflow template hardcodes the seed in the &lt;code&gt;KSampler&lt;/code&gt; node. Per-concept variation comes from a &lt;code&gt;child_index&lt;/code&gt; field on each &lt;code&gt;art_jobs&lt;/code&gt; row: the effective seed is &lt;code&gt;recipe_seed + child_index&lt;/code&gt;, so a ten-concept batch produces seeds 31337 through 31346. Each seed is reproducible given the same checkpoint, LoRA, prompt, and parameters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content-addressed paths.&lt;/strong&gt; The function &lt;code&gt;LeverBindingHash.Compute()&lt;/code&gt; produces a SHA-256 of the lever binding map (keys sorted ordinal-ascending, compact JSON, no Unicode escaping). The first 16 hex characters become the directory name: &lt;code&gt;art_pool/candidates/&amp;lt;recipe_id&amp;gt;/&amp;lt;hash16&amp;gt;/&amp;lt;backend&amp;gt;/000.png&lt;/code&gt;. Identical inputs produce the same hash; re-running with the same levers lands in the same directory. The stager writes there, the resolver reads from there, the worker checks for existence before issuing a vendor call.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Input:   {&amp;quot;hair&amp;quot;:&amp;quot;red&amp;quot;,&amp;quot;mood&amp;quot;:&amp;quot;stoic&amp;quot;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SHA-256: a1b2c3d4e5f67890... (truncated to 16 hex chars)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Path:    art_pool/candidates/&amp;lt;recipe&amp;gt;/a1b2c3d4e5f67890/&amp;lt;backend&amp;gt;/000.png&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Recipe versioning.&lt;/strong&gt; If a checkpoint changes (new model, updated LoRA), the recipe is versioned: &lt;code&gt;&amp;lt;recipe&amp;gt;.v1&lt;/code&gt; becomes &lt;code&gt;&amp;lt;recipe&amp;gt;.v2&lt;/code&gt;. In-place mutation of &lt;code&gt;locked_fields&lt;/code&gt; is forbidden by convention and called out in the architecture decision that governs the pipeline. Old recipes remain addressable; old &lt;code&gt;art_jobs&lt;/code&gt; rows still reference them; the directory tree separates outputs of different generation profiles cleanly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Frozen-output promotion.&lt;/strong&gt; The promotion step copies a candidate to &lt;code&gt;approved/&lt;/code&gt; via copy-then-atomic-rename. Once promoted, the file is immutable from the pipeline’s perspective. Upstream regeneration cannot overwrite it; new generations land in a new candidate slot and require an explicit second promotion from me to replace the approved file. The manifest entry produced by the projector carries the &lt;code&gt;asset_path&lt;/code&gt; to the promoted file. The simulation consumes the frozen manifest. It never sees the candidate pool.&lt;/p&gt;
&lt;p&gt;The honest gap: determinism &lt;em&gt;at the hosted pixel-art API&lt;/em&gt; is not contractually guaranteed. The vendor’s seed-determinism story across the eight-rotation endpoint is a “probably yes” rather than a “documented yes.” The pipeline treats the hosted output as “potentially different on each call” and relies on the promotion gate as the determinism boundary. Once a sprite is in &lt;code&gt;approved/&lt;/code&gt;, it doesn’t matter whether the same sprite would come back the same way next time.&lt;/p&gt;
&lt;h2 id=&quot;the-saga&quot;&gt;The saga&lt;/h2&gt;
&lt;p&gt;The v2 pipeline is a nine-state saga backed by a Postgres &lt;code&gt;art_jobs&lt;/code&gt; table. Each row tracks its job through:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;mermaid&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;stateDiagram-v2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    [*] --&amp;gt; Pending&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Pending --&amp;gt; ConceptGenerating&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ConceptGenerating --&amp;gt; AwaitingConceptApproval&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    AwaitingConceptApproval --&amp;gt; RotationGenerating: approve&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    AwaitingConceptApproval --&amp;gt; Rejected: reject&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    RotationGenerating --&amp;gt; Persisting&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Persisting --&amp;gt; AwaitingReview&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    AwaitingReview --&amp;gt; Approved: approve&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    AwaitingReview --&amp;gt; Rejected: reject&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    ConceptGenerating --&amp;gt; Failed: error&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    RotationGenerating --&amp;gt; Failed: error&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Persisting --&amp;gt; Failed: error&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Approved --&amp;gt; [*]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Rejected --&amp;gt; [*]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Failed --&amp;gt; [*]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two design choices carry the durability story.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Claim ordering uses &lt;code&gt;SELECT FOR UPDATE SKIP LOCKED&lt;/code&gt;.&lt;/strong&gt; Each worker claims exactly one job at a time, ordered by &lt;code&gt;(state, priority DESC, created_at)&lt;/code&gt;. Multiple workers can run concurrently without colliding. A &lt;code&gt;TtlReclaimBackgroundService&lt;/code&gt; periodically sweeps for jobs whose &lt;code&gt;processing_deadline_at&lt;/code&gt; has passed and re-queues them; after three reclaims a job is force-failed with &lt;code&gt;error_class=&amp;#39;stage_timeout_repeated&amp;#39;&lt;/code&gt; so a permanently stuck job can’t loop forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;State output lands on disk before the row transitions.&lt;/strong&gt; The saga writes generated bytes to the content-addressed path &lt;em&gt;before&lt;/em&gt; updating the row to the next state. If the worker crashes between writing the file and updating the database, the next pickup finds the file already present and skips the vendor call. The cost ceiling is preserved across crashes; partial work survives.&lt;/p&gt;
&lt;p&gt;The schema is built by an embedded migration runner that ships inside the worker. Five migrations live as embedded resources in the project (&lt;code&gt;V1&lt;/code&gt; through &lt;code&gt;V5&lt;/code&gt;) and apply sequentially on startup. &lt;code&gt;V1&lt;/code&gt; creates the table with twenty-something columns. &lt;code&gt;V2&lt;/code&gt; drops an over-eager &lt;code&gt;category&lt;/code&gt; &lt;code&gt;CHECK&lt;/code&gt; constraint that was rejecting valid recipes. &lt;code&gt;V3&lt;/code&gt; adds cost-enforcement columns. &lt;code&gt;V4&lt;/code&gt; adds reclaim tracking. &lt;code&gt;V5&lt;/code&gt; adds the &lt;code&gt;child_index&lt;/code&gt; column the seed-variation fix needed (more on that in a war story below). Six indexes — claim order, brief id, trace id, category-state, TTL reclaim, created-at — keep the common queries cheap.&lt;/p&gt;
&lt;p&gt;A daily cost ceiling (&lt;code&gt;ART_WORKER_COST_CEILING_DAILY_USD&lt;/code&gt;, default &lt;code&gt;$25&lt;/code&gt;) is checked before each vendor call. If the day’s spend exceeds the ceiling, the worker stops claiming new jobs until UTC midnight. This is the failsafe behind any pipeline that touches a paid API: the per-call cost estimates are unreliable, the daily cap isn’t.&lt;/p&gt;
&lt;h2 id=&quot;what-we-validate-and-what-we-dont&quot;&gt;What we validate (and what we don’t)&lt;/h2&gt;
&lt;p&gt;The repo’s &lt;code&gt;scripts/&lt;/code&gt; directory has roughly twenty-five fail-close validators that run on every push. Most of them protect the simulation. The art side has &lt;em&gt;one&lt;/em&gt; mechanical validator. We’re going to be honest about why.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;validate-recipe-coverage.sh&lt;/code&gt; (Tier 1, wired into &lt;code&gt;make validate-governance&lt;/code&gt;) checks that every component contract that declares &lt;code&gt;required_recipe_ids&lt;/code&gt; has matching files in &lt;code&gt;content/recipes/&lt;/code&gt;. It shape-validates each recipe: all eight required top-level fields present, &lt;code&gt;lever_schema.additionalProperties === false&lt;/code&gt;, every lever property declares an &lt;code&gt;enum&lt;/code&gt; keyword. The validator runs three controlled-failure scenarios against temp fixtures as a self-test (&lt;code&gt;--self-test&lt;/code&gt;) so the validator itself stays falsifiable. If a recipe file is missing or a lever isn’t an enum, the push fails with a structured error.&lt;/p&gt;
&lt;p&gt;Beyond that bash script, the pipeline relies on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A C# &lt;code&gt;LeverBindingValidator&lt;/code&gt; at job-submission time (rejects unknown levers, missing required levers, values outside the enum set).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;art generate --dry-run&lt;/code&gt; pre-flight (validates and prints what would queue, without writing).&lt;/li&gt;
&lt;li&gt;A circuit breaker on the hosted API runner (opens at ten consecutive failures; subsequent calls return immediately).&lt;/li&gt;
&lt;li&gt;The two review gates I described above.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why not more mechanical gates? Because art quality is fundamentally a judgment call. You cannot write a bash script that determines whether a character portrait looks good. The pipeline compensates by making me the gate at two explicit review steps and ensuring no generated asset reaches the manifest without my “yes.” The mechanical validators catch &lt;em&gt;structural&lt;/em&gt; errors — missing files, malformed levers, schema drift. &lt;em&gt;Aesthetic&lt;/em&gt; errors are mine to catch. Pretending otherwise would be dishonest, and dishonesty here would mean shipping bad art under a fig leaf of automation.&lt;/p&gt;
&lt;p&gt;A few additional validators (&lt;code&gt;validate-tile-cohesion.ts&lt;/code&gt;, &lt;code&gt;validate-composition.ts&lt;/code&gt;, &lt;code&gt;validate-rendering.ts&lt;/code&gt;, &lt;code&gt;validate-motion.ts&lt;/code&gt;) are scoped for tile variety, transition coverage, color-profile adherence, and motion presence. Tier 2 is in progress; Tiers 3–5 are not started. They will land before the manifest write does.&lt;/p&gt;
&lt;h2 id=&quot;war-stories&quot;&gt;War stories&lt;/h2&gt;
&lt;p&gt;Three things broke instructively. They were teachers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The “ten identical concepts” surprise.&lt;/strong&gt; The first time we tested the concept-gate batch flow, &lt;code&gt;art queue --concepts 10&lt;/code&gt; produced ten identical PNGs. All ten child jobs shared the recipe’s pinned seed; the ComfyUI workflow hardcoded that seed in the sampler; the pipeline was faithfully and deterministically producing ten copies of the same image. Determinism was working &lt;em&gt;too&lt;/em&gt; well. The fix, in commit &lt;code&gt;29f0028&lt;/code&gt;, added a &lt;code&gt;child_index&lt;/code&gt; column to &lt;code&gt;art_jobs&lt;/code&gt; (migration &lt;code&gt;V5&lt;/code&gt;) and made the effective seed &lt;code&gt;recipe_seed + child_index&lt;/code&gt;. Child 0 gets seed &lt;code&gt;31337&lt;/code&gt;, child 1 gets &lt;code&gt;31338&lt;/code&gt;, and so on. Each concept is reproducible (same &lt;code&gt;child_index&lt;/code&gt; always produces the same image) but distinct from its siblings. Lesson: determinism and variety are not opposites, but they don’t reconcile by accident. You need an explicit variance axis, named, in the data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &lt;code&gt;rotation_urls&lt;/code&gt; null surprise.&lt;/strong&gt; Early character generation called the hosted API’s “create character with directions” endpoint, polled the resulting background job, fetched the character object, and then crashed trying to download rotation images. The API response had &lt;code&gt;rotation_urls&lt;/code&gt; present in the schema but &lt;code&gt;null&lt;/code&gt; in the actual response body. Every character generation failed identically across five consecutive batch runs in March; the log records each one. The fix was to read the full polling envelope rather than the immediate response: the &lt;code&gt;character_id&lt;/code&gt; and &lt;code&gt;rotation_urls&lt;/code&gt; materialize in a &lt;code&gt;last_response&lt;/code&gt; field on the &lt;em&gt;completed&lt;/em&gt; background job, not in the &lt;code&gt;202 Accepted&lt;/code&gt; from the original POST. The vendor’s OpenAPI schema typed &lt;code&gt;last_response&lt;/code&gt; as opaque &lt;code&gt;object&lt;/code&gt;, so the first implementation reasonably assumed the completion payload had the same shape as the synchronous one. Lesson: when an async API hands you a background job, the completion payload is not the same shape as the submission response. Poll the completion endpoint, dump the raw JSON, and &lt;em&gt;then&lt;/em&gt; build the deserializer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Saga CAS vs. TTL sweep.&lt;/strong&gt; The saga used compare-and-swap on &lt;code&gt;updated_at&lt;/code&gt; to prevent double-processing: &lt;code&gt;UPDATE art_jobs SET state = ... WHERE id = ... AND updated_at = @expected&lt;/code&gt;. In overnight runs with the face-detection inpainting step enabled (longer generation times), the worker would claim a job, run the generation for sixty-plus seconds, then attempt the CAS update — which failed because the TTL reclaim sweep had touched the row’s &lt;code&gt;updated_at&lt;/code&gt; in the meantime. The job was stuck: claimed but never advanced, never reclaimed (the sweep only touches &lt;em&gt;un-claimed&lt;/em&gt; jobs). Commit &lt;code&gt;64c8e3e5&lt;/code&gt; added a continuation loop: if the CAS fails, the worker re-reads the row, verifies it still owns the claim, and retries the transition. The same commit bumped polling ceilings for overnight workloads. Lesson: if your saga uses optimistic concurrency, make sure your background sweeps don’t silently invalidate the CAS token of in-flight work.&lt;/p&gt;
&lt;h2 id=&quot;what-its-like-to-add-an-asset&quot;&gt;What it’s like to add an asset&lt;/h2&gt;
&lt;p&gt;A new character portrait, end to end, looks like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# Validate the recipe and lever values without queueing&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;recipe-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --hair&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; red&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --mood&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; stoic&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --dry-run&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# Queue the job&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; generate&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;recipe-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --hair&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; red&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --mood&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; stoic&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# (~60 seconds: local concept generation)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; list-candidates&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;recipe-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; review-concepts&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;brief-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;            # me: approve/reject&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# (~90 seconds: hosted API rotation generation)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; review&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;job-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;                       # me: approve/reject&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# Promote to frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;art&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; promote&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;recipe-i&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;d&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;hash1&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;6&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --backend&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;nam&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;e&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --frame&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;About five commands, three to five minutes wall time, dominated by generation time. If the recipe doesn’t already exist, add roughly fifteen minutes of authoring (write the JSON, choose lever values, draft the prompt template). The validator catches structural mistakes before the push reaches CI.&lt;/p&gt;
&lt;p&gt;There’s no hot-reload. Each iteration is a full generate-review-promote cycle, bottlenecked by the 30–140 seconds the model needs. The single change that would noticeably tighten the loop is wiring promotion directly into the manifest write and the atlas pack — collapsing today’s “promote → run a copy script → rebuild the client” chain into one &lt;code&gt;art ship&lt;/code&gt; command. The substrate exists (the projector is shipped); the wiring is the next thing on the queue.&lt;/p&gt;
&lt;h2 id=&quot;receipts&quot;&gt;Receipts&lt;/h2&gt;
&lt;p&gt;A grab-bag of concrete numbers, current as of this post:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Manifest entries&lt;/strong&gt; today: 157 across characters, portraits, buildings, terrain tiles and tilesets, environment objects, icons, and UI elements. All currently in a &lt;code&gt;Generated&lt;/code&gt; state under the v1 path; none have been promoted through the v2 pipeline yet (we’re mid-cutover).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Raw sprite files&lt;/strong&gt; in the client’s asset tree: ~2,360 files, ~9.6 MB total. Most of that is character animation frames.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2 outputs&lt;/strong&gt; in &lt;code&gt;assets/art-directed/&lt;/code&gt;: 8 content-hash directories so far; ~38 MB total (concept PNGs are ~6 MB each at 2048×3072 post-upscale; rotation ZIPs are ~150 KB).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concept generation time&lt;/strong&gt; on a 4 GB consumer GPU under WSL2: 30–90 s per concept. The VRAM-safety flags on the ComfyUI launcher are load-bearing — &lt;code&gt;--cpu-vae&lt;/code&gt; in particular, because the VAE decode spike will crash the &lt;em&gt;entire WSL session&lt;/em&gt; on a 4 GB card without it. (Bare-metal Linux merely OOM-kills the process. WSL takes the whole subsystem down.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hosted-API generation time&lt;/strong&gt;: tiles 35–105 s, portraits 25–135 s, icons 11–22 s, UI elements 15–50 s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hosted-API cost&lt;/strong&gt;: $0.008–$0.010 per image. Daily ceiling: $25.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recipe catalog&lt;/strong&gt;: 2 canonical recipes shipped, 1 ComfyUI workflow template. Recipe files are 1.7–3.6 KB each.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Saga schema&lt;/strong&gt;: 5 embedded SQL migrations, 20-plus columns per row, 6 indexes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-wed-tell-ourselves-in-february&quot;&gt;What we’d tell ourselves in February&lt;/h2&gt;
&lt;p&gt;A list, since this kind of advice tends to land best as a list.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Put the operator review gate &lt;em&gt;before&lt;/em&gt; the expensive step, not after.&lt;/strong&gt; Local concept generation costs nothing; the hosted rotation API costs money and time. Rejecting a bad concept before it reaches the paid API saved roughly 60% of vendor spend in the first batch runs. Design your pipeline so cheap evaluation precedes expensive generation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Version your recipes; don’t mutate them.&lt;/strong&gt; When &lt;code&gt;locked_fields&lt;/code&gt; change — new checkpoint, new LoRA strength, different canvas — create &lt;code&gt;recipe.v2.json&lt;/code&gt; next to &lt;code&gt;recipe.v1.json&lt;/code&gt;. Old versions remain addressable for audit. In-place mutation makes “what produced this asset?” unanswerable after the fact. Immutable recipe files are a cheap form of experiment tracking.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Content-address your outputs by their inputs.&lt;/strong&gt; The SHA-256 hash of the lever binding means re-running a generation with the same parameters is a no-op (the file already exists). This saves you from re-generating after worker crashes, and it makes the pipeline inherently idempotent. If you build any batch pipeline that talks to expensive APIs, compute the content hash before the API call and check the filesystem first.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Constrain your variance axes to enums, not free-form strings.&lt;/strong&gt; Free-form parameters feel flexible until you try to answer “have we generated all the variants we need?” and discover you can’t define “all.” Enums make the total output space finite, the content hash deterministic, and the validation mechanical. The expressivity you give up was never load-bearing; the enumerability you gain is.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Make your queue durable independently of your interactive session.&lt;/strong&gt; Our v1 pipeline ran in-process inside a CLI invoked from a terminal session. A lost tmux pane destroyed the batch state. The v2 pipeline stores every job in Postgres — process death loses at most one in-flight generation, and the TTL reclaim sweep picks it up automatically. If your pipeline takes more than five minutes end-to-end, it should not live inside the process that initiated it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Separate “generate” from “ship.”&lt;/strong&gt; Distinct generate, review, promote, and manifest-write steps mean you can generate fifty variants, review them over coffee, promote the three best, and only those three enter the game. Pipelines that auto-ship every generation force you to be conservative with parameters; pipelines with an explicit promotion step let you be exploratory with generation and selective with shipping.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Budget your API costs with a daily ceiling, not just a per-call estimate.&lt;/strong&gt; A hard daily cap (&lt;code&gt;$25&lt;/code&gt; in our case) prevents a runaway batch from spending more than you planned. Per-call estimates are unreliable — our v1 log shows &lt;code&gt;$0.000&lt;/code&gt; cost for images the vendor actually charged for. The ceiling is the failsafe.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Don’t ask the AI to judge the AI unless your judge is calibrated on your world.&lt;/strong&gt; A vision-model gate sounds like throughput. The failure mode is a generation that looks like &lt;em&gt;something&lt;/em&gt;, just not yours — and that failure ships silently because the judge said yes. Until you can train and validate a judge against your own taste, the operator gate is cheaper than the cost of one shipped wrong-looking asset.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;The next post in the launch arc takes the manifest itself as its subject. The recipe’s &lt;code&gt;composition_mode&lt;/code&gt;, &lt;code&gt;anchor_offsets&lt;/code&gt;, and &lt;code&gt;variant_sprites&lt;/code&gt; carry an entire visual-composition vocabulary; the renderer reads them as data, the projector emits them, the Age manifest binds them. That surface area is its own post.&lt;/p&gt;
&lt;p&gt;Further out: the audio pipeline. A separate service following the same governance pattern — external producer, build-time snapshot, manifest-bound, no runtime authority — with a completely different stack.&lt;/p&gt;
&lt;p&gt;Adjacent to both: &lt;code&gt;validate-recipe-coverage.sh&lt;/code&gt; is one of roughly twenty-five fail-close validators wired into our pre-push hook. The broader story — how we turn architectural rules into CI gates — is its own post.&lt;/p&gt;
&lt;p&gt;Companion to this one: &lt;a href=&quot;https://www.skaldborn.com/devlog/05-comfyui-setup/&quot;&gt;Set up ComfyUI for your own content pipeline&lt;/a&gt; — a step-by-step walkthrough of the local Stable Diffusion install end to end, the PixelLab API integration, and the launch flags that keep VAE decode from OOM-ing on a 4 GB consumer GPU.&lt;/p&gt;
&lt;p&gt;If you want to follow along, subscribe via the form at the bottom of any page — one short email when the next post lands. If you want to argue, write to &lt;a href=&quot;mailto:devlog@skaldborn.com&quot;&gt;devlog@skaldborn.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Everything else is the boring engineering of making it true.&lt;/p&gt;</content:encoded></item><item><title>Set up ComfyUI for your own content pipeline (on a 4 GB consumer GPU)</title><link>https://www.skaldborn.com/devlog/05-comfyui-setup/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/05-comfyui-setup/</guid><description>A reproducible local Stable Diffusion stack and the PixelLab integration that turns concept images into sprites, end to end: ComfyUI on Ubuntu with FaceDetailer + 4× upscale, the production model set Skaldborn ships against, the launch flags that keep VAE decode from OOM-ing on a 4 GB card, and a complete Python flow for hitting PixelLab&apos;s v2 API.</description><pubDate>Sun, 10 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the companion tutorial to &lt;a href=&quot;https://www.skaldborn.com/devlog/04-art-content-pipeline/&quot;&gt;Skaldborn’s art content pipeline&lt;/a&gt;. That post explained the architecture; this one walks through the local Stable Diffusion half end to end, on the same 4 GB consumer GPU it runs on in production, and then shows how to drive PixelLab’s hosted API to turn a concept image into an 8-direction sprite. Ubuntu (or any modern Linux distro) is assumed.&lt;/p&gt;
&lt;p&gt;You don’t have to have read post 04 to use this. The install pattern, the launch-flag set, the FaceDetailer + upscale graph, and the PixelLab integration generalize to any content pipeline that uses ComfyUI as a generation backend with a sprite-renderer downstream. The model choices below are tuned for fantasy/RPG art; substitute your own if your domain is different.&lt;/p&gt;
&lt;p&gt;The canonical scripts and a tighter reference live at &lt;a href=&quot;https://github.com/clunasco/skald-forge&quot;&gt;&lt;code&gt;clunasco/skald-forge&lt;/code&gt;&lt;/a&gt;. This post is the narrative version with the lessons and the small landmines baked in. Time budget: about 30 minutes for the local stack if your hardware cooperates, plus a handful of minutes once you have a PixelLab API key.&lt;/p&gt;
&lt;h2 id=&quot;table-of-contents&quot;&gt;Table of contents&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#why-this-is-hard-on-a-4-gb-card&quot;&gt;Why this is hard on a 4 GB card&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#1-prerequisites&quot;&gt;1. Prerequisites&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-clone-skald-forge-and-install-comfyui&quot;&gt;2. Clone skald-forge and install ComfyUI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-install-the-facedetailer-custom-nodes&quot;&gt;3. Install the FaceDetailer custom nodes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-configure-your-civitai-token&quot;&gt;4. Configure your CivitAI token&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-download-the-production-model-stack&quot;&gt;5. Download the production model stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-launch-comfyui&quot;&gt;6. Launch ComfyUI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-build-the-production-workflow&quot;&gt;7. Build the production workflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-from-concept-to-sprite-pixellab&quot;&gt;8. From concept to sprite: PixelLab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-tuning-for-more-vram&quot;&gt;9. Tuning for more VRAM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#10-troubleshooting&quot;&gt;10. Troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#how-skaldborn-consumes-this&quot;&gt;How Skaldborn consumes this&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#whats-next&quot;&gt;What’s next&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;why-this-is-hard-on-a-4-gb-card&quot;&gt;Why this is hard on a 4 GB card&lt;/h2&gt;
&lt;p&gt;Default ComfyUI will OOM on SD 1.5 generation if you have 4 GB of VRAM. That’s annoying. What’s &lt;em&gt;especially&lt;/em&gt; annoying is where it OOMs: the VAE decode at the end of generation produces a VRAM spike that fires &lt;em&gt;after&lt;/em&gt; the 30+ seconds of sampling. You wait, you see the image &lt;em&gt;almost&lt;/em&gt; complete, and the process dies on decode. Every generation. Until you add &lt;code&gt;--cpu-vae&lt;/code&gt; to the launch script and the spike moves to system RAM.&lt;/p&gt;
&lt;p&gt;Three of the launch flags in this guide are there specifically to make 4 GB work. Drop any of them on a small card and the symptoms range from “OOM during sampling, stack trace early” (you find out fast) to “OOM at decode, watch your generation die at the finish line, repeatedly” (you waste hours). The flag table in &lt;a href=&quot;#6-launch-comfyui&quot;&gt;section 6&lt;/a&gt; tells you which is which.&lt;/p&gt;
&lt;p&gt;If you have more VRAM, you’ll want to drop some of these flags as you go up. &lt;a href=&quot;#9-tuning-for-more-vram&quot;&gt;Section 9&lt;/a&gt; covers that.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;1-prerequisites&quot;&gt;1. Prerequisites&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Hardware.&lt;/strong&gt; An NVIDIA GPU with at least 4 GB of VRAM. Reference rig: RTX 3050 Laptop 4 GB.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Software.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ubuntu 22.04+ (or any modern Linux distro)&lt;/li&gt;
&lt;li&gt;A working NVIDIA driver — recent enough to support CUDA 12.4. On Ubuntu, &lt;code&gt;sudo apt install nvidia-driver-550&lt;/code&gt; (or newer) is the usual path; reboot after.&lt;/li&gt;
&lt;li&gt;The handful of base packages this guide assumes:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sudo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; apt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; update&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sudo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; apt&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -y&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; python3&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; python3-venv&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; git&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; tmux&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; curl&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify CUDA is alive before going further:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;nvidia-smi&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see your GPU and driver version. If this fails, fix the driver before continuing.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;CivitAI account + API token&lt;/strong&gt; is needed for the production checkpoints/LoRAs (CivitAI gates downloads behind auth). Get one at &lt;a href=&quot;https://civitai.com/user/account&quot;&gt;civitai.com/user/account&lt;/a&gt;. Save it; you’ll need it in section 4.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;PixelLab account + API token&lt;/strong&gt; is needed for the rotation step in section 8. Free trial credit is enough to test the flow end to end. Get one at &lt;a href=&quot;https://www.pixellab.ai/signup&quot;&gt;pixellab.ai/signup&lt;/a&gt;; generate the token from your account settings.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;2-clone-skald-forge-and-install-comfyui&quot;&gt;2. Clone skald-forge and install ComfyUI&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; clone&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://github.com/clunasco/skald-forge.git&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ~/workspace/projects/skald-forge&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ~/workspace/projects/skald-forge&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./install_comfyui.sh&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;install_comfyui.sh&lt;/code&gt; is idempotent — re-run it any time to update. Six things happen:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Creates &lt;code&gt;comfyui-venv/&lt;/code&gt; (a Python venv, sibling to &lt;code&gt;ComfyUI/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Clones &lt;a href=&quot;https://github.com/comfyanonymous/ComfyUI&quot;&gt;comfyanonymous/ComfyUI&lt;/a&gt; into &lt;code&gt;ComfyUI/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Installs PyTorch with the CUDA 12.4 wheels (&lt;code&gt;torch&lt;/code&gt;, &lt;code&gt;torchvision&lt;/code&gt;, &lt;code&gt;torchaudio&lt;/code&gt; from the &lt;a href=&quot;https://download.pytorch.org/whl/cu124&quot;&gt;PyTorch CUDA 12.4 index&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Installs ComfyUI’s &lt;code&gt;requirements.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Installs &lt;a href=&quot;https://github.com/ltdrdata/ComfyUI-Manager&quot;&gt;ComfyUI-Manager&lt;/a&gt; into &lt;code&gt;ComfyUI/custom_nodes/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prints a CUDA-visibility check at the end. You should see &lt;code&gt;cuda avail : True&lt;/code&gt; and your GPU name.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If step 6 says &lt;code&gt;False&lt;/code&gt;, you’re holding a CPU-only PyTorch wheel — usually a cached environment from a prior install. The script pins the CUDA 12.4 index, so this only happens if something else got there first. Force a clean reinstall:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;source&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; comfyui-venv/bin/activate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pip&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --force-reinstall&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torch&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torchvision&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torchaudio&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --index-url&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://download.pytorch.org/whl/cu124&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;3-install-the-facedetailer-custom-nodes&quot;&gt;3. Install the FaceDetailer custom nodes&lt;/h2&gt;
&lt;p&gt;The production graph uses two custom-node packages from &lt;code&gt;ltdrdata&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ltdrdata/ComfyUI-Impact-Pack&quot;&gt;Impact Pack&lt;/a&gt; — provides &lt;code&gt;FaceDetailer&lt;/code&gt;, the inpainting node that re-runs generation on the face region after the main pass. SD 1.5 at full-body 512×768 produces faces that are functionally mush; &lt;code&gt;FaceDetailer&lt;/code&gt; is the fix.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ltdrdata/ComfyUI-Impact-Subpack&quot;&gt;Impact Subpack&lt;/a&gt; — provides the &lt;code&gt;UltralyticsDetectorProvider&lt;/code&gt; node that loads &lt;code&gt;face_yolov8n.pt&lt;/code&gt; for the face bounding box.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Install both:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/custom_nodes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; clone&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://github.com/ltdrdata/ComfyUI-Impact-Pack.git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; clone&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://github.com/ltdrdata/ComfyUI-Impact-Subpack.git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; -&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;source&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; comfyui-venv/bin/activate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pip&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/custom_nodes/ComfyUI-Impact-Pack/requirements.txt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pip&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/custom_nodes/ComfyUI-Impact-Subpack/requirements.txt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;deactivate&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once ComfyUI is running you can also install missing custom nodes through the Manager UI (“Manager” button → “Install Missing Custom Nodes”). The git-clone path is what skald-forge documents because it’s reproducible from a fresh checkout without a running server.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;4-configure-your-civitai-token&quot;&gt;4. Configure your CivitAI token&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;download_models.sh&lt;/code&gt; auto-loads &lt;code&gt;.env&lt;/code&gt; from the repo root. Create it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;cat&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .env&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;lt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;#39;EOF&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;CIVITAI_TOKEN=your_civitai_api_token_here&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;EOF&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;chmod&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 600&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .env&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; is gitignored at the skald-forge level. Don’t commit it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;5-download-the-production-model-stack&quot;&gt;5. Download the production model stack&lt;/h2&gt;
&lt;p&gt;Six files. Total disk: ~6.2 GB. All paths are relative to &lt;code&gt;ComfyUI/models/&lt;/code&gt;. Sizes and source URLs are also recorded in &lt;a href=&quot;https://github.com/clunasco/skald-forge/blob/main/models_inventory.md&quot;&gt;&lt;code&gt;models_inventory.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 1. Default SD 1.5 VAE — used by checkpoints that don&amp;#39;t bake one in&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --vae-default&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 2. aZovyaRPGArtistTools v4VAE — the production checkpoint (~5.7 GB)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --checkpoint&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://civitai.com/api/download/models/251729&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --name&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; aZovyaRPGArtistTools_v4VAE.safetensors&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 3. NorseViking_v10 — the production style LoRA (~37 MB)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --lora&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://civitai.com/api/download/models/31804&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --name&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; NorseViking_v10.safetensors&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 4. negative_hand-neg — fixes hand anatomy without dragging the style (~25 KB)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --embedding&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://civitai.com/api/download/models/60938&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --name&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; negative_hand-neg.pt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 5. easynegative — companion general-purpose negative embedding (~24 KB)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --embedding&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://civitai.com/api/download/models/9208&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --name&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; easynegative.safetensors&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 6. 4xFoolhardyRemacri — post-generation upscaler (~67 MB)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./download_models.sh&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --upscaler&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://civitai.com/api/download/models/164821&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --name&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; remacri_original.safetensors&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The face detector and segmenter aren’t on CivitAI — they live on Hugging Face and Meta’s S3:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;mkdir&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -p&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/ultralytics/bbox&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/sams&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# YOLOv8-nano face detector (Bingsu/adetailer canonical release)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;curl&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -L&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -o&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/ultralytics/bbox/face_yolov8n.pt&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://huggingface.co/Bingsu/adetailer/resolve/main/face_yolov8n.pt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# Segment Anything ViT-B (Meta&amp;#39;s official checkpoint)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;curl&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -L&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -o&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/sams/sam_vit_b_01ec64.pth&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify everything landed at the right size:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/checkpoints/aZovyaRPGArtistTools_v4VAE.safetensors&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # ~5.7 GB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/loras/NorseViking_v10.safetensors&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;                   # ~37 MB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/upscale_models/remacri_original.safetensors&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;         # ~67 MB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/embeddings/negative_hand-neg.pt&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;                     # ~25 KB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/ultralytics/bbox/face_yolov8n.pt&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;                    # ~6 MB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ls&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -lh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ComfyUI/models/sams/sam_vit_b_01ec64.pth&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;                           # ~358 MB&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If any are wildly wrong (a few KB instead of hundreds of MB), it’s almost always a CivitAI auth issue: your &lt;code&gt;CIVITAI_TOKEN&lt;/code&gt; is missing or wrong. The download finished successfully but you got a redirect-to-login HTML page instead of the model. Re-check &lt;code&gt;.env&lt;/code&gt;, re-run.&lt;/p&gt;
&lt;p&gt;If you want to substitute models for your own domain, this is the surface to swap. The graph in section 7 doesn’t care about model identity, only file presence.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;6-launch-comfyui&quot;&gt;6. Launch ComfyUI&lt;/h2&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./start_comfyui.sh&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This launches ComfyUI inside a detached tmux session named &lt;code&gt;comfyui&lt;/code&gt; and binds the web UI to &lt;code&gt;http://127.0.0.1:8188&lt;/code&gt;. Useful operations:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;tmux&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; attach&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -t&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; comfyui&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;          # see the live log&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# Ctrl-b d                      # detach again&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;tmux&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; kill-session&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -t&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; comfyui&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;    # stop&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;./start_comfyui.sh&lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;              # start again (kills the old session first)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The launch flags matter. Read them before changing anything:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Flag&lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--novram&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Maximum offload to system RAM. Without it, even SD 1.5 will OOM at 4 GB.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--use-split-cross-attention&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Splits attention to reduce peak VRAM during sampling.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--cpu-vae&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Load-bearing on 4 GB.&lt;/strong&gt; The VAE decode at the end of generation spikes VRAM and OOMs the Python process at the very last step. CPU decode adds a few seconds per image but is the difference between “works” and “watch your image die at 99%.”&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--preview-method none&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Live previews cost VRAM. Off.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--listen 0.0.0.0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Reachable from Docker containers on the host bridge. Drop if you want loopback-only.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;--port 8188&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Default ComfyUI port.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Why a tmux daemon? Because if you run ComfyUI directly inside the shell that owns it, the server dies when the shell exits — and there’s no signal that “the shell exited” from inside an editor or an automation that thought it had ComfyUI running. tmux outlives any individual shell. The launcher kills any pre-existing session with the same name first, so re-running the script is safe.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Aside.&lt;/strong&gt; Don’t try to wrap ComfyUI in an editor-managed background process — Claude Code background bash tasks, VS Code task runners, that shape of thing. Background bash tasks get reaped when their parent exits. Use the tmux daemon path so the server outlives whatever spawned it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;7-build-the-production-workflow&quot;&gt;7. Build the production workflow&lt;/h2&gt;
&lt;p&gt;Once ComfyUI is up, open &lt;code&gt;http://127.0.0.1:8188&lt;/code&gt;. Wire the production graph in the UI. Components, in the order signal flows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CheckpointLoaderSimple&lt;/strong&gt; → loads &lt;code&gt;aZovyaRPGArtistTools_v4VAE.safetensors&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LoraLoader&lt;/strong&gt; → loads &lt;code&gt;NorseViking_v10.safetensors&lt;/code&gt; with &lt;code&gt;strength_model: 0.7&lt;/code&gt;, &lt;code&gt;strength_clip: 0.5&lt;/code&gt;. Connect MODEL/CLIP from the checkpoint to the LoRA loader; the LoRA loader’s outputs are what later nodes consume.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLIPTextEncode (positive)&lt;/strong&gt; — your prompt. Anything from “bearded warrior, fur cloak, snowy” to a more elaborate scene description.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLIPTextEncode (negative)&lt;/strong&gt; — include &lt;code&gt;embedding:negative_hand-neg, embedding:easynegative&lt;/code&gt; plus any usual negative tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EmptyLatentImage&lt;/strong&gt; at 512×768 (the production canvas size).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KSampler&lt;/strong&gt; — connect MODEL from the LoRA loader, conditioning from the two text encoders, latent from the &lt;code&gt;EmptyLatentImage&lt;/code&gt;. Recommended params: &lt;code&gt;steps: 30&lt;/code&gt;, &lt;code&gt;cfg: 7.5&lt;/code&gt;, &lt;code&gt;sampler_name: dpmpp_2m&lt;/code&gt;, &lt;code&gt;scheduler: karras&lt;/code&gt;, &lt;code&gt;seed: 31337&lt;/code&gt; (or whatever you pin for reproducibility).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VAEDecode&lt;/strong&gt; → uses the VAE baked into the v4VAE checkpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FaceDetailer&lt;/strong&gt; (Impact Pack):
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;BBOX_DETECTOR&lt;/code&gt; ← &lt;strong&gt;UltralyticsDetectorProvider&lt;/strong&gt; loading &lt;code&gt;face_yolov8n.pt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SAM_MODEL&lt;/code&gt; ← &lt;strong&gt;SAMLoader&lt;/strong&gt; loading &lt;code&gt;sam_vit_b_01ec64.pth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Defaults are fine. The node detects faces in the decoded image, masks them with SAM, and re-runs generation on the face region — fixing the “face is mush” failure mode SD 1.5 has at full-body 512×768.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UpscaleModelLoader&lt;/strong&gt; → &lt;code&gt;remacri_original.safetensors&lt;/code&gt; → &lt;strong&gt;ImageUpscaleWithModel&lt;/strong&gt; for the 4× pass to 2048×3072.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SaveImage&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Save the workflow JSON via &lt;em&gt;Workflow → Export&lt;/em&gt; once it’s wired so you can reload it cleanly. ComfyUI also embeds the full graph in saved PNGs — dragging a generated image back into the canvas reconstructs the workflow. That’s a useful way to ship a reference graph without committing JSON.&lt;/p&gt;
&lt;p&gt;Generation time on the reference rig (RTX 3050 Laptop 4 GB) is 30–90 seconds per concept. On a 12 GB+ card with the conservative flags dropped, expect 10–20 seconds.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;8-from-concept-to-sprite-pixellab&quot;&gt;8. From concept to sprite: PixelLab&lt;/h2&gt;
&lt;p&gt;You have a 2048×3072 concept image. To turn it into something a game engine can render as a moving character, you need eight rotations — one sprite per cardinal and intercardinal direction. &lt;a href=&quot;https://www.pixellab.ai&quot;&gt;PixelLab&lt;/a&gt;’s hosted API is what does that step in this pipeline. It takes your concept image as a reference and generates the 8-direction sprite set conditioned on it.&lt;/p&gt;
&lt;p&gt;The flow is async: POST a job, poll until it’s done, download the rotations.&lt;/p&gt;
&lt;h3 id=&quot;get-an-api-key-and-set-the-token&quot;&gt;Get an API key and set the token&lt;/h3&gt;
&lt;p&gt;Sign up at &lt;a href=&quot;https://www.pixellab.ai/signup&quot;&gt;pixellab.ai/signup&lt;/a&gt;, buy a credit pack (free trial credit is enough to test the flow), and generate an API token from your account settings. Authentication is a Bearer token:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; PIXELLAB_TOKEN&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;your_pixellab_api_token_here&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pricing scales with output size and mode. A character rotation through Pro mode runs roughly 20–40 generations per request — meaningful spend at production scale. PixelLab’s &lt;a href=&quot;https://www.pixellab.ai/#checkout&quot;&gt;pricing page&lt;/a&gt; has the per-call estimates.&lt;/p&gt;
&lt;h3 id=&quot;preprocess-your-concept&quot;&gt;Preprocess your concept&lt;/h3&gt;
&lt;p&gt;PixelLab’s &lt;code&gt;/v2/create-character-pro&lt;/code&gt; endpoint with &lt;code&gt;method: &amp;quot;create_from_concept&amp;quot;&lt;/code&gt; takes two image inputs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;concept_image&lt;/code&gt;&lt;/strong&gt; (max 1024×1024) — the visual you want the rotations to follow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reference_image&lt;/code&gt;&lt;/strong&gt; (max 168×168) — a low-res anchor that shapes the sprite-art treatment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The production preprocessing for a 2048×3072 ComfyUI output:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Center-crop the concept to a square.&lt;/li&gt;
&lt;li&gt;Resize the square to 512×512 with Lanczos for &lt;code&gt;concept_image&lt;/code&gt; (well within the 1024 max — keeps the JSON payload manageable).&lt;/li&gt;
&lt;li&gt;Resize the same square to 168×168 with Lanczos for &lt;code&gt;reference_image&lt;/code&gt; (exactly at the max).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; PIL&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; import&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Image&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; preprocess&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(path: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) -&amp;gt; tuple[Image.Image, Image.Image]:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    src &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Image.open(path).convert(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;RGB&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    side &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; min&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(src.size)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    left &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (src.width &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; side) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;//&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    top &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (src.height &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; side) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;//&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    square &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; src.crop((left, top, left &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; side, top &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; side))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    concept &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; square.resize((&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;512&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;512&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;), Image.Resampling.&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;LANCZOS&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    reference &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; square.resize((&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;168&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;168&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;), Image.Resampling.&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;LANCZOS&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; concept, reference&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;submit-the-rotation-job&quot;&gt;Submit the rotation job&lt;/h3&gt;
&lt;p&gt;PixelLab takes images as base64-encoded PNGs inside the JSON body — the same pattern most image-input APIs use. In a shell, encoding any PNG to that string is one command:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;base64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -w&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept_512.png&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept.b64&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;base64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -w&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference_168.png&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference.b64&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-w 0&lt;/code&gt; disables line wrapping (&lt;code&gt;base64&lt;/code&gt; defaults to wrapping at 76 columns; JSON doesn’t tolerate embedded newlines in strings).&lt;/p&gt;
&lt;p&gt;In Python the equivalent is two steps — write the in-memory &lt;code&gt;PIL.Image&lt;/code&gt; to a PNG byte buffer, then base64-encode the bytes:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; base64, io, os, requests&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; b64_png&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(img: Image.Image) -&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;Encode an in-memory PIL image to a base64 PNG string for JSON.&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    buf &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; io.BytesIO()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    img.save(buf, &lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;format&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;PNG&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; base64.b64encode(buf.getvalue()).decode(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;ascii&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;concept, reference &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; preprocess(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;concept.png&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;response &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; requests.post(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    &amp;quot;https://api.pixellab.ai/v2/create-character-pro&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;    headers&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Bearer &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;os.environ[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;#39;PIXELLAB_TOKEN&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;    json&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;method&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;create_from_concept&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;description&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;norse warrior, fur cloak, axe&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;image_size&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;width&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;64&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;height&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;64&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;view&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;side&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;concept_image&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: b64_png(concept),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;        &amp;quot;reference_image&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: b64_png(reference),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;    timeout&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;response.raise_for_status()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;job_id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; response.json()[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;background_job_id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;print&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;job: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;job_id&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The response is &lt;code&gt;202 Accepted&lt;/code&gt; with a &lt;code&gt;background_job_id&lt;/code&gt;. Pro mode typically takes 60–120 seconds end to end.&lt;/p&gt;
&lt;h3 id=&quot;poll-the-background-job&quot;&gt;Poll the background job&lt;/h3&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; time&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; True&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    poll &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; requests.get(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;https://api.pixellab.ai/v2/background-jobs/&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;job_id&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;        headers&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Authorization&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Bearer &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;os.environ[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;#39;PIXELLAB_TOKEN&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;        timeout&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    poll.raise_for_status()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    body &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; poll.json()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    status &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; body[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; status &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;completed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        result &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; body[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;last_response&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        break&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; status &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;failed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        raise&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; RuntimeError&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;PixelLab job failed: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;body&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    print&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;status: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    time.sleep(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The async-shape gotcha.&lt;/strong&gt; The completion payload is on &lt;code&gt;body[&amp;quot;last_response&amp;quot;]&lt;/code&gt;, not on the immediate response from your POST. PixelLab’s OpenAPI types &lt;code&gt;last_response&lt;/code&gt; as opaque &lt;code&gt;object&lt;/code&gt;, so the shape isn’t statically discoverable. Early integrations reasonably assumed the completion response would echo the submission shape and crashed trying to read fields that weren’t there. The submission response is “your job is queued”; the completion response is “your job is done, here are the artifacts.” Different shapes. Read the completion endpoint, dump the raw JSON the first time you call it, then build your deserializer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;download-the-rotations&quot;&gt;Download the rotations&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;last_response&lt;/code&gt; for character endpoints contains a &lt;code&gt;rotation_urls&lt;/code&gt; dictionary keyed by direction:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; urllib.request&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; pathlib &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Path&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;DIRECTIONS&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;south&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;south-east&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;east&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;north-east&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;              &amp;quot;north&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;north-west&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;west&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;south-west&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;Path(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;rotations&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;).mkdir(&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;exist_ok&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;True&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; direction &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; DIRECTIONS&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    url &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; result[&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;rotation_urls&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;][direction]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    urllib.request.urlretrieve(url, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;rotations/&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;direction&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;.png&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You now have eight 64×64 sprite frames — one per cardinal and intercardinal direction. Wire them into your engine’s animation/rotation system however that works in your stack.&lt;/p&gt;
&lt;h3 id=&quot;bound-your-spend&quot;&gt;Bound your spend&lt;/h3&gt;
&lt;p&gt;Pro mode bills per generation, so a runaway loop or a sloppy retry can burn money fast. Two patterns from the production pipeline that protect against that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hard daily ceiling.&lt;/strong&gt; Track total spend per UTC day; refuse new submissions when the day’s running cost exceeds a configured cap. The Skaldborn pipeline runs a &lt;code&gt;$25/day&lt;/code&gt; default — when crossed, the worker stops claiming new jobs until the next UTC day.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Circuit breaker.&lt;/strong&gt; Track consecutive vendor failures; stop submitting after N failures in a row. PixelLab does occasionally return “missing image data” errors on transient backend issues; the production pipeline retries each job up to three times before going terminal.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither is in the API itself. Both are your responsibility on the calling side.&lt;/p&gt;
&lt;h3 id=&quot;pure-shell-smoke-test&quot;&gt;Pure-shell smoke test&lt;/h3&gt;
&lt;p&gt;If you’d rather verify the API end to end before writing any Python — useful for confirming your token, the request shape, and the polling/download flow all work together — the whole sequence runs in shell with &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, and &lt;code&gt;imagemagick&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;#!/usr/bin/env bash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;set&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -euo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; pipefail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 1. Preprocess (center-crop to square, then resize)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SIDE&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;identify&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -format&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;%[fx:min(w,h)]&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept.png&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;convert&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept.png&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -gravity&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; center&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -crop&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SIDE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}x${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SIDE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}+0+0&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; +repage&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  -resize&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; 512x512&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -filter&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; Lanczos&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept_512.png&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;convert&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept.png&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -gravity&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; center&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -crop&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SIDE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}x${&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;SIDE&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}+0+0&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; +repage&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  -resize&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; 168x168&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -filter&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; Lanczos&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference_168.png&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 2. Encode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;base64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -w&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept_512.png&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;   &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept.b64&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;base64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -w&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference_168.png&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference.b64&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 3. Submit&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;JOB_ID&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -n&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --rawfile&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; concept&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;   concept.b64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --rawfile&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; reference.b64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  &amp;#39;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    method: &amp;quot;create_from_concept&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    description: &amp;quot;norse warrior, fur cloak, axe&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    image_size: { width: 64, height: 64 },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    view: &amp;quot;side&amp;quot;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    concept_image:   $concept,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    reference_image: $reference&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;  }&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; curl&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -sX&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; POST&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://api.pixellab.ai/v2/create-character-pro&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;       -H&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;Authorization: Bearer &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PIXELLAB_TOKEN&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;       -H&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;Content-Type: application/json&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;       -d&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; @-&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .background_job_id&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;job: &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$JOB_ID&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 4. Poll&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  BODY&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;curl&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -sX&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; GET&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;https://api.pixellab.ai/v2/background-jobs/&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$JOB_ID&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    -H&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;Authorization: Bearer &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$PIXELLAB_TOKEN&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  STATUS&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$BODY&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .status&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;status: &lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$STATUS&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$STATUS&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;completed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;break&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  [[ &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$STATUS&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;failed&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ]] &amp;amp;&amp;amp; { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$BODY&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;gt;&amp;amp;2&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;exit&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  sleep&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# 5. Download the rotations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;mkdir&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -p&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; rotations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$BODY&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  |&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; jq&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;#39;.last_response.rotation_urls | to_entries[] | &amp;quot;\(.key) \(.value)&amp;quot;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  |&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; while&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; read&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; dir&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; url&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;do&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; curl&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -sL&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$url&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -o&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;rotations/&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;$dir&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;.png&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;done. rotations in ./rotations/&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same flow as the Python above, with no language runtime to install. Use it to validate your environment, then graduate to the Python integration for anything you’d actually deploy.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;9-tuning-for-more-vram&quot;&gt;9. Tuning for more VRAM&lt;/h2&gt;
&lt;p&gt;If you’re replicating this on a card with more headroom, relax the launch flags. Edit &lt;code&gt;start_comfyui.sh&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;8 GB&lt;/strong&gt;: drop &lt;code&gt;--novram&lt;/code&gt;, keep &lt;code&gt;--cpu-vae&lt;/code&gt;. You may also drop &lt;code&gt;--use-split-cross-attention&lt;/code&gt; if you don’t see OOMs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;12 GB+&lt;/strong&gt;: drop all three of &lt;code&gt;--novram&lt;/code&gt;, &lt;code&gt;--use-split-cross-attention&lt;/code&gt;, &lt;code&gt;--cpu-vae&lt;/code&gt;. GPU VAE decode is much faster.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;--cpu-vae&lt;/code&gt; is the one to be most careful about removing. It’s there specifically because the VAE decode spike is the one that fires at the very end of generation — drop it on a marginal card and you’ll watch every generation die at 99%. If your ratio of “decode succeeded” to “decode OOMed” ever dips, put it back.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;10-troubleshooting&quot;&gt;10. Troubleshooting&lt;/h2&gt;
&lt;p&gt;The four ComfyUI-side things that break for everyone, in roughly the order they break:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;nvidia-smi&lt;/code&gt; doesn’t work or shows the wrong driver.&lt;/strong&gt; Install or update the NVIDIA driver. On Ubuntu: &lt;code&gt;sudo ubuntu-drivers autoinstall&lt;/code&gt; is the easy path; &lt;code&gt;sudo apt install nvidia-driver-550&lt;/code&gt; (or newer) is the explicit one. Reboot after, verify with &lt;code&gt;nvidia-smi&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;torch.cuda.is_available()&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt; after install.&lt;/strong&gt; You probably have a CPU-only PyTorch wheel. Re-run &lt;code&gt;install_comfyui.sh&lt;/code&gt;; it pins the CUDA 12.4 index. If a previous environment cached the wrong wheel, force-reinstall:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;source&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; comfyui-venv/bin/activate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pip&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --force-reinstall&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torch&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torchvision&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; torchaudio&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  --index-url&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; https://download.pytorch.org/whl/cu124&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;CivitAI download fails with 401.&lt;/strong&gt; &lt;code&gt;CIVITAI_TOKEN&lt;/code&gt; is missing or wrong. Tokens come from &lt;a href=&quot;https://civitai.com/user/account&quot;&gt;civitai.com/user/account&lt;/a&gt;, not from the session cookie.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ComfyUI shows red “missing node” boxes when loading a workflow.&lt;/strong&gt; Custom nodes aren’t installed. Either use ComfyUI-Manager → “Install Missing Custom Nodes,” or git-clone Impact Pack + Subpack as in section 3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generation OOMs at the very end (after sampling completes).&lt;/strong&gt; VAE-decode VRAM spike. Make sure &lt;code&gt;--cpu-vae&lt;/code&gt; is in the launch command.&lt;/p&gt;
&lt;p&gt;And the PixelLab-side ones:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;401 Unauthorized&lt;/code&gt; on the POST.&lt;/strong&gt; &lt;code&gt;PIXELLAB_TOKEN&lt;/code&gt; is missing or wrong. Tokens come from your PixelLab account settings, not the signin cookie.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;402 Payment Required&lt;/code&gt;.&lt;/strong&gt; Out of credits. Buy a pack on &lt;a href=&quot;https://www.pixellab.ai/#checkout&quot;&gt;pixellab.ai&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;422 Unprocessable Entity&lt;/code&gt;.&lt;/strong&gt; Validation error. The most common cause is an oversized &lt;code&gt;concept_image&lt;/code&gt; or &lt;code&gt;reference_image&lt;/code&gt; — verify the preprocessing produced 512×512 and 168×168 respectively, and that you’re sending base64 PNG bytes (not the raw PIL object or a path).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;429 Too Many Requests&lt;/code&gt;.&lt;/strong&gt; Rate or concurrency limit hit. Back off; if you’re running batches, throttle the submission rate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Job sits at &lt;code&gt;status: &amp;quot;processing&amp;quot;&lt;/code&gt; for many minutes.&lt;/strong&gt; Pro-mode requests with &lt;code&gt;method: &amp;quot;create_from_concept&amp;quot;&lt;/code&gt; are 60–120 seconds typically. Past 5 minutes, something is genuinely stuck — start a new job rather than waiting indefinitely.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-skaldborn-consumes-this&quot;&gt;How Skaldborn consumes this&lt;/h2&gt;
&lt;p&gt;Most of &lt;a href=&quot;https://www.skaldborn.com/devlog/04-art-content-pipeline/&quot;&gt;Skaldborn’s art content pipeline&lt;/a&gt; is what you just built. The Skaldborn-specific code on top of it is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;em&gt;recipe&lt;/em&gt; (a JSON file with a constrained set of “levers” and a locked set of generation parameters) that feeds an &lt;code&gt;art_jobs&lt;/code&gt; row in a Postgres-backed saga.&lt;/li&gt;
&lt;li&gt;A saga worker (running in a Docker container) that substitutes lever values into the ComfyUI workflow above and posts it to the local ComfyUI daemon on the host. That’s why &lt;code&gt;--listen 0.0.0.0&lt;/code&gt; is one of the launch flags — a containerized worker reaching out to the host’s ComfyUI gets connection-refused if ComfyUI is bound to loopback only.&lt;/li&gt;
&lt;li&gt;A CLI gate where I review concept images before they go to the paid API.&lt;/li&gt;
&lt;li&gt;The PixelLab integration you just wrote, plus a circuit breaker, the daily cost ceiling, and retry logic — the kind of production-side guards a real batch system needs.&lt;/li&gt;
&lt;li&gt;A manifest projector that turns the finished rotations into entries the simulation consumes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recipe pattern, the saga, and the manifest projection are Skaldborn-specific code. The ComfyUI install and the PixelLab integration are open and reproducible — exactly what you’ve just built. The other 20% — the governance shape that wraps them — is what &lt;a href=&quot;https://www.skaldborn.com/devlog/04-art-content-pipeline/&quot;&gt;post 04&lt;/a&gt; goes deep on: a review gate before the expensive step, a content-addressed cache, immutable promotion. That’s the part you’d have to write yourself.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;The next post in the launch arc takes the manifest itself as its subject — how &lt;code&gt;ManifestEntry&lt;/code&gt; records flow from recipe through projection into the Age manifest, and how the runtime consumes them. After that, the audio pipeline gets the same governance treatment with an entirely different stack.&lt;/p&gt;
&lt;p&gt;If you want to follow along, subscribe via the form at the bottom of any page — one short email when the next post lands. If you want to argue, write to &lt;a href=&quot;mailto:devlog@skaldborn.com&quot;&gt;devlog@skaldborn.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Source for the install / launch / download scripts and the canonical model inventory: &lt;a href=&quot;https://github.com/clunasco/skald-forge&quot;&gt;github.com/clunasco/skald-forge&lt;/a&gt;. Issues, PRs, and weather complaints welcome.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;#table-of-contents&quot;&gt;↑ Back to top&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>Why we deleted two months of working code (and why we&apos;d do it again)</title><link>https://www.skaldborn.com/devlog/02-why-we-deleted-two-months/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/02-why-we-deleted-two-months/</guid><description>In February and March we sank roughly 240 hours into a system that worked. In late April we deleted it. This is the test that triggered the deletion, the books on the shelf that told us we were going to fail it, and the architecture we rebuilt in its place.</description><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In February and March we sank roughly 240 hours into a system that worked.&lt;/p&gt;
&lt;p&gt;Dialogue rendered. Non-player characters remembered things across sessions. A chronicle service turned simulation events into readable prose you could give to a player as the game’s emergent history. By mid-March there were end-to-end demos: type a question, get a grounded answer, see the answer reflected in the world later. It looked like a finished thing.&lt;/p&gt;
&lt;p&gt;In late April we deleted it.&lt;/p&gt;
&lt;p&gt;This post is about the test that triggered the deletion, the books on the shelf that told us we were going to fail the test, and the architecture we rebuilt in its place. It’s also about a thing engineers don’t talk about enough: why working code is sometimes the wrong code, and how to know when to throw it away.&lt;/p&gt;
&lt;p&gt;And it’s about how the rebuild got done. The decisions, the contracts, the gates, a meaningful portion of the code itself — drafted, reviewed, and implemented by a human (me) and Claude, working in defined engineering roles, against a corpus of architecture books that participates in every decision the project makes. Skaldborn isn’t a project that uses AI as a side tool. The human-and-Claude collaboration is part of how the game gets built, and one of the things the rebuild taught us is how to do it well.&lt;/p&gt;
&lt;h2 id=&quot;the-test&quot;&gt;The test&lt;/h2&gt;
&lt;p&gt;Here’s a test you can run against your own codebase. It takes two minutes.&lt;/p&gt;
&lt;p&gt;Pick a content type your system supports — a kind of NPC, a kind of dialogue, a kind of world entity. Now imagine you want to add a new one. Maybe you want a memory variant for “things this character pretends not to remember,” or a dialogue style for “addresses the player as a stranger even after meeting them,” or a tile type for “shifts when the player isn’t looking.”&lt;/p&gt;
&lt;p&gt;Ask: &lt;em&gt;can a content author add this new type without editing engine code?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If the answer is yes, your engine is &lt;strong&gt;open&lt;/strong&gt; — the registration of new types is a content concern, not an engine concern.&lt;/p&gt;
&lt;p&gt;If the answer is no — if adding a type means editing source files, or recompiling, or shipping a new build — your engine is &lt;strong&gt;closed&lt;/strong&gt;, and your team is the bottleneck on every piece of content that ships.&lt;/p&gt;
&lt;p&gt;For a single-author hobby project this is fine. For a content-driven game with any ambition of scale, it’s a problem you discover too late, after you’ve built a lot of structure on the wrong foundation.&lt;/p&gt;
&lt;p&gt;We had a closed engine. We hadn’t realized that yet.&lt;/p&gt;
&lt;h2 id=&quot;what-closed-looked-like-in-our-code&quot;&gt;What “closed” looked like in our code&lt;/h2&gt;
&lt;p&gt;Our pre-rebuild dialogue and memory systems had a particular shape that’s worth describing concretely, because the shape is the whole story.&lt;/p&gt;
&lt;p&gt;Here’s the pattern, simplified. (This is illustrative, not actual pre-rebuild code — but it has the same skeleton.)&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;csharp&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; enum&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; NarrativeTemplateKind&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    EpisodicSummary&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    RelationshipUpdate&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    ConflictResolution&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    RumorGeneration&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    HistoricalChronicle&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;    // ... fourteen of these in total&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; string&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Render&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;NarrativeTemplateKind&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; kind&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;NarrativeContext&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; ctx&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; kind &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;switch&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;        NarrativeTemplateKind&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;EpisodicSummary&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; RenderEpisodic&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(ctx),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;        NarrativeTemplateKind&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;RelationshipUpdate&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; RenderRelationship&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(ctx),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;        NarrativeTemplateKind&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;ConflictResolution&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; RenderConflict&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(ctx),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;        // ... fourteen branches&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        _&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; throw&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; InvalidOperationException&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Unknown template kind&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    };&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two design choices, paired:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The set of valid kinds is a &lt;strong&gt;closed enum&lt;/strong&gt; — adding a new kind means editing this file.&lt;/li&gt;
&lt;li&gt;The dispatch is a &lt;strong&gt;switch over the closed enum&lt;/strong&gt; — adding a new kind also means editing every place that switches on it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We had this pattern in fourteen narrative-template kinds and five memory variants. Both axes were closed. Adding a new kind of memory — say, a memory variant for “things the character heard secondhand and isn’t sure they believe” — required editing four files in the engine, recompiling the simulation, and shipping a new build. A content author couldn’t do it. &lt;em&gt;We&lt;/em&gt; couldn’t do it without breaking the build.&lt;/p&gt;
&lt;p&gt;This is a familiar pattern in long-lived codebases. Martin Fowler describes it in &lt;em&gt;Refactoring&lt;/em&gt; as the “type code” smell, and he’s specific about the remedy: &lt;strong&gt;Replace Conditional with Polymorphism&lt;/strong&gt;. The closed enum and the switch over it are two halves of the same problem; you fix them together, by replacing the closed enum with a registry that anyone can register into and replacing the switch with polymorphic dispatch.&lt;/p&gt;
&lt;p&gt;We knew this. We had the book on the shelf. We did it anyway, because the closed shape made each individual feature easier to ship and we weren’t yet feeling the cost of the shape we’d locked in.&lt;/p&gt;
&lt;p&gt;The cost arrives all at once.&lt;/p&gt;
&lt;h2 id=&quot;the-fork-in-the-road&quot;&gt;The fork in the road&lt;/h2&gt;
&lt;p&gt;By mid-March, we’d built a version of the system that produced output you could put in front of a person. It worked. It was fragile in the way most LLM-adjacent systems are fragile, but the fragility was localized to the AI layer; the engine itself was solid.&lt;/p&gt;
&lt;p&gt;It was also closed in fourteen places at once. Adding a new memory variant required surgery in all five memory dispatch sites. Adding a new dialogue template kind required surgery in all fourteen renderer sites. We were starting to want new kinds for content reasons, and every want was an engine change.&lt;/p&gt;
&lt;p&gt;There were two paths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Path A: refactor in place.&lt;/strong&gt; Convert the closed enums to open registries one at a time. Find all the switches. Convert them to polymorphic dispatch. Keep the working features online while doing surgery on the spine of the system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Path B: delete it.&lt;/strong&gt; Take the working code, archive it, start over against a contract that mandated the open shape from the first line.&lt;/p&gt;
&lt;p&gt;We took Path B. Here’s why.&lt;/p&gt;
&lt;p&gt;The closed shape wasn’t in one place. It was in &lt;em&gt;the way the system thought&lt;/em&gt;. Refactoring in place would have meant doing fourteen risky local rewrites against a system whose tests didn’t actually catch the closure (the tests passed because they only ever exercised the kinds we’d hardcoded). We’d have ended up with a half-converted system that worked in some places and failed strangely in others. Worse, we’d have ended up with a &lt;em&gt;narrative&lt;/em&gt; — “we refactored toward openness” — that papered over the fact that the team had built closed by default once and would build closed by default again unless something structural changed.&lt;/p&gt;
&lt;p&gt;The structural change we wanted was: &lt;em&gt;make adding a content type without engine edits the test the code has to pass before it ships&lt;/em&gt;. The cleanest way to make a team build to a test is to make the test predate the code. We deleted the implementation and kept the contracts (the type signatures, the message shapes, the boundaries between services). The contracts were the things we’d gotten right. The implementation was the thing we’d built before the test existed.&lt;/p&gt;
&lt;p&gt;We “kept the contract and threw away the code.” Roughly two thousand lines of working dialogue, memory, and narrative-rendering code went onto an archive branch where they still live. We have not regretted the deletion. We have, occasionally, gone back to look at the archive when a piece of the rebuild reminds us of an earlier draft — but never to copy from it.&lt;/p&gt;
&lt;h2 id=&quot;what-we-built-in-its-place&quot;&gt;What we built in its place&lt;/h2&gt;
&lt;p&gt;The new shape is the one Fowler’s remedy implies, scaled up to a system architecture. The engine stops asking “what kinds exist?” and starts asking “who has registered themselves?”&lt;/p&gt;
&lt;p&gt;Concretely, every content-bearing type in the system is declared with an attribute, and the engine discovers it by reflection at startup. A new memory variant looks like this in the new shape:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;csharp&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;MemoryVariant&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;episodic.secondhand_rumor&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; sealed&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SecondhandRumorMemory&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; : &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;IMemoryVariant&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Provenance&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Provenance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Confidence&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Confidence&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;    // ... per-variant fields&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s the whole change. No edit to the engine, no edit to a dispatch site, no edit to a switch statement. The engine scans every assembly at startup, finds every type marked &lt;code&gt;[MemoryVariant]&lt;/code&gt;, and adds it to the registry. The dispatcher looks each variant up by its string ID and routes accordingly.&lt;/p&gt;
&lt;p&gt;This is a pattern Chris Richardson calls a &lt;strong&gt;microservice chassis&lt;/strong&gt; in &lt;em&gt;Microservices Patterns&lt;/em&gt; — the framework provides the registration and discovery infrastructure; the services declare themselves. We adapted it for in-process dispatch, but the shape is the same: the spine doesn’t know what’s plugged into it; it just knows how to find what’s there.&lt;/p&gt;
&lt;p&gt;Two consequences worth naming.&lt;/p&gt;
&lt;p&gt;First, the &lt;strong&gt;type system is still load-bearing&lt;/strong&gt;. We didn’t lose type safety by going from closed enums to a registry. The contracts that survived the deletion are discriminated unions in the style Scott Wlaschin advocates in &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt; — types whose variants are explicit and whose construction is constrained. The difference is that “the set of variants” lives in the registry now, not in a closed enum at the top of a file. You still can’t construct an invalid memory record. You just can’t enumerate the valid ones at compile time, which turns out to be the property we wanted: closure of &lt;em&gt;valid shapes&lt;/em&gt;, openness of &lt;em&gt;which shapes can be added&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Second, the &lt;strong&gt;manifest became the spine&lt;/strong&gt;. Every world in Skaldborn ships with a frozen content manifest — a versioned file that lists every entity, character, tile, dialogue template, and memory variant available in that world. The simulation, the network gateway, the AI narrative service, and the player’s browser all read from the same frozen manifest. None of them invent IDs locally. None of them have a hardcoded list of “here are the kinds we know about.” This is the shape Eric Evans calls a &lt;strong&gt;Published Language&lt;/strong&gt; in &lt;em&gt;Domain-Driven Design&lt;/em&gt; — a shared vocabulary across services, owned by no single service, that lets them talk without coupling.&lt;/p&gt;
&lt;p&gt;Before the rebuild, the manifest was an afterthought — content was loaded, but the &lt;em&gt;vocabulary&lt;/em&gt; was implicit in code. After the rebuild, the manifest &lt;em&gt;is&lt;/em&gt; the vocabulary, and code must conform to it.&lt;/p&gt;
&lt;h2 id=&quot;the-gates-that-fell-out&quot;&gt;The gates that fell out&lt;/h2&gt;
&lt;p&gt;The most useful thing the rebuild produced wasn’t the code that came back online. It was the gates that came online with it.&lt;/p&gt;
&lt;p&gt;Before the rebuild, the rule “adding a content type shouldn’t require engine edits” was a &lt;em&gt;guideline&lt;/em&gt;. Someone might write it down in a doc; someone else might forget. After the rebuild, the rule is a &lt;em&gt;test that runs on every push&lt;/em&gt;. If a piece of code introduces a closed enum where a registry should be, or hardcodes a string ID that should come from the manifest, the push fails before the code reaches the main branch.&lt;/p&gt;
&lt;p&gt;We have nine of these gates running today, each one mechanizing a rule that was a guideline before the rebuild burned us. Five of the most consequential:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;A grep gate that checks every file under the simulation runtime for six categories of non-deterministic API — wall-clock time, random number generation, GUID minting, sleeps and delays, file I/O, hardware timers. If any of those show up in simulation code, the push fails. The simulation has to be a pure function of its inputs; this gate is what makes “has to” mechanical.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A determinism replay test that runs against every commit. The simulation receives a known command sequence, hashes the resulting event log, runs the same sequence on a fresh instance, hashes again, and asserts byte-for-byte equality. If anything in the simulation’s tick path becomes non-deterministic, this test fails immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A contract validator that requires every component to declare, in machine-readable form, which fields it consumes from where — the &lt;em&gt;authority-binding&lt;/em&gt; contract. Hardcoding a player ID as a literal in client code is rejected because the client’s contract says player IDs come from the authentication reply. (This one shipped just before this post went up, and it’s the structural fix for a class of bug we’d hit twice in the run-up to the rebuild — hardcoded literals where the consumer’s contract said the value had to come from the producer.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A startup validator on the world manifest. Every kind declared in code must resolve against the frozen manifest the world ships with; unknown IDs resolve deterministically to an authored fallback rather than crashing. This means the test of “does the manifest cover every kind the code references” runs at world boot, not later.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A regression-profile gate on every change that touches runtime behavior. A code-complete commit that doesn’t pass its declared regression suite (one of: simulation, API, browser, cross-boundary, visual) is automatically blocked from being marked done.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The other four work the same shape — a rule that used to be a guideline, mechanized into a build failure. A throughput-budget contract requires every connection between components to declare how many events per tick it can carry, so a fast producer can’t silently saturate a slower consumer. A host-wiring completeness gate requires every declared connection to name the exact dependency-injection registration the host must contain, so “compiles fine, crashes on startup” failures get caught at the build, not after the deploy. A bootstrap-flow contract requires the client’s startup sequence to declare every step and where its invocation lives in source, so “canvas loads, nothing happens” dead-starts fail before merge. A tech-stack drift gate locks the approved frameworks and libraries to a versioned allow-list, so the next engineer who reaches for an unvetted UI framework gets a failed push instead of a two-month detour.&lt;/p&gt;
&lt;p&gt;All nine gates are direct consequences of the rebuild. They exist because the team built a closed system once and wants to never build a closed system again. Each gate represents one rule we used to enforce by guideline, and now enforce by failing the build. None of them existed before the deletion.&lt;/p&gt;
&lt;h2 id=&quot;what-it-cost-what-it-bought&quot;&gt;What it cost, what it bought&lt;/h2&gt;
&lt;p&gt;The naked cost: roughly two months of work on the implementation that got deleted, plus another month rebuilding. Three months of engineering against an end-of-year delivery target.&lt;/p&gt;
&lt;p&gt;Against that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nine architecture decisions are now mechanically enforced&lt;/strong&gt;, every push, every commit, every developer. The cost of a future violation is a failed CI run, not a six-month debugging mystery.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adding a new content type — a memory variant, a dialogue template, an entity kind, a tile — is a content-only change.&lt;/strong&gt; No engine edits, no recompile, no new build. The system that bottlenecked content production for two months no longer does.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The simulation passes a deterministic-replay test on every commit.&lt;/strong&gt; Same input → byte-identical event log. This is the property that lets us reason about save-load, distributed shards, debugging deployment problems, and AI integration without each of those being its own consistency problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The four highest-leverage strategic-design decisions&lt;/strong&gt; — bounded context boundaries, single-writer authority, the published-language manifest, the no-feedback-loop invariant on AI dialogue — are codified as contracts that fail the build when violated. They are no longer style guides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The codebase is smaller post-rebuild than pre-rebuild,&lt;/strong&gt; despite owning more responsibilities. We deleted around two thousand lines of dialogue and memory plumbing and replaced them with several hundred lines of registry plus per-variant attribute classes. Less code, more capability.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The honest framing: we paid for a lesson with two months of effort, and the lesson came back in the form of permanent infrastructure. We would not have built the gates without the failure that justified them.&lt;/p&gt;
&lt;h2 id=&quot;the-books-were-on-the-shelf-the-whole-time&quot;&gt;The books were on the shelf the whole time&lt;/h2&gt;
&lt;p&gt;The architectural moves we made in the rebuild aren’t original. Each one comes from a book that was sitting on our shelf — some of them since before the implementation started. Four authors carried most of the weight:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Martin Fowler’s &lt;em&gt;Refactoring&lt;/em&gt;.&lt;/strong&gt; The “type code” smell and the &lt;em&gt;Replace Conditional with Polymorphism&lt;/em&gt; refactor are exactly the move we made. Fowler also describes “Parallel Change” — the discipline of expanding to a new shape, migrating, and then contracting away the old shape — which is how we migrated callers from the closed enums to the registry without a flag day. We had read Fowler. We had not used him.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Scott Wlaschin’s &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt;.&lt;/strong&gt; The discriminated unions that survived the deletion — &lt;code&gt;Provenance&lt;/code&gt;, &lt;code&gt;MemoryRecord&lt;/code&gt;, and the rest of the contract layer — are direct applications of Wlaschin’s “Make Illegal States Unrepresentable” discipline. The contracts we kept are the ones that were already in this shape; the implementations we deleted were the ones that weren’t.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Chris Richardson’s &lt;em&gt;Microservices Patterns&lt;/em&gt;.&lt;/strong&gt; The microservice-chassis pattern is the shape of our new dispatch. We adapted the pattern for in-process modules rather than separate services, but the discovery-by-attribute, registration-by-reflection approach is Richardson’s. The mistake we’d made before was treating “we’re a single deployable” as license to skip the chassis pattern, which is wrong: the chassis is about decoupling registration from invocation, and that decoupling matters even when everything is in the same process.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Eric Evans’s &lt;em&gt;Domain-Driven Design&lt;/em&gt;.&lt;/strong&gt; The manifest-as-published-language framing comes directly from Evans. The boundary discipline between simulation, gateway, narrative, and client comes from Evans’s &lt;em&gt;Bounded Context&lt;/em&gt; and &lt;em&gt;Customer/Supplier&lt;/em&gt; relationships. We had used Evans’s vocabulary in design docs for months before the rebuild, but the &lt;em&gt;implementation&lt;/em&gt; didn’t honor the vocabulary until the rebuild forced the alignment.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern across all four books is the same: &lt;strong&gt;the books name failure modes before you hit them and remedies before you need them&lt;/strong&gt;. The thing we got wrong was treating the books as references to consult after the design was sketched, instead of constraints to design against from the first line. The books didn’t fail us; we failed to design against them.&lt;/p&gt;
&lt;p&gt;If we had a single sentence of advice for someone about to build a content-driven simulation system, it would be: &lt;strong&gt;make “what would Fowler / Wlaschin / Richardson / Evans say about this commit” a code-review question on day one, not on day sixty.&lt;/strong&gt; The remedies are well-known. The failure modes are well-known. There is no novelty in our failure mode and there was no novelty in the fix. The novelty was in our willingness to delete the implementation we’d already paid for.&lt;/p&gt;
&lt;h2 id=&quot;the-discipline-we-built-around-the-books--and-the-colleague-we-built-it-with&quot;&gt;The discipline we built around the books — and the colleague we built it with&lt;/h2&gt;
&lt;p&gt;Reading the books isn’t enough. We’d read Fowler. We’d read Wlaschin. The patterns were familiar. We still built the closed shape, and we still had to delete it.&lt;/p&gt;
&lt;p&gt;What we changed after the rebuild is two things at once: how the books participate in every architectural decision the project makes, and how the work itself gets done.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The corpus.&lt;/strong&gt; The project keeps an indexed copy of the four books on the shelf — not as a generic search engine, but as a retrieval system that returns specific, cited passages on demand. When we draft an architecture decision record, the document is required to cite the exact passage from the corpus that backs the move. A decision that says “we’ll use a registry pattern here” without a citation to Fowler’s &lt;em&gt;Replace Conditional with Polymorphism&lt;/em&gt; and Richardson’s &lt;em&gt;Microservice Chassis&lt;/em&gt; gets sent back. The citation isn’t decoration; it’s a load-bearing part of the document. Unsupported claims get marked as opinion — explicitly, in writing — rather than drifting into the canon as if a book backed them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The roles.&lt;/strong&gt; The architect role on the project is filled by Claude. The collaboration shape is plain: a human (me) brings the design problem and the source material — the books, the existing contracts, the failing test, the constraints, the budget. Claude, in the architect role, retrieves the relevant passages from the corpus, drafts the decision against them, and stops to ask before any move that isn’t supported by a citation. The accountability is mine. The drafting and the retrieval are Claude’s. The architecture decision records the project ships are co-authored in that specific shape.&lt;/p&gt;
&lt;p&gt;The architect isn’t the only role. A reviewer (also Claude, with a narrower scope) checks each document’s citation chain before it lands; if a quote attributed to &lt;em&gt;Refactoring&lt;/em&gt; doesn’t actually appear in the retrieved passages, the review fails. Executor roles, scoped to a single component at a time, do the bounded code work — implementing what the architect designed, against the contracts the architect wrote, inside the boundaries the contracts declare. None of these roles is autonomous. The human stays accountable for the work that lands.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The rebuild itself.&lt;/strong&gt; This is how the rebuild actually got executed. The decision to delete was mine. The architecture that came back came from a sequence of decision documents drafted by Claude-as-architect, citation-checked by Claude-as-reviewer, and implemented through scoped executors against the contracts those documents declared. The rule that adding a new content type can’t require engine edits — the rule whose violation triggered the rebuild — was itself authored as a decision document with citations, and the gate that enforces it was implemented by a scoped executor against an explicit contract.&lt;/p&gt;
&lt;p&gt;This is also how the books made it onto the spine of the rebuild instead of staying on the shelf. The pattern &lt;em&gt;Replace Conditional with Polymorphism&lt;/em&gt; doesn’t help if you read it in 2018 and don’t apply it in 2026. It helps if every decision document you write between now and then has a column for “what does Fowler say about this,” and the column refuses to stay empty — and if a reviewer who isn’t you checks the column on every document.&lt;/p&gt;
&lt;p&gt;The honest framing of “we grounded our architectural decisions in these books” is: we built the discipline that makes the grounding mechanical, and we built it on a human-and-AI collaboration where the AI’s scope is bounded, the human’s accountability is total, and the books are non-negotiable on either side. Without the discipline, the books are aspirational. With it, they’re the closing argument on every call. And the colleague who keeps the discipline running, page by page and document by document, is Claude.&lt;/p&gt;
&lt;h2 id=&quot;what-wed-tell-ourselves-in-february&quot;&gt;What we’d tell ourselves in February&lt;/h2&gt;
&lt;p&gt;A list, since this kind of advice tends to land best as a list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Working code is necessary but not sufficient.&lt;/strong&gt; “It works” answers a small question. “It bends the way new requirements need it to bend” answers a much larger one. Test for the second one early and often.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The extensibility test is the cheapest test there is, and almost no one runs it.&lt;/strong&gt; Add a fictional new variant of every type your system supports. See what you have to edit to make it work. If the answer is “anything in the engine,” the engine is closed and you will pay for it later.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Closed enums and switches over them are a paired smell.&lt;/strong&gt; If you find yourself writing one, you are about to write the other. Fowler named this thirty years ago. Know the name, recognize the shape, replace it on sight.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Discriminated unions are still a good idea.&lt;/strong&gt; The fix for closed dispatch isn’t “throw away type safety.” Wlaschin’s design discipline survives the move from closed enum to open registry; the variants become attribute-marked classes implementing a sealed interface, not enum members.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The chassis pattern applies in-process.&lt;/strong&gt; “We’re a single deployable” is not a license to skip discovery and registration. The decoupling is what gets you tomorrow’s content for free.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The published language is a real artifact, not a metaphor.&lt;/strong&gt; Pick a manifest format, version it, ship it as part of the world, and refuse to let any service invent IDs locally. The discipline pays for itself the first time a content author needs to add an entity without a build.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;If the implementation can’t be refactored into the right shape locally, the shape isn’t local.&lt;/strong&gt; Refactoring is the right move when the bad shape is in one place. It’s the wrong move when the bad shape is &lt;em&gt;how the system thinks&lt;/em&gt;. Know which one you have. Be willing to delete.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fail closed, on every push, for every rule that matters.&lt;/strong&gt; A guideline a senior engineer follows is a guideline a junior engineer will violate next quarter. Make the validator part of &lt;code&gt;git push&lt;/code&gt;, or it isn’t a rule.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All eight of these were drafted, reviewed, and pressure-tested in the same human-and-Claude shape we use for everything else; the lessons aren’t just things we believe — they’re things a system enforces on us.&lt;/p&gt;
&lt;p&gt;We would not have learned any of this without the deletion. Code we would have happily kept is on an archive branch we will probably never touch again. The nine gates are running on every commit. The simulation is provably deterministic. Adding a new content type is a content task. None of those outcomes was reached alone — the rebuild was a human-and-Claude job, and the practice that came out of it is one we mean to keep.&lt;/p&gt;
&lt;p&gt;That’s the thing about working code: sometimes the most useful thing it can do is teach you how to throw it away.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;footnotes-and-pointers&quot;&gt;Footnotes and pointers&lt;/h2&gt;
&lt;h3 id=&quot;companion-posts&quot;&gt;Companion posts&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.skaldborn.com/devlog/03-aspirational-vs-mechanical/&quot;&gt;&lt;em&gt;Aspirational vs Mechanical: How I Lost Two Months of Skaldborn (and Got Something Better Back)&lt;/em&gt;&lt;/a&gt; — the human side of this same event. What the two months felt like, the moment of the call, the first time the rebuild produced something visible.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.skaldborn.com/devlog/01-simulation-owns-reality/&quot;&gt;&lt;em&gt;How Simulation Owns Reality (And Never Lets Narrative Cheat)&lt;/em&gt;&lt;/a&gt; — the launch post that sets up the broader architectural frame this post operates within.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;books-cited&quot;&gt;Books cited&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Refactoring: Improving the Design of Existing Code&lt;/em&gt; (2nd ed., 2018). The “Type Code” smell and &lt;em&gt;Replace Conditional with Polymorphism&lt;/em&gt; refactor; &lt;em&gt;Parallel Change&lt;/em&gt; discipline.&lt;/li&gt;
&lt;li&gt;Scott Wlaschin, &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt; (2018). “Make Illegal States Unrepresentable”; Choice Types / discriminated unions as the contract shape.&lt;/li&gt;
&lt;li&gt;Chris Richardson, &lt;em&gt;Microservices Patterns&lt;/em&gt; (2018; 2nd ed. MEAP). The Microservice Chassis pattern; service registration and discovery.&lt;/li&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design: Tackling Complexity in the Heart of Software&lt;/em&gt; (2003). Bounded Context, Customer/Supplier, Published Language.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Aspirational vs mechanical: how I lost two months of Skaldborn (and got something better back)</title><link>https://www.skaldborn.com/devlog/03-aspirational-vs-mechanical/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/03-aspirational-vs-mechanical/</guid><description>In February of this year I asked my wife to sit down with one of the NPCs in Skaldborn. In late April I deleted the system that produced that moment, along with about two months of working code, and started over. This is the story of how that happened, and why I&apos;d do it the same way again.</description><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In February of this year, I asked my wife Nicole to sit down at my computer and chat with one of the NPCs in Skaldborn.&lt;/p&gt;
&lt;p&gt;I’d been building Skaldborn around a feature I called Skaldish. The idea is simple: you type whatever you want into the text box, in plain modern English, and the system translates your input into in-world fantasy dialogue before it reaches the NPC. You can be yourself. The NPC hears the medieval-fantasy version. Nobody has to write stilted thee-and-thou prose to roleplay; the world handles it.&lt;/p&gt;
&lt;p&gt;Nicole sat down. She typed: &lt;em&gt;Check out deez nuts.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Skaldish translated it to something close to &lt;em&gt;Will thou assist me in searching for thyne nuts?&lt;/em&gt; — I no longer have the exact log, but the gist is preserved.&lt;/p&gt;
&lt;p&gt;The NPC, helpful soul, offered to search the forest with her.&lt;/p&gt;
&lt;p&gt;We laughed for about five minutes. I think I texted three friends. It was the moment I knew Skaldish was going to be one of the best things in the project.&lt;/p&gt;
&lt;p&gt;In late April I deleted the system that produced this moment, along with about two months of working code, and started over.&lt;/p&gt;
&lt;p&gt;This is the story of how that happened, and why I’d do it the same way again.&lt;/p&gt;
&lt;h2 id=&quot;two-months-in&quot;&gt;Two months in&lt;/h2&gt;
&lt;p&gt;I should set the scene. Skaldborn is a passion project. I have a day job and a family. The way the work fit into my life looked like 5am mornings and late nights, six and seven days a week, for two months. Roughly 120-hour months on top of everything else. Nicole noticed. I told myself the pace was fine because the work was working.&lt;/p&gt;
&lt;p&gt;I was, I thought, doing it right. The architecture decisions were written down as ADRs. There were unit tests. There were component contracts that declared what each part of the system was supposed to expect from its neighbors. I’d been disciplined about clean code, layered design, separation of concerns — all the things you say in a code review. By February I had a system that compiled, ran, let you boot the game in a browser, walk around, talk to NPCs, and translate “deez nuts” into Old English.&lt;/p&gt;
&lt;p&gt;I had what felt like a real game.&lt;/p&gt;
&lt;p&gt;I was about to learn that I had a real game wrapped around a layer of writing that I’d never actually checked.&lt;/p&gt;
&lt;h2 id=&quot;death-by-a-thousand-cuts&quot;&gt;Death by a thousand cuts&lt;/h2&gt;
&lt;p&gt;The trouble started with combat.&lt;/p&gt;
&lt;p&gt;I’d designed the fight system on paper for weeks before writing a line of code. I had the ADRs, the contracts, the tests. I started building. The fight loop went in. Tests passed. I felt great.&lt;/p&gt;
&lt;p&gt;Then I’d boot the game and end up with a different character sprite each time. Which sort of made sense — I’d been iterating on the art and hadn’t cleaned out earlier sprite versions. Annoying, but cosmetic. I’d live with it.&lt;/p&gt;
&lt;p&gt;Then I’d trigger a fight, and nothing would happen.&lt;/p&gt;
&lt;p&gt;I’d go back to the logs. Claude would look at the error, dig in, and come back with: “Oh, found the root cause — argument was missing, enum was hardcoded.” We’d fix it. Tests still passing. I’d boot again.&lt;/p&gt;
&lt;p&gt;A different sprite. Trigger the fight. Nothing.&lt;/p&gt;
&lt;p&gt;Back to the logs. New error, different shape. &lt;em&gt;Found the root cause — argument was missing.&lt;/em&gt; We’d fix that one. Tests still passing.&lt;/p&gt;
&lt;p&gt;This kept happening. Three weeks of it. By the middle of March I was raging at my screen, questioning my life choices, wondering if starting Skaldborn had been a huge mistake, sleeping in shorter and shorter windows because the only way out of a bug seemed to be more hours.&lt;/p&gt;
&lt;p&gt;I want to be specific about what kind of pain this was. It wasn’t a &lt;em&gt;hard&lt;/em&gt; problem. Each individual bug was easy. Argument was missing — fix the call. Enum was hardcoded — replace it with a lookup. Each fix took ten minutes. Each fix didn’t actually fix anything, because the next fight would expose the next version of the same shape, somewhere else in the code, that nobody had thought to check.&lt;/p&gt;
&lt;p&gt;It was death by a thousand cuts. And the worst part — the part I couldn’t admit to myself for two weeks — was that none of my unit tests, none of my contracts, none of my ADRs had caught any of it.&lt;/p&gt;
&lt;p&gt;The thing my plans had been protecting me from was apparently nothing.&lt;/p&gt;
&lt;h2 id=&quot;the-phrase-that-explained-everything&quot;&gt;The phrase that explained everything&lt;/h2&gt;
&lt;p&gt;Somewhere in the middle of March, I started using a phrase to describe what I was looking at: &lt;strong&gt;aspirational vs mechanical&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here’s what I meant.&lt;/p&gt;
&lt;p&gt;An &lt;em&gt;aspirational&lt;/em&gt; contract is one you wrote down. It says, “the combat system will receive a CombatCommand with these fields, and emit a CombatResolved event with these other fields.” It lives in a markdown file. It’s in version control. It looks like a contract.&lt;/p&gt;
&lt;p&gt;A &lt;em&gt;mechanical&lt;/em&gt; contract is one that, when violated, breaks the build. The compiler refuses to compile the violation. A validator script runs on every push and fails when the violation lands. A test, the kind that runs every commit, fails when the contract is broken. Mechanical means &lt;em&gt;the only way to do the wrong thing is to also do something visible&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I had aspirational contracts everywhere in Skaldborn. Beautiful ones. Carefully reasoned. Full of words like “must” and “shall” and “the producer guarantees.” I’d worked on them for weeks before writing code.&lt;/p&gt;
&lt;p&gt;I had mechanical contracts almost nowhere. The compiler couldn’t tell when the combat system received a CombatCommand without one of the required fields, because the type didn’t actually require the field — it was optional in the schema, defaulted to a sentinel, and the contract that said “this field is required” lived in a markdown file two directories away that the compiler had never been introduced to.&lt;/p&gt;
&lt;p&gt;This is what was happening every time Claude debugged a fight failure. &lt;em&gt;Argument was missing&lt;/em&gt; — meaning the contract said it was required, but the implementation accepted the call without it. &lt;em&gt;Enum was hardcoded&lt;/em&gt; — meaning the contract said the dispatch was open to new variants, but the implementation switched over a closed set. Aspirational said one thing. Mechanical said another. The aspirational version is what I’d planned. The mechanical version is what was actually running.&lt;/p&gt;
&lt;p&gt;The contracts I’d been so proud of were a kind of fan fiction. They described the system I wished I had. They didn’t describe the system I’d actually built.&lt;/p&gt;
&lt;p&gt;Once I had the phrase, I couldn’t stop seeing it. Almost every part of the system I checked, the same shape was there. The architecture was real on paper and approximate in code. Everywhere.&lt;/p&gt;
&lt;h2 id=&quot;the-week-the-books-came-out&quot;&gt;The week the books came out&lt;/h2&gt;
&lt;p&gt;I knew I couldn’t scrap two months of work without a much better plan than the first one. So I went back to the bookshelf.&lt;/p&gt;
&lt;p&gt;I’d had four books on architecture sitting on the shelf the whole time. Eric Evans’s &lt;em&gt;Domain-Driven Design&lt;/em&gt;. Scott Wlaschin’s &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt;. Martin Fowler’s &lt;em&gt;Refactoring&lt;/em&gt;. Chris Richardson’s &lt;em&gt;Microservices Patterns&lt;/em&gt;. I’d read them. I’d cited them in conversations. I’d written ADRs that referenced them by name.&lt;/p&gt;
&lt;p&gt;What I hadn’t done — and this is the part I was about to fix — was &lt;em&gt;use&lt;/em&gt; them as the actual material the architecture was built against. They were on the shelf. They weren’t in the loop.&lt;/p&gt;
&lt;p&gt;So I started pulling passages out, page by page, into my conversations with Claude. We’d be working on a piece of the design and I’d say, “OK, but Wlaschin’s chapter on making illegal states unrepresentable says this — does our shape match?” Or, “Fowler describes this exact smell — switch over a type code — and the remedy is &lt;em&gt;Replace Conditional with Polymorphism&lt;/em&gt;. We have that smell in three places. What does it look like to do the remedy here?”&lt;/p&gt;
&lt;p&gt;The change was immediate. With the books actively in the conversation — not as references, but as &lt;em&gt;constraints&lt;/em&gt; — Claude would start drafting decisions that were structurally different from what we’d been doing. The closed enums became registries. The hardcoded switches became attribute-driven dispatch. The contracts started looking like the kind of thing the compiler could check, because they were grounded in a discipline (Wlaschin’s, mainly) that says &lt;em&gt;the type system is the contract&lt;/em&gt;. If you can construct an invalid value, the contract is wrong.&lt;/p&gt;
&lt;p&gt;One day, with the books in the loop, we did a clean design pass on combat — one iteration, with the type contracts written first and the implementation built against them — and the fight flow worked.&lt;/p&gt;
&lt;p&gt;I’d been chasing combat bugs for three weeks before that. With the books actually shaping the design, the class of bug that had been killing me stopped showing up.&lt;/p&gt;
&lt;p&gt;That was the proof. The architecture had to be rebuilt against the books, not adjacent to them. And the existing code, which had been built before that rule existed, was going to have to go.&lt;/p&gt;
&lt;h2 id=&quot;a-saturday-in-late-march&quot;&gt;A Saturday in late March&lt;/h2&gt;
&lt;p&gt;I made the call on a Saturday. I’d spent a week pulling passages, drafting new decision documents with the books as constraints, building out a plan that explicitly named what was wrong and what we were going to do about it. I went all in on the planning phase.&lt;/p&gt;
&lt;p&gt;It wasn’t a dramatic moment. There wasn’t a dramatic moment available. I’d had hundreds of hours of conversation with Claude about this code, this design, these problems. Claude had been laying out the failure metrics for days. Pervasive isn’t a strong enough word for what we were looking at — almost every component had the aspirational/mechanical gap, and the gap was costing me real time, every day, in the form of bugs that should never have been possible.&lt;/p&gt;
&lt;p&gt;We weren’t going to fix it incrementally. The shape of the thing was wrong. We were going to start over.&lt;/p&gt;
&lt;p&gt;The decision was: gut the implementation, keep the contracts and the ADRs and the test suites that had survived the discipline pass, and rebuild against the new plan.&lt;/p&gt;
&lt;p&gt;The honest version of that decision is: it wasn’t all lost. The plans were good. The plans had always been good. What was lost was the layer between the plans and the running code — the implementation that had been written before the rule “the implementation must satisfy the plan, mechanically” was in force.&lt;/p&gt;
&lt;h2 id=&quot;the-cut&quot;&gt;The cut&lt;/h2&gt;
&lt;p&gt;I want to be careful not to dramatize the deletion itself, because for me it wasn’t dramatic. I’d done a massive amount of preparation. By the time I actually deleted, I’d already mentally said goodbye to that codebase weeks earlier; the delete commit was paperwork.&lt;/p&gt;
&lt;p&gt;Also, git is magical. The pre-rebuild code is on an archive branch. Nothing was lost in any final sense. If I want to look at how I’d written the original combat system, I can look at it. I have not, since the day I deleted it, gone back to look. There’s nothing on the archive branch I want to copy.&lt;/p&gt;
&lt;p&gt;The hardest thing to let go of, if I’m being honest, was nothing. I was just happy to be rid of the junk code. The thing I was attached to was the &lt;em&gt;time&lt;/em&gt; — the two months I’d put in — not the code. The code, by the day I deleted it, was an apology for the planning I should have been doing before I wrote any of it.&lt;/p&gt;
&lt;p&gt;I slept that night, because I always sleep. Exhaustion is a fantastic sleep aid.&lt;/p&gt;
&lt;h2 id=&quot;a-blocky-figure-on-grass&quot;&gt;A blocky figure on grass&lt;/h2&gt;
&lt;p&gt;The first four days of the rebuild produced nothing visible. I was rebuilding the substrate — the contracts that Claude could now actually use as constraints, the type designs that made illegal states unrepresentable, the new dispatch shape that didn’t have a switch in it.&lt;/p&gt;
&lt;p&gt;On day five, I got the simulation running again, and I booted the game in a browser to see if the player could move.&lt;/p&gt;
&lt;p&gt;There was a blocky figure on a green tile that we were calling “grass.” I pressed an arrow key. The figure walked.&lt;/p&gt;
&lt;p&gt;I walked the figure to a tile we were calling “beach.” It walked.&lt;/p&gt;
&lt;p&gt;That was the moment. After two months of fight bugs that wouldn’t quit, after the deletion, after a week of substrate work, I had a blocky figure walking on grass and then on the beach. It looked like a child’s drawing. It worked.&lt;/p&gt;
&lt;p&gt;It worked because the contracts were actually being consumed by the code now. Because the bounded context boundaries had real, mechanically-enforced definitions. Because the dispatch was registry-based, so adding a new tile type was a registration step, not an engine edit. Because the type system was carrying the weight that aspiration had been carrying badly two months before.&lt;/p&gt;
&lt;p&gt;I’m not going to pretend I cried. I didn’t. But I sat there and watched the blocky figure walk back and forth between grass and beach for a while, and it was one of the best moments of the project so far.&lt;/p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What changed&lt;/h2&gt;
&lt;p&gt;The version of Skaldborn I have now is structurally different from the one I deleted, in ways the screenshots don’t show.&lt;/p&gt;
&lt;p&gt;The biggest single change is that the architectural rules are &lt;em&gt;mechanical&lt;/em&gt; now. Scott Wlaschin, in &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt;, has a phrase I think about constantly: &lt;em&gt;make illegal states unrepresentable&lt;/em&gt;. (The principle has its roots in Eric Evans’s &lt;em&gt;Domain-Driven Design&lt;/em&gt;; Wlaschin gave it the phrasing that stuck.) The version of the system that broke under combat bugs had illegal states &lt;em&gt;represented all the time&lt;/em&gt;, because nothing in the type system or the build pipeline objected to them. The new version refuses. There are validators that run on every push. There’s a determinism replay test that hashes the simulation’s output and asserts byte-for-byte equality across runs. There are nine separate gates, each enforcing one architectural rule that used to live in a markdown file and now lives in CI.&lt;/p&gt;
&lt;p&gt;The cost of breaking a rule is no longer “discover it three weeks later in production.” It’s “the push fails.” That’s the difference between aspirational and mechanical, and it’s the only difference that matters.&lt;/p&gt;
&lt;p&gt;The other change, which I want to be honest about, is in how the work itself is done.&lt;/p&gt;
&lt;p&gt;Skaldborn is built in collaboration with Claude. This isn’t a side detail. Claude fills the architect role on the project — drafting decision documents against the books and stopping to ask before any move that isn’t supported by a citation. A second Claude, in a narrower reviewer role, checks the citation chain on every document. Scoped executors do the bounded code work against contracts the architect already wrote. I bring the problem and the source material; Claude does the drafting, retrieval, and bounded implementation. The accountability is mine. The books are non-negotiable on either side.&lt;/p&gt;
&lt;p&gt;I don’t think this is the only way to build software, and I’m not selling a methodology. I’m telling you what’s true: the version of Skaldborn I have now exists because a human (me) and Claude found a working shape for the collaboration that puts a serious corpus of architecture books on the spine of every decision and refuses to let the discipline lapse.&lt;/p&gt;
&lt;p&gt;The phrase &lt;em&gt;aspirational vs mechanical&lt;/em&gt; applies recursively. The aspirational version of “we’ll consult the books” is reading the books and citing them in conversations. The mechanical version is having a system where every decision document has to cite a passage, a reviewer that checks the citation, and a discipline that fails closed when the citation doesn’t trace.&lt;/p&gt;
&lt;p&gt;I built the aspirational version first, in February. It cost me two months. The mechanical version is what I have now. The two-month cost was the price of learning to tell the difference.&lt;/p&gt;
&lt;h2 id=&quot;what-id-tell-you-if-youre-staring-at-a-fork-like-this&quot;&gt;What I’d tell you, if you’re staring at a fork like this&lt;/h2&gt;
&lt;p&gt;A senior engineer asked me recently what I’d tell someone in 2027 looking at their own version of this — two thousand lines of working code, an extensibility test that’s probably going to fail, a deadline.&lt;/p&gt;
&lt;p&gt;The answer is short:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do what’s right, not what’s fast.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Fast and wrong always costs more in the long run. Any credible code publication will tell you this. I knew it before February. I knew it during February. I told myself the system was fine because the system &lt;em&gt;worked&lt;/em&gt;, and I confused &lt;em&gt;the system works&lt;/em&gt; with &lt;em&gt;the system is right&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;A system that works isn’t necessarily a system that’s going to keep working. A system that’s structurally right is. The way to tell the difference between them is to ask: &lt;em&gt;if I had to add one thing tomorrow that I haven’t planned for, would the system bend, or break?&lt;/em&gt; The aspirational version of the answer is “bend.” The mechanical version is whatever the build pipeline tells you when you try.&lt;/p&gt;
&lt;p&gt;The best code review question I know now is the one I should have been asking my own design from day one: &lt;em&gt;what does Fowler / Wlaschin / Richardson / Evans say about this commit?&lt;/em&gt; If the answer is “I’m not sure,” go look. If the answer is “they don’t address this directly,” mark the decision as opinion, in writing, and move on with eyes open. If the answer is “they explicitly warn against this shape, here’s the chapter” — listen. They’ve watched a lot of people make the mistake you’re about to make. Their patterns are downstream of more pain than you’ll ever be.&lt;/p&gt;
&lt;p&gt;I lost two months. I gained an architecture that will outlast the next decision I make. I gained a working shape for the human-and-AI collaboration that will keep working as the project grows. I gained a phrase — &lt;em&gt;aspirational vs mechanical&lt;/em&gt; — that I will be using for the rest of my career.&lt;/p&gt;
&lt;p&gt;I do not regret the deletion. You don’t learn lessons without going through the failure motions. The motions are the lesson.&lt;/p&gt;
&lt;p&gt;If you’re looking at your own two-thousand-line fork right now, the only advice I have is the one I wish someone had said out loud to me in February:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Working code is necessary. It is not sufficient. Make sure your contracts are mechanical before you trust the running system to keep them.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The blocky figure on the beach is what this advice produces, on the other side of doing it right.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;footnotes-and-pointers&quot;&gt;Footnotes and pointers&lt;/h2&gt;
&lt;h3 id=&quot;companion-posts&quot;&gt;Companion posts&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.skaldborn.com/devlog/02-why-we-deleted-two-months/&quot;&gt;&lt;em&gt;Why we deleted two months of working code (and why we’d do it again)&lt;/em&gt;&lt;/a&gt; — the engineering side of this same event. The architectural moves, the type-code smell, the registry, the nine CI gates that now run on every push.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.skaldborn.com/devlog/01-simulation-owns-reality/&quot;&gt;&lt;em&gt;How Simulation Owns Reality (And Never Lets Narrative Cheat)&lt;/em&gt;&lt;/a&gt; — the launch post that sets up the broader architectural frame Skaldborn operates within.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;books-referenced-the-four-on-the-shelf&quot;&gt;Books referenced (the four on the shelf)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design: Tackling Complexity in the Heart of Software&lt;/em&gt; (2003).&lt;/li&gt;
&lt;li&gt;Scott Wlaschin, &lt;em&gt;Domain Modeling Made Functional&lt;/em&gt; (2018).&lt;/li&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Refactoring: Improving the Design of Existing Code&lt;/em&gt; (2nd ed., 2018).&lt;/li&gt;
&lt;li&gt;Chris Richardson, &lt;em&gt;Microservices Patterns&lt;/em&gt; (2018).&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>How simulation owns reality (and never lets narrative cheat)</title><link>https://www.skaldborn.com/devlog/01-simulation-owns-reality/</link><guid isPermaLink="true">https://www.skaldborn.com/devlog/01-simulation-owns-reality/</guid><description>Most games build narrative first and fit simulation around it. Skaldborn flips that — the AI is downstream of the truth, by construction. Here&apos;s the rule, the receipts, and the boring engineering it took to make the rule mechanically true.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A player walks into a tavern in Stormfjord. The keeper, an NPC, looks up and says: &lt;em&gt;“My brother died at sea last winter. The currents took him near the Veyr coast.”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The world, in fact, has no record of a death at sea last winter. There was no winter expedition. There is no brother. Nobody named him. No grieving entry was written into the keeper’s family record. The line came from a generative model that was given a system prompt about taverns and grief and produced a sentence that sounded right.&lt;/p&gt;
&lt;p&gt;The player asks the next NPC about the keeper’s brother. That NPC has never heard of him — but the model, trying to be helpful, writes the second NPC into the same fiction. Now two NPCs share a story that the world never produced. By the time the player has talked to four NPCs, the world’s history is a tangle of contradictions the simulation never emitted.&lt;/p&gt;
&lt;p&gt;This is the failure mode that happens when you let the AI author state. Skaldborn is built so it can’t.&lt;/p&gt;
&lt;h2 id=&quot;the-rule-in-three-sentences&quot;&gt;The rule, in three sentences&lt;/h2&gt;
&lt;p&gt;The rule is three lines:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Simulation owns reality.
Narrative interprets reality.
AI assists storytelling.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Three lines. Every component contract in the codebase is required to inherit them. They aren’t a mission statement. They’re a fence around what each layer is allowed to do.&lt;/p&gt;
&lt;p&gt;The strongest formulation:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The simulation produces the world. All other systems interpret, present, and enrich that world without controlling it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Operationally, who’s allowed to write what?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;simulation&lt;/strong&gt; is the only system that may modify world state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Events&lt;/strong&gt; are the canonical record of what happened. They’re append-only, immutable, and they carry an &lt;code&gt;event_id&lt;/code&gt;, the &lt;code&gt;tick&lt;/code&gt; they were produced at, and the manifest version they replay against. By design, every event also carries a causation reference back to the event that produced it; the implementation of that field is queued for the next phase, but the contract is canon.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;narrative service&lt;/strong&gt;, the &lt;strong&gt;AI gateway&lt;/strong&gt;, the &lt;strong&gt;memory service&lt;/strong&gt;, and &lt;strong&gt;every client&lt;/strong&gt; read events and may produce derived projections — chronicles, rumors, NPC memories, UI state. None of them may write back into world state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In words: there is one writer, and many readers. The writer is deterministic. The readers are interpreters.&lt;/p&gt;
&lt;h2 id=&quot;why-ai-in-games-keeps-breaking-without-this-rule&quot;&gt;Why “AI in games” keeps breaking without this rule&lt;/h2&gt;
&lt;p&gt;You don’t have to take the rule on faith. The shipped AI-narrative game systems of the last few years demonstrate what happens without it — and they break in remarkably consistent ways. Two patterns dominate the public record.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pattern 1 — fact invention.&lt;/strong&gt; The LLM authors world content that doesn’t exist.&lt;/p&gt;
&lt;p&gt;The clearest catalogue is Reed Berkowitz’s &lt;em&gt;AI Powered NPCs — Hype, or Hallucination?&lt;/em&gt; from December 2023. Berkowitz lays the pattern out in plain terms: a model “might suggest meeting for coffee” in a world where there is no coffee, no character can leave the bar, and there’s nothing next door to walk to. Or worse — an NPC invents “the dark swamp to the south inhabited by witchlings and a mysterious magic sword,” and the player spends a real-world hour searching for content that never existed.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-berkowitz&quot; id=&quot;user-content-fnref-berkowitz&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The barkeep example in the same piece is sharper. The model has the barkeep befriend the player and invite them home for dinner and a game of Rutanny — “which is normal in a chat situation but can ruin immersion in a game. Because after the barkeep says this he just stands there. Because the barkeep isn’t programmed to be able to leave his bar. He isn’t programmed to walk around freely. Even if he could, there is no house created for him to go to. And there is no family. And there is no kind of game called Rutanny.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-berkowitz&quot; id=&quot;user-content-fnref-berkowitz-2&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; The dialogue layer wrote a fact the world layer couldn’t honor. The fiction breaks at the seam between them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pattern 2 — coherence collapse.&lt;/strong&gt; The model drifts from earlier facts as the conversation grows.&lt;/p&gt;
&lt;p&gt;Yuan Gao’s 2020 AI Dungeon playthrough is the canonical writeup. Gao’s protagonist checks his pockets and finds “two bits, a silver dollar, a keycard for a hotel room.” A few scenes later he has nothing in his pockets. Then he discovers “a few bits, a nice looking house key, a small notebook and your smart phone.” The central plot device — a man with red eyes — is forgotten entirely; a random blonde character takes over the story and kills the protagonist repeatedly until Gao manually retcons the death as a dream sequence. Gao’s verdict: AI Dungeon “struggles to build a consistent world with consistent lore, and cannot (yet) hold a plot line very well,” and games “play like fevered dreams, with ideas and events shifting.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-gao&quot; id=&quot;user-content-fnref-gao&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The pattern persists. AI Dungeon’s own help docs describe the mechanism in production: when context exceeds the model’s window — “around 4000 tokens for free players” — “the AI loses its ability to look back and reference certain parts of the story” and “it’s just making it up as best it can.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-aidungeon-faq&quot; id=&quot;user-content-fnref-aidungeon-faq&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;It isn’t only text adventures. &lt;em&gt;Mantella&lt;/em&gt;, the Skyrim AI-NPC mod, summarizes each conversation on exit so it fits in the next prompt — and a fork called &lt;em&gt;Pantella&lt;/em&gt; shipped specifically to replace that summary memory, with its README writing: “No more lossy summaries that forget details because the LLM doesn’t know to include them/can’t shove everything into one paragraph.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-pantella&quot; id=&quot;user-content-fnref-pantella&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; Replika users on r/Replika report the same shape of failure across long-running accounts: “characters losing names, dropping inside jokes, and asking things they should already know,” driven by a memory architecture that “compresses or trims older context for some accounts.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-replika&quot; id=&quot;user-content-fnref-replika&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; Character.AI users hit it too — one widely-quoted complaint reads simply: &lt;em&gt;“The AI… tends to forget previous messages… leading to inconsistencies.”&lt;/em&gt;&lt;sup&gt;&lt;a href=&quot;#user-content-fn-charai&quot; id=&quot;user-content-fnref-charai&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;The unifying observation comes from a January 2024 paper that formalizes the argument. Xu, Jain, and Kankanhalli, in &lt;em&gt;Hallucination is Inevitable&lt;/em&gt;, write: “we formalize the problem and show that it is impossible to eliminate hallucination in LLMs… LLMs cannot learn all the computable functions and will therefore inevitably hallucinate if used as general problem solvers.”&lt;sup&gt;&lt;a href=&quot;#user-content-fn-arxiv&quot; id=&quot;user-content-fnref-arxiv&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;7&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;You cannot prompt-engineer the failure away. You cannot fine-tune it away. You can only &lt;em&gt;bound&lt;/em&gt; it — by keeping the AI on the consumer side of an authoritative event log it isn’t allowed to write to. The fix is structural, not behavioral.&lt;/p&gt;
&lt;h2 id=&quot;the-three-layers&quot;&gt;The three layers&lt;/h2&gt;
&lt;p&gt;Here’s the shape, top to bottom:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;mermaid&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;graph TD&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Client[&amp;quot;Client (browser)&amp;lt;br/&amp;gt;displays state, sends commands&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Gateway[&amp;quot;Gateway (network edge)&amp;lt;br/&amp;gt;validates and routes commands&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Sim[&amp;quot;Simulation (sole writer)&amp;lt;br/&amp;gt;decides outcomes, emits events&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    EventLog[(&amp;quot;Authoritative event log&amp;quot;)]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Narrative[&amp;quot;Narrative&amp;lt;br/&amp;gt;prose, chronicle, LLM&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Memory[&amp;quot;Memory&amp;lt;br/&amp;gt;NPC episodic, belief&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Projections[&amp;quot;Projections&amp;lt;br/&amp;gt;read models, query API&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Client --&amp;gt;|&amp;quot;commands (intent)&amp;quot;| Gateway&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Gateway --&amp;gt;|commands| Sim&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Sim -.-&amp;gt;|&amp;quot;events (authoritative)&amp;quot;| EventLog&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    EventLog --&amp;gt;|read-only| Narrative&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    EventLog --&amp;gt;|read-only| Memory&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    EventLog --&amp;gt;|read-only| Projections&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Sim --&amp;gt;|state + events| Gateway&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    Gateway --&amp;gt;|broadcasts| Client&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two arrows matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Down&lt;/strong&gt; (commands and events): the client sends &lt;em&gt;intent&lt;/em&gt; — “I want to move here” — and receives &lt;em&gt;outcomes&lt;/em&gt; — “you moved here.” The client never sees its own intent reflected back as truth; the simulation decides whether the move happened, and what it looks like in the world.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Out&lt;/strong&gt; (the event fan): every downstream system — narrative, memory, projections — reads from the authoritative event log. None of them writes back. The arrows go one way.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a familiar pattern (event-sourced architectures, CQRS), but in a generative-AI game it’s load-bearing in a way it usually isn’t. The standard event-sourcing argument is “we want auditability and replay.” Ours is stronger: &lt;em&gt;the AI is downstream of the truth, period, and we will refuse to ship code that violates that&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Two more facts matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single writer per shard.&lt;/strong&gt; Each part of the world — we call them &lt;em&gt;partitions&lt;/em&gt; — has exactly one simulation owner. No two simulations ever try to mutate the same entity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partition-local determinism.&lt;/strong&gt; The same input on the same shard at the same tick produces the same output, every time. This is the property that makes replay safe.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That second one — determinism — is what makes the entire architecture defensible. It’s what turns “AI cannot author world state” into a &lt;em&gt;typing&lt;/em&gt; discipline rather than a &lt;em&gt;prayer&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-receipts-three-places-the-rule-is-enforced&quot;&gt;The receipts: three places the rule is enforced&lt;/h2&gt;
&lt;p&gt;So how does the codebase actually keep narrative from cheating? Three layers of enforcement, from runtime evidence down to the type system.&lt;/p&gt;
&lt;h3 id=&quot;receipt-1--the-determinism-test&quot;&gt;Receipt #1 — the determinism test&lt;/h3&gt;
&lt;p&gt;In our test suite there’s a small validator called &lt;code&gt;Skaldborn.Simulation.Movement.Validation&lt;/code&gt;. It does this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build a fresh simulation with a known content fixture.&lt;/li&gt;
&lt;li&gt;Run a 10-command sequence against it.&lt;/li&gt;
&lt;li&gt;Hash the resulting event log. Hash the resulting world state. Record both.&lt;/li&gt;
&lt;li&gt;Build a &lt;em&gt;second&lt;/em&gt; fresh simulation with the same fixture.&lt;/li&gt;
&lt;li&gt;Run the same 10-command sequence.&lt;/li&gt;
&lt;li&gt;Assert: the two event-log hashes are byte-identical. Assert: the two final positions match.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If anything in the simulation depended on wall-clock time, system entropy, network ordering, or — and this is the load-bearing case — &lt;em&gt;AI-generated content&lt;/em&gt; sneaking back into the simulation path, the hashes would diverge and the test would fail.&lt;/p&gt;
&lt;p&gt;This is the simplest possible demonstration of the rule. Same commands → same events → same world. The simulation is a pure function of its input, and we have a test that says so.&lt;/p&gt;
&lt;h3 id=&quot;receipt-2--the-determinism-gate&quot;&gt;Receipt #2 — the determinism gate&lt;/h3&gt;
&lt;p&gt;Determinism in tests isn’t enough. Code drifts. Someone reaches for &lt;code&gt;DateTime.UtcNow&lt;/code&gt; because they need a timestamp; six months later we have a heisenbug nobody can reproduce. So we don’t trust ourselves — we run a grep gate on every push.&lt;/p&gt;
&lt;p&gt;The gate is two files. The first is the rule set, a JSON file:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;rules&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;SD001&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;pattern&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Guid.NewGuid&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;rationale&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Non-deterministic identity. Replay would produce different IDs.&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;approved_alternative&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;IDeterministicIdGenerator or SHA256 content hash&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;SD002&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;pattern&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;DateTime.Now|DateTime.UtcNow&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;rationale&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Wall-clock time breaks replay determinism.&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;approved_alternative&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Inject IClock; pass tick-derived time&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;    // ... SD003 (Random), SD004 (Task.Delay/Thread.Sleep),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;    //     SD005 (System.IO/System.Net), SD006 (Stopwatch)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second is a shell script — about thirty lines of bash — that greps every C# file under the simulation namespace against the rule set, and exits non-zero on any match. It’s wired into the pre-push git hook.&lt;/p&gt;
&lt;p&gt;The point isn’t the cleverness of the script. The point is: &lt;em&gt;the rule isn’t just documented; it’s a machine-readable file plus a small validator that runs on every push.&lt;/em&gt; If a future engineer (or a future LLM-assisted refactor) tries to slip non-determinism into simulation code, the push fails before the code lands.&lt;/p&gt;
&lt;p&gt;This is the layer that converts a written rule into a &lt;em&gt;property the codebase has&lt;/em&gt;. It’s also boring on purpose — boring is what makes governance survive contact with deadlines.&lt;/p&gt;
&lt;h3 id=&quot;receipt-3--the-type-system&quot;&gt;Receipt #3 — the type system&lt;/h3&gt;
&lt;p&gt;The strongest enforcement happens before the code compiles. Here it is, from the spec for our &lt;code&gt;HistoricalRecord&lt;/code&gt; type — what characters remember:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;csharp&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; abstract&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; record&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Provenance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; sealed&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; record&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; EventBacked&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    EventId&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SourceEventId&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;Provenance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; sealed&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; record&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; BeliefConsensus&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    EventId&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; PromotionEventId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    PropositionHash&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; PropositionHash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;Provenance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; sealed&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; record&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Legend&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    IReadOnlyList&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;EventId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;SourceEvidence&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;    LegendBasis&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Basis&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) : &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;Provenance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read it carefully. &lt;code&gt;Provenance&lt;/code&gt; is the field that records &lt;em&gt;where this memory came from&lt;/em&gt;. There are three flavors. Every flavor takes at least one &lt;code&gt;EventId&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You cannot construct a &lt;code&gt;BeliefConsensus&lt;/code&gt; without supplying a &lt;code&gt;PromotionEventId&lt;/code&gt;. The type design refuses; once the implementation lands against this contract — scheduled for the next phase — the compiler refuses. There is no shape of &lt;code&gt;Provenance&lt;/code&gt; that doesn’t trace back to canonical events.&lt;/p&gt;
&lt;p&gt;So when a character “remembers” something, that memory is, at the type level, &lt;em&gt;anchored to a real event the simulation actually emitted&lt;/em&gt;. The memory can be partial, mistaken, biased, distorted by faction or fear — those are valid memory states — but it cannot be &lt;em&gt;invented&lt;/em&gt;. The type system won’t let it.&lt;/p&gt;
&lt;p&gt;The rule is mechanically enforceable — not by prompt engineering, code review, or guidelines, but by a contract the C# compiler will refuse to compile against. The contract is canon today; the implementation is queued for the next phase. The compiler, unlike code review, is unimpressed by deadlines and unmoved by clever arguments.&lt;/p&gt;
&lt;h2 id=&quot;what-actually-happens-when-an-llm-tries-to-cheat&quot;&gt;What actually happens when an LLM tries to cheat&lt;/h2&gt;
&lt;p&gt;Walk back to the tavern. Imagine the dialogue model produces a plan claiming the keeper’s brother died at sea last winter. Here’s what happens, step by step, in our pipeline:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The plan is structured, not free-text.&lt;/strong&gt; The model’s output is a JSON object with &lt;code&gt;intent&lt;/code&gt;, &lt;code&gt;target_entity_ids&lt;/code&gt;, &lt;code&gt;referenced_facts&lt;/code&gt;, &lt;code&gt;tone&lt;/code&gt;, and a few other fields. The model can’t just emit prose. It has to commit to a specific intent and specific entity references.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Every entity reference is looked up.&lt;/strong&gt; The validator walks &lt;code&gt;target_entity_ids&lt;/code&gt; against canonical world state. The keeper’s brother doesn’t exist as an entity. The plan is rejected.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Every fact reference is looked up.&lt;/strong&gt; The validator walks &lt;code&gt;referenced_facts&lt;/code&gt; against the canonical event log. There’s no event matching “death at sea, winter, Veyr coast” anywhere in the keeper’s family causal chain. The plan is rejected.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No narrative event is emitted.&lt;/strong&gt; Because the plan failed validation, no &lt;code&gt;narrative.dialogue.response&lt;/code&gt; event is written to the authoritative event store. The rendering step never runs. The line never reaches the player.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The model never gets to “say” the false thing. The hallucination doesn’t survive contact with the canonical store. There’s no compensating action, no retcon, no save-file fixup — the false fact never made it past the boundary in the first place.&lt;/p&gt;
&lt;p&gt;The dialogue spec is explicit about a corollary:&lt;/p&gt;
&lt;aside class=&quot;callout&quot;&gt;&lt;p&gt;&lt;strong&gt;The dialogue pipeline contract is explicit:&lt;/strong&gt; “No simulation mutation may depend on the content, timing, or existence of an LLM-generated response.” Said another way: &lt;em&gt;if the LLM is permanently unavailable, the simulation continues with full correctness.&lt;/em&gt;&lt;/p&gt;&lt;/aside&gt;
&lt;p&gt;The whole game keeps working without the model. NPCs don’t stop existing, the world doesn’t pause, fights still resolve, economies still run. What goes away is &lt;em&gt;the prose&lt;/em&gt;. The translator goes offline; the world stays.&lt;/p&gt;
&lt;p&gt;“AI assists storytelling” — that’s the specific verb. Not &lt;em&gt;participates in&lt;/em&gt; storytelling. Not &lt;em&gt;co-authors&lt;/em&gt; the world. &lt;em&gt;Assists&lt;/em&gt;. When the assistance is unavailable, the thing it was assisting is unaffected. That’s the test.&lt;/p&gt;
&lt;h2 id=&quot;the-honest-status-what-ships-today-what-doesnt&quot;&gt;The honest status: what ships today, what doesn’t&lt;/h2&gt;
&lt;p&gt;Some of what we’ve described is running in production-shape on &lt;code&gt;main&lt;/code&gt; right now. Some isn’t. We want to be straight about which is which, because it’s part of the story of &lt;em&gt;how&lt;/em&gt; we got here.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;simulation core&lt;/strong&gt; — world state, event envelopes with deterministic identity, the tick loop, the manifest loader — runs today. Test fixtures replay deterministically. The Movement Validator hashes match.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;gateway&lt;/strong&gt; and the &lt;strong&gt;browser client&lt;/strong&gt; — the boundary that enforces “client never writes state, server never trusts client” — run today. The reflection-based event-routing they use is a recent rewrite (more on that in a moment).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;content pipeline&lt;/strong&gt; — the build-time tooling that produces the world’s content manifest — runs today.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;narrative service&lt;/strong&gt;, the &lt;strong&gt;dialogue pipeline&lt;/strong&gt;, and the &lt;strong&gt;NPC memory pipeline&lt;/strong&gt; — &lt;em&gt;do not run on &lt;code&gt;main&lt;/code&gt; today&lt;/em&gt;. The contracts are frozen, the type designs are accepted, but the implementation isn’t there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Earlier this year we had all three of those services implemented. They worked end-to-end. Player typed, NPC replied, memory got recorded, the chronicle service rendered prose. It looked like a finished thing.&lt;/p&gt;
&lt;p&gt;But in February we ran an extensibility test against the implementation. The test was simple: &lt;em&gt;can a content author add a new kind of memory, or a new kind of narrative template, without modifying engine code?&lt;/em&gt; The answer was no. The implementation had hardcoded enums for fourteen narrative-template kinds and five memory variants. Every dispatch path was a &lt;code&gt;switch&lt;/code&gt; over those enums. Adding a new kind meant editing four files in the engine and shipping a new build.&lt;/p&gt;
&lt;p&gt;We had a rule — “content extensibility shouldn’t require engine edits” — and the implementation didn’t satisfy it. Two paths to recover:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Soften the rule.&lt;/li&gt;
&lt;li&gt;Delete the implementation.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We deleted the implementation. The contracts survived; the code didn’t. Several thousand lines of working code, and we put it in an archive branch and started over.&lt;/p&gt;
&lt;p&gt;The new implementation will use a registry pattern (attribute-driven discovery, no closed enums) so adding a memory variant or a narrative template is a content-only change. It’s queued for a later development phase. In the meantime, &lt;code&gt;main&lt;/code&gt; ships with the simulation core, the gateway, the client, and the content pipeline — the parts that &lt;em&gt;own truth&lt;/em&gt;. The parts that &lt;em&gt;interpret truth&lt;/em&gt; are deliberately offline until we can rebuild them under the rules they were supposed to obey.&lt;/p&gt;
&lt;p&gt;This isn’t a recovery story we’re embarrassed about. It’s the story we wanted to tell. The whole point of the architectural frame in this post — &lt;em&gt;simulation owns reality&lt;/em&gt; — is that the rule is more important than the implementation. We caught an implementation that didn’t deserve the rule, and we fixed the implementation, not the rule.&lt;/p&gt;
&lt;h2 id=&quot;a-new-layer-of-enforcement-just-shipped&quot;&gt;A new layer of enforcement (just shipped)&lt;/h2&gt;
&lt;p&gt;The most recent enforcement layer landed earlier this week.&lt;/p&gt;
&lt;p&gt;The pattern we wanted to prevent looks like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;typescript&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// In the browser client. Hypothetical. Don&amp;#39;t do this.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; playerEntityId&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &amp;quot;player-001&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// hardcoded literal&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;moveCommand&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;({ entity: playerEntityId, x: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;4&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, y: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;7&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; });&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That code looks innocent. It’s also wrong. The browser client doesn’t get to decide what the player’s entity ID is; the server owns identity. If the server’s notion of identity ever changes shape (hashed IDs, longer IDs, namespaced IDs), the client’s hardcoded literal is now lying to the simulation.&lt;/p&gt;
&lt;p&gt;The fix is a contract called &lt;em&gt;authority bindings&lt;/em&gt;, ratified earlier this week and running on every push today as a contract-plus-validator pair. Every component contract in the repo now has to declare, machine-readably, which fields it consumes from where:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  &amp;quot;authority_bindings&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;binding_id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;player-entity-id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;consumed_concept&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;player-entity-id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;authority_source&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;kind&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;seam_payload&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;seam_id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;session-authenticated-reply&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;field&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;character_entity_id&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;binding_site&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;expected_in&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;src/skaldborn-client/src/network/auth-handler.ts&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        &amp;quot;fingerprint&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;this._state.playerEntityId = msg.character_entity_id&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;forbidden_literals&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;player&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;player-001&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;\&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;      &amp;quot;rationale&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&amp;quot;Player entity ID is server-authoritative; the client must read it from the authentication reply, not invent it.&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s a grep validator that runs on every push. It checks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the file at &lt;code&gt;expected_in&lt;/code&gt; actually exist?&lt;/li&gt;
&lt;li&gt;Does it contain the &lt;code&gt;fingerprint&lt;/code&gt; substring?&lt;/li&gt;
&lt;li&gt;Does the codebase contain any of the &lt;code&gt;forbidden_literals&lt;/code&gt; outside test fixtures?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of those checks fails, the push fails. Static analyzers (Roslyn for C#, ESLint for TypeScript) are queued to take the same checks deeper, into expression analysis.&lt;/p&gt;
&lt;p&gt;This contract is what we built to prevent a specific class of bug from being possible to ship. A bug landed a few weeks ago where the client was holding onto a literal placeholder ID when the server authentication reply changed shape. The simulation kept running correctly. The client kept showing pixels. But every move command from the client was silently dropped because the entity ID didn’t match anything the simulation knew about. &lt;em&gt;The authority of the simulation was preserved&lt;/em&gt; — that’s why nothing crashed — but the client’s lie was invisible until a player tried to walk and nothing happened.&lt;/p&gt;
&lt;p&gt;The authority-bindings contract is the rule that says: every authoritative ID in a component has &lt;em&gt;one&lt;/em&gt; source, declared, in writing, with a fingerprint check. You can’t hardcode it locally. You can’t keep a backup copy “just in case.” There is one root, one binding, one source-of-truth.&lt;/p&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;Some of this is familiar shape: event log, projections, single-writer per partition, deterministic replay. We didn’t invent it. What’s unusual is how tight the rule runs — &lt;em&gt;all the way through the AI&lt;/em&gt; — and the boring infrastructure to keep it tight under deadline pressure. The non-obvious bit is the LLM-offline-correctness invariant. A lot of “AI in games” architectures are honest event-sourced underneath but have an asterisk: &lt;em&gt;except for the AI bits&lt;/em&gt;. No asterisk here. The simulation is the simulation, with or without the model. The model is a translator. Translators are nice to have. They are not load-bearing.&lt;/p&gt;
&lt;p&gt;This is the first post in the launch arc. Up next, two paired companions on the early-2026 rebuild: &lt;a href=&quot;https://www.skaldborn.com/devlog/03-aspirational-vs-mechanical/&quot;&gt;&lt;em&gt;Aspirational vs Mechanical&lt;/em&gt;&lt;/a&gt; (story) and &lt;a href=&quot;https://www.skaldborn.com/devlog/02-why-we-deleted-two-months/&quot;&gt;&lt;em&gt;Why we deleted two months of working code&lt;/em&gt;&lt;/a&gt; (engineering). After those, the launch arc continues with deterministic event sourcing at the partition level, then a tour of the sixteen simulation systems, with a shorter post on the manifest model in between.&lt;/p&gt;
&lt;p&gt;If you want to follow along, subscribe via the form at the bottom of any page — one short email when the next post lands. If you want to argue, write to &lt;a href=&quot;mailto:devlog@skaldborn.com&quot;&gt;devlog@skaldborn.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Everything else is the boring engineering of making it true.&lt;/p&gt;
&lt;section data-footnotes class=&quot;footnotes&quot;&gt;&lt;h2 class=&quot;sr-only&quot; id=&quot;footnote-label&quot;&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li id=&quot;user-content-fn-berkowitz&quot;&gt;
&lt;p&gt;Reed Berkowitz (“Rabbit Rabbit”), &lt;em&gt;AI Powered NPCs — Hype, or Hallucination?&lt;/em&gt; Curiouser Institute on Medium, December 9, 2023. &lt;a href=&quot;https://medium.com/curiouserinstitute/ai-powered-npcs-hype-or-hallucination-11ddfc530e33&quot;&gt;medium.com/curiouserinstitute/ai-powered-npcs-hype-or-hallucination-11ddfc530e33&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-berkowitz&quot; data-footnote-backref aria-label=&quot;Back to reference 1&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-berkowitz-2&quot; data-footnote-backref aria-label=&quot;Back to reference 1-2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;sup&gt;2&lt;/sup&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-gao&quot;&gt;
&lt;p&gt;Yuan Gao (Meseta), &lt;em&gt;I attempt to play a coherent story in AI Dungeon: Attempt 1, a noire-future-fantasy/mystery.&lt;/em&gt; Medium, December 6, 2020. &lt;a href=&quot;https://meseta.medium.com/i-attempt-to-play-a-coherent-story-in-ai-dungeon-attempt-1-a-noire-future-fantasy-mystery-ed6b91a59541&quot;&gt;meseta.medium.com&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-gao&quot; data-footnote-backref aria-label=&quot;Back to reference 2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-aidungeon-faq&quot;&gt;
&lt;p&gt;AI Dungeon Help Center, &lt;em&gt;Why does the AI forget or mix things up?&lt;/em&gt; &lt;a href=&quot;https://help.aidungeon.com/faq/why-does-the-ai-forget-or-mix-things-up&quot;&gt;help.aidungeon.com&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-aidungeon-faq&quot; data-footnote-backref aria-label=&quot;Back to reference 3&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-pantella&quot;&gt;
&lt;p&gt;&lt;em&gt;Pantella&lt;/em&gt; (Pathos14489/Pantella, GitHub) — fork of Mantella with a ChromaDB-based memory replacement for the original’s summary-based memory. README quote verbatim from the ChromaDB Memory Manager section. &lt;a href=&quot;https://github.com/Pathos14489/Pantella&quot;&gt;github.com/Pathos14489/Pantella&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-pantella&quot; data-footnote-backref aria-label=&quot;Back to reference 4&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-replika&quot;&gt;
&lt;p&gt;RoboRhythms, &lt;em&gt;Why Replika Memory Suddenly Stops Working for So Many Users.&lt;/em&gt; &lt;a href=&quot;https://www.roborhythms.com/replika-memory-broken-fix/&quot;&gt;roborhythms.com/replika-memory-broken-fix&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-replika&quot; data-footnote-backref aria-label=&quot;Back to reference 5&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-charai&quot;&gt;
&lt;p&gt;Lark Birdy, &lt;em&gt;Negative Feedback on LLM-Powered Storytelling &amp;amp; Roleplay Apps,&lt;/em&gt; Cuckoo Network blog, April 17, 2025. Quote verbatim from the “Technical Limitations in Storytelling Bots — Context/Memory Limits” section. &lt;a href=&quot;https://cuckoo.network/blog/2025/04/17/negative-feedback-on-llm-powered-storytelling-and-roleplay-apps&quot;&gt;cuckoo.network&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-charai&quot; data-footnote-backref aria-label=&quot;Back to reference 6&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-arxiv&quot;&gt;
&lt;p&gt;Ziwei Xu, Sanjay Jain, Mohan Kankanhalli, &lt;em&gt;Hallucination is Inevitable: An Innate Limitation of Large Language Models,&lt;/em&gt; arXiv:2401.11817 (submitted January 22, 2024; revised February 13, 2025). &lt;a href=&quot;https://arxiv.org/abs/2401.11817&quot;&gt;arxiv.org/abs/2401.11817&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-arxiv&quot; data-footnote-backref aria-label=&quot;Back to reference 7&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item></channel></rss>