# Matej Murn — Portfolio (full content) > Software mechanic based in Slovenia with 15+ years of full-stack experience building web platforms, e-commerce systems, and AI-powered tools. --- # What survives a port is the asset URL: https://murn.eu/blog/port-test Date: 2026-07-04 Tags: engineering, philosophy The weather API exists twice in the same repository. The original is TypeScript on NestJS; under `java/` sits a Spring Boot port — same version number, mirrored package tree, tests and all — serving the same hand-written `openapi.yaml`. Duplicating a service across stacks is supposed to be waste. I'd call it a measurement. ## Two trees, one contract The port mirrors the original down to its seams: `api`, `common/http`, `config`, `domain`, `service`, `upstream/arso`, `web`, with end-to-end tests that mock the upstream the way the TypeScript suite does — WireMock standing where nock stood. What the Java tree does not contain is a second contract or a second set of decisions. Both implementations serve the one committed `openapi.yaml` at the repo root, and both answer to the same decision records beside it. Porting turned out to mean re-projecting those fixed points onto a second runtime — not re-deriving them, because there was nothing left to derive. The deciding had already happened somewhere the TypeScript couldn't keep it. ## What transferred and what didn't Sort the repo's artifacts by what the port did to them. Transferred untouched: the contract, the decision records, and the shape of the test suite — which behaviors get verified, which upstream failures get simulated. Rewritten entirely: every line of application code. That sorting is the insight. Code is the projection of decisions onto one runtime, so changing the runtime rewrites the projection and leaves the decisions standing. What a port preserves is exactly what was never stack-shaped to begin with. I won't tell you the port was cheap, because I didn't measure it in hours. What I can enumerate is what it did not require: no new contract, no new decisions, no new ideas about what to test. Everything expensive had already been paid for in stack-independent currency. ## The measurement I argued in [Disposable by default](/blog/disposable) that most code an agent produces is meant to be thrown away, and the pushback is always the same: surely the codebase is the asset. The port is the experiment that answers it. Call it the port test: port your system to a second stack — on paper, if not in code — and watch what survives. Whatever transfers unchanged is the asset. Whatever must be rewritten was scaffolding for that asset, however much it cost to write. A codebase that would survive a port only as ideas — contract, decisions, test intent — was always just those ideas wearing one syntax. The test only measures systems that have artifacts capable of surviving. A service whose behavior lives nowhere but its code has nothing to port except the code, and the test's verdict on it is not kind — though it is useful, because it tells you what to write down next. The port crossed a runtime boundary and the assets held. There's a boundary I don't think they'd cross: change what the API is about — not the stack under it, the domain in front of it — and even the contract burns; the decisions dissolve into context for a problem that no longer exists. Something presumably carries an engineer across that boundary too. I haven't figured out what to call it, or whether it can live in a repository at all. --- # Staging should run NODE_ENV=production URL: https://murn.eu/blog/two-envs Date: 2026-06-30 Tags: engineering `NODE_ENV=staging` looks obviously right and is quietly wrong. The variable was never a deployment-tier label — it's a semantics switch that other people's code branches on. Set it to anything but `production` and the frameworks underneath you switch off the exact behaviors staging exists to rehearse. ## The variable libraries read In the weather API's env schema, `NODE_ENV` validates against exactly three values — `development`, `production`, `test` — because those are the values the ecosystem actually compares against. Express, Fastify, Nest, half of npm: they check `=== 'production'` and change caching, error verbosity, logging, and optimization paths on the answer. Set `staging` and every one of those checks reads it as "not production." Staging bugs escape to production because staging silently ran different library code paths — the rehearsal happened on a stage with different scenery bolted to the floor. ## The second axis The project's answer is two variables, because there are two independent questions. `NODE_ENV` answers "how should code behave" and stays inside the vocabulary libraries understand. A separate `APP_ENV` — `development`, `staging`, `production` — answers "which tier is this deploy." Staging runs `NODE_ENV=production` with `APP_ENV=staging`: the runtime behaves exactly as it will in production, while the deployment still knows its own name. Both variables are enum-validated at boot, so a typo dies at startup instead of living on as a silent "not production." One variable felt like enough right up until the repo grew `docker-compose.staging.yml` next to `docker-compose.production.yml`. At that point, staging that lies to its libraries stopped being a hypothetical and became a file I could open — the second axis appeared the moment there was a second tier to be honest about. ## What the tier axis buys The tier variable does visible work. Swagger UI is mounted only when `APP_ENV` isn't `production` — interactive docs available to poke at in development and staging, hidden where the public lives. The same repo's one-image rule leans on the split too: build a single environment-agnostic image and promote the identical artifact across tiers, which only works if the tier is runtime input rather than something baked into the build. Neither decision is expressible when the only variable you have was already spent on semantics. The Monday check is one line: look at what your staging currently exports as `NODE_ENV`. If it says `staging`, your dress rehearsal has been running with the understudy. The mechanism here is Node-specific — Rails and Spring carved this boundary differently and earlier — and my scar tissue is all from Node, so that's where I'll bound the claim. Two axes covered this project completely. What I don't know is how many axes a real fleet needs — region, tenant, compliance regime — before per-variable axes collapse into a config service, and whether that collapse counts as the scheme failing or just growing up. --- # Every knob is a promise URL: https://murn.eu/blog/knob-debt Date: 2026-06-27 Tags: engineering The HTTP retry policy in the weather API is three hardcoded constants: a ten-second timeout, three retries, and a 300-millisecond base delay that doubles into a 300–600–1200 backoff ladder. Twelve-factor instinct says those belong in the environment. The decision record says no, and I've come around to its reasoning: configuration is an interface, and every knob you expose is a promise that all of its positions work. ## Three constants `HTTP_TIMEOUT_MS = 10_000`, `HTTP_RETRY_COUNT = 3`, `HTTP_RETRY_BASE_DELAY_MS = 300` — compile-time constants in one small file. The decision record prices the choice without flinching: changing the policy "requires a code change and redeploy rather than a config flip — an accepted trade-off until per-tier tuning is actually needed." The phrase doing the work is *actually needed*. An env var for the retry count would cost nothing today and quietly start charging later, because every value nobody sets is a combination nobody tests. A service that has only ever run with three retries does not, in any sense that matters, support five — the knob just makes the claim. Unused configurability rots because testing tracks the configurations that exist, not the ones the schema permits. The gap that accumulates between what the knobs claim and what the deploys have proven is what I'd call knob debt. ## The knob that earned it The same service's upstream feed address, `ARSO_BASE_URL`, went the other way: it's an environment variable, required and validated at boot. The difference isn't importance — the retry policy matters just as much to the service's behavior. The difference is a demonstrated second consumer. The e2e suite has to point the service at a fake feed, `http://arso.test/feed.xml`, so two configurations of that variable genuinely exist and both genuinely run, every time the tests do. Watching that variable earn its way into the env schema is what named the rule for me. The knob wasn't designed in; it was promoted — by a consumer that actually needed the second value, on the day it needed it. Before that day, a hardcoded URL would have been exactly as correct. ## Constants first, promoted on demand The Monday audit: for each variable in your env schema, name who sets it to something non-default. A tier? A test suite? An operator who has actually turned it? If the answer is no one, fold it back into a constant — you lose nothing but the claim, and the claim was rotting anyway. Promote it again the day a real second consumer shows up, and write down who that consumer is, because that name is the justification the next reader will want. One bound on all of this: the weather API is a single-instance service run by the person who wrote it. A fleet with an operations team prices knobs differently — when the people turning the dials can't redeploy, a config flip is worth real money, and a half-tested promise may still be worth keeping. The rule I trust is narrower than the slogan: constants first, promotion on demonstrated demand. What I keep getting wrong is the prediction. Values I was sure would need tuning never did; the base URL I'd have happily hardcoded was the one the tests demanded. The rule tells me when to promote a constant. It still doesn't tell me which ones I'll end up promoting. --- # Refresh on the publisher's clock URL: https://murn.eu/blog/upstream-clock Date: 2026-06-23 Tags: engineering The weather API's cache refresh runs every hour at minute 27. Not on the hour — at `:27`. The number looks arbitrary and is the least arbitrary thing in the file. ## Minute 27 The upstream is ARSO's observation feed, which the project's decision record describes as publishing on a roughly 25-minute cycle. The cron — `27 * * * *` — sits just past that beat, with the record naming the offset as a safety margin over the publish cycle. Schedule the same job on the round hour instead and you refresh at whatever point in the upstream's cycle the hour happens to land — worst case, moments before fresh data appears, after which you serve the previous reading for another full hour. Round numbers maximize staleness because freshness is relative to when the publisher publishes, not to when your clock looks tidy. The refresh runs on the upstream clock, not ours. There's a quieter rule in the same service: the refresh job catches its own failures, logs them, and keeps the last good data instead of throwing. A cache warmed on the publisher's rhythm can afford to miss a beat. ## The TTL that drifted The cache lives for an hour in code — `60 * 60 * 1000` — a small multiple of the publish cycle rather than a number picked for niceness. A TTL detached from the cycle fails in one of two directions: too short and you refetch data that cannot have changed yet; too long and you re-serve readings the publisher has already replaced. And here's the honest wrinkle: the decision record still says ninety minutes. The window changed at some point and the code moved while the record didn't — I only found the disagreement rereading both for this post. The mismatch doesn't weaken the scheduling logic, but it's a live specimen of the thing I keep writing about: two homes for one number, drifting. The Monday version: for one thing you cache today, find out when its upstream actually publishes, then look at your refresh schedule next to that number. If you can't find out when it publishes — that's worth knowing too. All of this assumes the upstream has a rhythm at all; event-driven sources make the point moot. And a publisher can change its beat without telling anyone. The cron will keep firing at :27 out of loyalty to a cycle that may no longer exist — and I don't yet have a way for the schedule to notice. --- # Validate 86 fields to serve 6 URL: https://murn.eu/blog/boundary-tripwire Date: 2026-06-20 Tags: engineering The weather API returns six fields per station. The validator at its upstream boundary checks eighty-six. By the validate-what-you-use school, eighty of those checks are waste. They're the point. ## The shape of the feed The upstream is ARSO's XML feed of station observations. Before any of it reaches the application, the payload passes an XML well-formedness gate and then a 245-line validation DTO that checks the feed's full published shape — identity fields present, coordinates numeric-shaped, the observation array non-empty. Eighty-six fields verified, for an API that will serve six of them. Anything that fails surfaces as exactly one thing: a 502 at the edge. The 502 is the mechanism. Inside the boundary, data is presumed sane — that presumption is what lets the rest of the code stay simple. So when garbage does get in, it fails far from its cause, wearing your code's fingerprints: an `undefined` three layers down looks like your bug, not their drift, and you'll debug it as yours. Strict validation at the edge converts that entire class of failure into one loud error at the point where the cause actually lives, labeled as what it is — a bad gateway, not a bad service. ## The eighty fields you ignore Why check fields the API never returns? Because shape drift rarely starts where you're looking. An upstream that renames, retypes, or empties a field you ignore is telling you something about its publishing pipeline — and the fields you depend on ride the same pipeline. The wide check is a tripwire strung across the whole boundary: it fires on the earliest sign of movement instead of the last one. That's the trade I'd name the boundary tripwire — you accept alarms about fields you don't care about, in exchange for hearing the floorboards creak before the beam goes. ## Bought, not adopted This rule wasn't imported from a best-practices list. The commit that hardened the boundary — "Fix Arso mapper for teperature field, tighten validation in controller query and response, add exception handler, and request retryer" — runs to nearly two thousand changed lines across thirty-three files, and it exists because a mapper bug in the temperature field got through first. The wide validation arrived as the response to a narrow failure. I'd have called an 86-field DTO overengineering if I'd seen it on day one; it reads differently as a scar. The Monday check: pick one upstream response type in your codebase and count what fraction of its documented shape you actually verify. You don't have to close the gap; just knowing the number changes how you'll read your next "weird data" bug. The strictness has an edge I can bound but not prove. An upstream *adding* an eighty-seventh field should never break you — the tripwire covers the shape you enumerated, it is not a ban on growth — and exactly where that line sits is judgment, not doctrine. Too strict and you page on their every release; too loose and you're back to `undefined` three layers down. This API's line sits where the temperature bug taught me to put it, which means I've already moved it once. I expect to move it again; I just can't tell you toward which side. --- # Missing is not zero URL: https://murn.eu/blog/typed-absence Date: 2026-06-16 Tags: engineering There's a station in Slovenia's national weather feed — Vojsko — that reports no temperature. Not zero degrees; no reading at all. Every clean-API instinct I've absorbed says to smooth that over: drop the key, or default the number. Both moves manufacture a bug and hand it to someone else. ## Zero is a measurement In a Slovenian winter, 0 °C is an ordinary reading. A consumer that receives `temperature: 0` has to be able to act on it — and it can't, if 0 is also the encoding for "the station didn't say." Coercing absence to a default creates the bug because it maps two different facts onto one value, and only the producer ever knew which fact it was. Dropping the key is the same trick in different clothes: now an absent field means either "not measured" or "you typo'd the name," and the consumer is back to guessing about facts the boundary threw away. I didn't get this right the first time. The commit log carries the correction — a fix to the mapper for the temperature field specifically — from before the contract question had been thought through. The station was already out there not reporting; the code just hadn't decided what that meant yet. ## Required, nullable, converted at the edge The contract the API settled on keeps `temperature` and `humidity` in every station object — the key is always present — and allows the value to be `null`. Null means exactly one thing: the upstream had no reading. The key stays required because presence is the contract's promise that the consumer never has to distinguish a missing key from a missing value. The boundary enforces the meaning: a `toNullableNumber()` helper converts the feed's empty and whitespace values to `null`, never to `0`. That's the whole pattern — typed absence: "not measured" carried as a first-class value instead of a hole the consumer falls into. The decision record even keeps its own embarrassment on file. Expressing "number or null" cleanly needed newer OpenAPI syntax than the spec file declares, and reconciling that is explicitly deferred. Contract correctness beat spec purity, and the mismatch is written down rather than hidden. The Monday version: grep your serializers for `?? 0` and `|| 0` on measured quantities. Every hit is a place where a consumer will someday act on a measurement that never happened. What `null` doesn't carry is *why* the reading is missing — a sensor that's down looks identical to a sensor that was never installed. One kind of absence has been enough for this API so far. I suspect that the day it isn't, the fix won't be a second kind of null; I just don't know yet what it will be. --- # Generated specs drift away from review URL: https://murn.eu/blog/drift-direction Date: 2026-06-13 Tags: engineering "Generate the API docs from the code — then they can never lie." It's the most settled advice in the room, and in NestJS it's a decorator import away. On a small weather API I refused it on purpose, and the reason wasn't taste: generation doesn't remove drift between code and contract. It decides which of the two drifts unreviewed. ## The file everyone had read The project's brief defined the endpoint contract "in openapi.yaml" — a file that, at first, didn't exist in the repo. I proposed one reverse-engineered from the upstream's live response; the client sent the canonical version; they matched, and the build proceeded against that file. (The fuller story of that exchange is [its own post](/blog/executable-questions).) What matters here is the sequence: by the time any application code existed, the yaml had been read and accepted by everyone involved. That reading is the whole source of the file's power. The spec could bind both sides because both sides had reviewed it. A contract's authority comes from the review, not from the file format — and anything that quietly changes a reviewed artifact is spending authority it doesn't have. ## A decorator away from a second spec NestJS convention says: sprinkle `@Api*` decorators over the controllers and DTOs and let the framework emit the spec. The project's decision record refuses in one sentence — scattering decorators "would duplicate that contract across the code and create a second spec that can drift from the file everyone reviews." A generated spec drifts silently because regeneration happens downstream of review: at build time, from whatever the code now says. Change a DTO constraint and the published contract changes with it. No reviewer signed the new promise, nothing forces anyone to notice, and the artifact with all the authority has become the one nobody is watching. ## Aiming the drift The shipped setup serves the committed `openapi.yaml` directly — static mode, no generation — with a CI step that validates the file. The cost is written into the same decision record: change a validation rule in a DTO and you must mirror it in the yaml by hand. That cost is the mechanism, not an unfortunate side effect. Code and contract are edited by different processes, so they will disagree eventually; the only real decision is which one chases the other. Keep the spec hand-maintained and the code chases the contract — a mismatch surfaces as a failing check or a wrong response that someone can condemn by pointing at the approved file. Generate the spec and the contract chases the code, silently. Same drift, opposite direction, and only one direction is catchable. I wrote in [Rule drift](/blog/rule-drift) that every rule needs one canonical home; the sharper version I'd write now is that when the candidates for home are the code and the thing a stakeholder approved, the approved thing wins. The Monday check: pick one API you own and ask which artifact a stakeholder actually signed off on. Then ask which artifact your tooling treats as the source of truth. If those are different files, you've found the direction your drift runs. None of this applies to a service whose spec nobody reads — generate away; an unreviewed contract has no authority to protect. And the setup has a hole I want to name rather than hide: the CI validates that the yaml is well-formed, not that it still tells the truth about the code. Structural drift is gated; semantic drift is still caught by me, on a manual read, or not at all. I know which direction the drift runs now. I still don't have a machine that notices when it arrives. --- # Undocumented decisions get made twice URL: https://murn.eu/blog/relitigation Date: 2026-06-09 Tags: engineering Nineteen architecture decision records for a codebase of twenty source files, written by one person in a build measured in days, looks like ceremony worship. The usual advice runs the other way: ADRs are for big teams and big bets, and a solo project should just write good code. That advice assumes the reader of the record is a future teammate. On this project the reader was an agent session that woke up every morning with no memory. ## The migration that happened twice Early in the project I moved the test files out of `src/` into a top-level `test/` folder and excluded them from the Docker build — it's in the log, commit message and all. Days later, the log shows the same migration again: two commits, both titled "Moved tests to test folder." The layout had drifted back in between, and I was moving the same files a second time. It drifted back because the first move was never recorded as a decision. To the next agent session, an unwritten decision is indistinguishable from an accident of history — the file layout is just how things happen to be, and "how things happen to be" is exactly what a model feels free to improve. Colocated tests are a perfectly defensible default, so the model chose it, fresh, the way it would for any repo that had no written opinion on the matter. ## Session amnesia An agent session starts from zero. Whatever I decided yesterday exists for the model only if it lives in the files the session loads — the instructions file, the code, the docs. Everything else, the conversation where we settled it and the reasoning I hold in my head, is gone by the next session. A decision that lives only in my head isn't a decision to the agent; it's a preference I have to keep re-asserting, and re-litigation is what it looks like on the days I forget to. That's what nineteen records over twenty source files actually is: not ceremony, a memory prosthesis. After the test layout finally got its record, the migration stayed put — not because the model got smarter, but because the decision had become something a session could load. ## Three layers for one rule The strongest decision in the repo is enforced three times. The rule says controllers read their query parameters through a dedicated DTO, never as raw `@Query('name')` primitives. Its text lives in the decision record. A one-line restatement sits in the instructions file every agent session loads. And an ESLint `no-restricted-syntax` gate on `**/*.controller.ts` fails the build if anyone — me or the model — writes the banned form anyway. Each layer catches a different reader. The record persuades a human who wants the why; the instructions line reaches every session cheaply; the lint gate catches us both on the days we read neither. The rule held not because anyone remembered it, but because forgetting it stopped compiling. I argued in [Rule drift](/blog/rule-drift) that a rule should live in one canonical home precisely so copies can't disagree, and this arrangement looks like a violation of my own rule. The defense I'd offer: the record holds the rule's text, and the other two layers hold a pointer and a tripwire — enforcement is not a copy. But I hold that distinction by hand, and hand-held distinctions drift; that's the part of this setup I trust least. The Monday version: find the decision you've re-explained to your agent twice — that's the tell — and give it the three layers: a recorded why, a line in the file every session loads, and a lint rule if it's mechanically checkable. This is agent-specific advice; human teams re-litigate too, but not because their memory resets overnight. The first test-folder move felt too small to record, and that's the part I keep chewing on. The decisions that get re-made are precisely the ones that felt too small to write down — which means the natural filter, record the big ones, selects for the wrong set. I don't yet have a rule for which small decisions deserve their tripwire before they've been violated once. --- # The AI plan is for keeping, not following URL: https://murn.eu/blog/control-plan Date: 2026-06-06 Tags: engineering, philosophy Plan-then-execute is the respectable way to work with a coding agent: have it draft the plan, refine it together, then hold the build to it. On a recent client project — a small weather API — I did something stranger. I had the model generate its complete project plan, committed the file, and added a standing rule to the agent's instructions: never follow it. ## Two builds that demo the same The finished API serves one endpoint, `GET /v1/current`, and it works. Here is the uncomfortable part: a vibe-coded version of it would also work. Same route, same JSON, same happy-path demo. The demo cannot tell the two apart because a demo shows outcomes, and engineering judgment is a property of the decisions behind the outcome — which options were seen, which were refused, and why. Both versions return the same response; only one of them knows why its cache lasts an hour. That gap matters most exactly when the work is being evaluated by people who never watched it happen. A client sees the artifact, not the reasoning. If the reasoning is the thing you're actually selling, it needs somewhere to live. ## The rule in the harness The repo carries a file called `PROJECT_PLAN.md` under `.claude/` — the model's own full plan for the project, generated at the start and never edited since. Next to it, in the instructions every agent session loads, sits this rule: "During development NEVER FOLLOW if not explicitly said to do .claude/PROJECT_PLAN.md guidelines." The README states why the file exists at all: to be able to "demonstrate deviation of AI-vibe coded solution and AI-engineered solution approach." The plan is the control group. It records what the project would have been if I had accepted the model's defaults — and deviation can only be demonstrated against a preserved default. Delete it and the judgment turns invisible again; every choice in the delivered system looks like the only choice there was. I used to treat generated plans as scaffolding and delete them the moment real work started. Keeping this one unedited changed what the repository can prove. ## A ledger of approvals The setup has a second half. The same instructions tell the agent that whenever I approve a generated plan, it saves that plan into `.claude/plans/`, where they accumulate in order. So the repo ends up holding both ends of every decision: the frozen default the model would have picked on its own, and the sequence of plans I actually said yes to. Judgment becomes reviewable because the baseline and the approvals are both on disk — a reviewer doesn't have to take my word that thinking happened; the two artifacts bracket it. The portable move: at the start of your next agent-assisted project, ask the model for its complete plan and commit the file unedited. Then work however you work. Where you deviate, the deviation is the demonstration — and where you don't deviate, that's worth knowing too. If you don't build with agents, there's no default to preserve and this whole move has nothing to grip; it is advice for exactly one kind of workflow. There's an assumption underneath that I can't verify from inside the repo: that someone eventually reads both files. The demonstration works on a reviewer who diffs the default against the delivery, and I don't know what fraction of reviewers ever open `.claude/` at all. Keeping the plan costs nothing, which is why I'll keep doing it. What I can't yet tell is whether I'm preserving evidence or just filing it. --- # Disposable by default URL: https://murn.eu/blog/disposable Date: 2026-06-02 Tags: engineering, llm Andrej Karpathy's line is that vibe coding raises the floor and agentic engineering raises the ceiling. Two recent pieces run with it — one from MindStudio walking through the framework, one from Fan Wu on Design Bootcamp mapping it onto product work — and both tell it as a story about people. Amateurs vibe their way to a working demo; professionals orchestrate agents and check the output; the arc from one to the other is a ladder you climb. The metaphor is clean, and I think it points at the wrong variable. What decides which mode you should be in isn't who you are. It's whether the code you're about to generate has to survive. ## The ladder that isn't one Fan Wu's piece stacks the work in three layers: strategic product thinking on top, agentic systems in the middle, vibe practice at the bottom — the why, the what, the how. It reads as a climb, bottom to top. Karpathy's floor-and-ceiling image does the same work: the floor is where beginners stand, the ceiling is what experts reach for. Both describe a person moving up and staying up. When these two pieces first crossed my feed I read them the same way. What changed my mind was a smaller observation: the same engineer drops from the ceiling to the floor and back inside a single afternoon. The ladder framing breaks because the mode you pick changes far faster than your skill does. Skill only moves one direction, and slowly — you are not less experienced after lunch than before it. But the choice between vibing something out and verifying it line by line flips a dozen times a day, which means it tracks something that changes minute to minute. Skill isn't it. ## What actually changes is the artifact The MindStudio piece points to Peter Steinberger running dozens of agents in parallel and checking every output before it lands. The easy reading is that this is just what professionals do — verification as a mark of seniority. But look at what he's actually checking: outputs headed into a codebase that teammates, and his own later self, will build on top of. He isn't verifying because he's senior. He's verifying because the output crosses a line where someone downstream has to trust it without re-deriving it. Verification is only worth its cost once an output has to be trusted by a second reader, because that is the only time being wrong is expensive. Below that line, a wrong output costs you the thirty seconds it takes to throw it away. Above it, a wrong output costs whoever inherits it — and they inherit it without the context that would let them catch the error cheaply. Call the line the survival boundary. Under it sit the disposable things: the throwaway script, the one-off data transform, the prototype you demo once and close. Over it, the output has a second reader — a teammate, production, the next agent that loads your code as context. Verification is the toll for crossing. ## Why the label hides the real skill Karpathy's own late-2025 note is that the models got reliable enough that he mostly stopped correcting them. In nearly the same breath comes the example everyone repeats: a frontier model that refactors a sprawling codebase cleanly and then, asked something about the physical world, recommends walking to a car wash. The capability is spiky. Reliability is high on average and unpredictable in any single spot, so the decision to verify can't be set once and worn like a title — it has to be made fresh against this output and whatever depends on it. Treating the mode as identity fails in two directions, because the label answers "who am I" when the artifact is asking "will anyone trust this later." The engineer who has decided he's an agentic engineer now reviews a five-line script he'll run twice and delete, paying a toll at a boundary he never crossed. The engineer who has decided vibe coding is fine ships the demo that quietly becomes the backend. Both got the question wrong from opposite ends, and in each case the miss wasn't about skill — it was a misread of how long the thing they made was going to live. ## The question to ask instead Fan Wu assigns each model a role — ChatGPT as thinking partner, Claude as product partner, Gemini as the executor — and frames it as how he orchestrates. MindStudio leans the other way, folding database, auth, and payments behind the agent so the orchestration disappears, while a tool like Remy generates a full app from an annotated markdown spec. Between them, the floor and the ceiling stop being separate rooms. When the platform handles the plumbing and the model writes the app, "what kind of engineer am I" gets harder to even answer. The one question that survives the tooling is whether the thing you just generated has to survive too. So ask it directly, per artifact, before you accept or check an output: who is the second reader? If you can't name one — no teammate, no production path, no future agent that will read this as context — the output is disposable, and verifying it is wasted motion. If you can name one, it crossed the boundary, and you owe it the check. The test is fast enough to run in your head on every generation, which is the whole point: the check has to cost less than what it's checking, or it isn't worth running. This only bites if you've watched a prototype get promoted to production without a rewrite; if you haven't yet, the tell is the demo nobody on the team is willing to delete. The vibe-versus-agentic question gets sold as a fork in your career — pick a side, grow up, become the engineer who verifies. It's smaller and more constant than that: a call you make dozens of times a day about a single artifact, answered "disposable" most of the time and "this one has to survive" the rest. The case I haven't worked out is the artifact that changes its answer after you've decided — the script you correctly called disposable on Monday that someone quietly builds on by Friday, the boundary sliding under code you've long stopped checking. Nobody re-runs the decision, because nothing tells them it moved. --- # Ship the answer with the question URL: https://murn.eu/blog/executable-questions Date: 2026-06-02 Tags: engineering Asking clarifying questions has a bad reputation. With a new client it supposedly reads as indecision; under time pressure, as friction; the standard advice is to impress with output instead. I think that advice mixes up two kinds of questions. An open question hands the other side homework. A question that arrives with its own candidate answer hands them a veto. Only the first kind stalls you. ## A brief with a missing contract The brief asked for a weather API whose endpoint contract is "defined in openapi.yaml". The repository contained a README and nothing else. The central artifact of the whole assignment was missing. The obvious move is to ask where the file is and wait. But an open question blocks the asker, not the answerer. Until the requester sits down and authors an answer, the builder cannot move — and authoring is precisely the work that gets deferred to tomorrow. That asymmetry is why ambiguity survives: it is priced cheap for the person who owns the answer and expensive for the person who needs it. A tight schedule has no runway for paying someone else's deferral. ## The spec I proposed instead Instead of asking where the file was, I reverse-engineered a proposed spec from the live ARSO response, committed it to the repo root, and posted the question with the artifact attached: confirm I should build against my proposed contract, or send the canonical one — "I'll build against mine until I hear otherwise." The committed file changed what the other side had to do, and that is why it worked: an open "where is the spec?" gets parked because it asks someone to author a document, while mine could be answered by reading one. Authoring needs a free afternoon; a veto needs a glance. The canonical file arrived, and my reply is still on the Slack log: "Provided file looks very aligned with my expectations." Two contracts written independently — one from the brief's intent, one from a live payload — had converged, which was also the check that my proposal was grounded rather than presumptuous. ## Questions you can run ARSO, Slovenia's environment agency, publishes weather data but no official public API. There were three ways in: a reverse-engineered public JSON endpoint, the official XML feeds, and a third-party mirror. Before asking which one to build on, I wrote two small scripts — `arso-feeds.mjs` and `arso-fetch.mjs` — that fetched each candidate and parsed what actually came back. So the question I posted was not "which source should we use?" It was three options with their costs on record and a call already made: long-term the XML feed, for now the public JSON, "shout if you'd rather I commit to #2 from the start." Nobody had to argue from a guess, because the question carried its own evidence — the discussion starts from parsed output rather than from opinions about parsing. That is the shape I would now call an executable question: a question posed as a committed file, a runnable script, or a working default, so that answering it means reacting to something real. (Version 1.0.0 shipped on the XML feed in the end; the option I had filed under "later" arrived before release.) ## Defaults with a veto Testing surfaced an edge case: what should `/v1/current?lat=55&lon=` return — a query with half a coordinate? I implemented an answer first — treat the incomplete pair as absent, return all stations — and then asked: "If you prefer different behaviour of this please point out." The distance math went the same way: Haversine, with a note attached that "if there is not a really good reason to use other one this should do job good enough." The work never waited on a reply, because every question left the system in a decided state. The default is the answer until somebody overrides it, and if the override comes later, applying it costs a small diff. I did not have a name for any of this while it was happening — in the moment it mostly felt like impatience. The shape only appeared when I reread the Slack log afterwards and noticed every message on it does the same thing: here is the situation, here is my answer, override me. The portable version: take the vaguest requirement you are holding right now, write down the answer you would pick if forced, and send that — "building against this unless you say otherwise" — instead of the question alone. On solo work there is no requester to veto, but the move still pays: future-you would rather inherit a committed default than an open TODO. There is a limit here I have not worked out. The pattern equates silence with consent, and on this project the equation held — a short build, one channel, one reviewer who read what I sent. On a slower project, with a busier requester, a confident default can sail through unread, and then "aligned" is something I declared rather than something we agreed on. I know how to keep a question from blocking the work; what I cannot yet tell, from inside the project, is whether a default nobody vetoed was a decision somebody made, or only silence. --- # Pattern shopping URL: https://murn.eu/blog/pattern-shopping Date: 2026-05-26 Tags: engineering, llm There is a genre of post that lays out a roadmap for mastering agentic design patterns. ReAct first, then Reflection, then Planning, then Tool Use, then Multi-agent. Each pattern gets a short paragraph on when to reach for it and a short paragraph on what it costs. The pieces are useful as references and I have nothing against them as references. The problem is that they read as curricula, and a pattern catalog read as a curriculum produces decisions made by vocabulary rather than by need. ## The catalog reads forward ReAct is a loop — think, act, observe, repeat. Reflection is generation, self-critique, revision. Planning is decomposing the task into ordered steps before any of them run. Tool use is calling out to a fixed catalog of external functions. Multi-agent is splitting the work across specialists under a coordinator. Each pattern is presented the same way: here is the name, here is the loop, here is when to use it, here is what it costs. The catalog reads in that order — name, then move, then constraint — because that is the only order an enumeration can read in. The name is the index. You cannot look up "the move that fixes outputs you cannot validate inline" — you can only look up Reflection and discover it might be. The sequence is forced by the format. ## Engineering runs backward The pipeline I wrote this post in has three components. A planning phase that runs before any prose. An audit phase that reads the draft against a written critique list. A vocabulary file the draft gets swept against. The catalog has names for all three: the planning phase is Planning, the audit phase is Reflection, the vocabulary file is closer to Tool Use. None of those pieces got chosen because they appeared in a catalog. The planning phase exists because drafts kept opening with abstraction instead of a concrete anchor. The audit phase exists because AI-tell words kept slipping through the drafting pass and I could not catch them in the same pass that wrote them. The vocabulary file exists because the same rules were drifting across two files — I named that one already, in [rule drift](/blog/rule-drift). Each piece is the response to a specific failure, and the catalog names them only after the fact. Engineering runs backward from the catalog because the design decision is which constraint binds, not which name applies. The name is a label on the answer, not the answer. ## Pattern shopping Three teams over the last year. The criticism is the catalog, not the engineers. Team A wrapped a Reflection loop around a model output that was supposed to be JSON in a specific schema. The Reflection loop generated the JSON, the critic checked it, the reviser fixed it if not. The loop ran in two to four seconds per call. A JSON-schema validator on the same output would have run in milliseconds and either passed or failed with a precise reason. The team could name the pattern but not the constraint Reflection was supposed to answer. Team B reached for Planning on a task that already had a fixed sequence of steps. The plan was generated, the executor walked through it, the plan generator re-planned whenever something unexpected came back. The system worked. A deterministic pipeline of the same steps would have been faster, cheaper, and easier to debug. Planning was the right name for the move, but the move was answering a constraint that was not present. Team C decomposed a single agent into five specialists because the prompt was getting long. The bug count went up — coordination is its own surface — and the latency went up because the coordinator now had to make a call before any specialist ran. The bottleneck had been prompt length, which responds to retrieval or summarisation, not to specialisation. Pattern shopping happens because the reader picks the most recently learned move rather than the one their failure mode demands. The move and the failure detach. The system still gets built; it just gets built around a vocabulary, and the vocabulary's weight is wrong for the constraint at hand. ## The ancestor test The diagnostic is one sentence per pattern: name the pre-LLM engineering move it descends from. ReAct is a debug loop with logging — produce an output, observe its effect, decide what to do next, repeat. Any engineer who has shipped a long-running process has written that loop, usually with print statements as the observation step. The loop body is now a language model; the loop itself is decades old. Reflection is code review with a linter — the model as both author and reviewer, the linter as the deterministic check the reviewer reaches for. Planning is task decomposition — the move every engineer makes the first time they write a one-paragraph spec before they write the code. Tool use is API integration with a fixed catalog of endpoints. Multi-agent is service decomposition; the trade-offs (coordination cost, ownership of state, routing logic) are the same ones distributed systems have always carried. If the ancestor is unfamiliar, the pattern probably is too, and the catalog is doing the work of scaffolding rather than reference. Scaffolding is not a bad thing — most learning needs it — but it is a different thing, and the genre does not label it that way. The catalog reads correctly when the reader brings the constraint to it, because then the catalog only has to supply the name. The expensive part — recognising which move applies — is already done. ## The replacement A design review template that fits on one screen. For each pattern in a proposed design, the author has to answer three questions. What failure mode does this pattern answer? What is the cheapest alternative we considered? What breaks if we remove this pattern? If the author cannot answer all three, the design goes back. The template took ten minutes to write and has caught more over-architecture than any technical-design book on my shelf. Intake runs the same direction. The first document for any new system is a failure-mode list, not a pattern list. Each failure mode is one sentence — *the model produces malformed JSON*, *the prompt grows beyond context limits*, *the user-facing latency exceeds two seconds*. Patterns enter the doc second, attached to specific failure modes. A pattern with no failure attached gets cut. Hiring runs the same logic. A standard question I now ask in technical interviews: walk me through a system you have shipped, and for each architectural choice — not just the obvious patterns, all of it — name the constraint that drove it. A candidate who pattern-shops will lead with the names they used; one who has thought backwards will lead with the constraints those names answered, and that difference is audible inside twenty minutes. It is a more reliable hiring signal than any algorithms round I have run. A constraint-first design review surfaces over-architecture before it ships because each pattern has to defend its place rather than appear by default. The same logic carries through intake and hiring. The CTO seat acts on three levers — approval, intake, hiring — and pulling them in the same direction is what stops the team pattern-shopping. ## When the shelves are empty An engineer on my team joined six months ago. Three years into their career, all of it building on top of LLMs and managed APIs — no service decomposition shipped, no debug loop written in print statements, no integration tests against an unreliable third-party API. Smart, ships, fast. The ancestor test points at empty shelves for them. The scaffolding is not the catalog and not the pre-LLM history. It is a curated wiki of the failures the team has had, each entry organised by the constraint it taught — JSON output versus Reflection, fixed pipelines versus Planning, prompt length versus specialisation, and the rest as they accrete. Each entry carries the constraint, the move the team almost made (or made and recovered from), and the cheapest right move once the constraint was named. New engineers read the wiki first, the pattern catalog second. A wiki of past failures works as scaffolding because it gives the engineer the constraint side of every pattern before they have earned it through experience, so the names in the catalog have somewhere to attach. The catalog comes out only after the wiki has primed the constraint half. The reading order matches engineering's order. The wiki has a limit. It is reactive. It only covers failures the team has already named, which means the engineer reading it will still pattern-shop on any constraint the wiki does not anticipate. The wiki is a stopgap that fills in until the team grows an engineer who has the constraint side already, and stops being load-bearing the moment that engineer is in the room. The catalog itself is fine. The genre that presents it as a curriculum is the move I am pushing back on. The intake template and the wiki of past failures between them give a team a way to read the catalog backward — pattern shopping gets harder when the design review will not approve a pattern without a failure attached, and newer engineers get the constraint side they have not lived through. The harder problem is the failure modes the wiki does not yet name. The wiki catches up by waiting for the loss, and the next pattern-shopping incident on a constraint nobody has spoken about will look indistinguishable from the rest until the loss has a name. --- # Rule drift URL: https://murn.eu/blog/rule-drift Date: 2026-05-25 Tags: engineering, llm Addy Osmani has a clean line about agent harnesses. *Every mistake becomes a rule.* The harness ratchets toward the behaviour you want, one failure at a time, and the rulebook only grows. The framing is right and his [post](https://www.oreilly.com/radar/agent-harness-engineering/) is worth reading. It is also half the move. The other half — the one that keeps the rulebook from collapsing under its own weight — is consolidation, and most write-ups about harness engineering skip it. ## What the ratchet gives you Take a code-review harness with a smells list — the patterns the reviewer agent should flag on any PR. Say it has nine entries. Bare `except:` is on it because one past review approved a bare except and the bug it hid surfaced in prod two weeks later. `SELECT *` is on it because a query that looked harmless against a twenty-row table table-scanned a million-row one in staging. Each entry came from a specific review that should not have shipped, and the post-mortem on that review is the reason the rule exists. A rule earned through a real failure cannot be argued out of, because the cost of the failure is the only argument the rule ever needs to make. The list does not grow by taste. It grows by evidence. The bar for adding a pattern is one review the harness produced that should not have shipped — anything weaker is opinion, and opinion is the wrong currency for a rulebook the system reads on every run. That is what Osmani is naming when he calls it a ratchet. The constraint only moves one direction. Each new entry is a small step the harness will never have to take again, because the rule that prevents it is in the rulebook now, and every future review is written with it in scope. ## Where the ratchet drifts Imagine the same harness has two smells lists. One lives in `reviewer.md`, under the Rules section that the reviewer step reads while drafting comments on a PR. The other lives in `final-pass.md`, as the sweep the audit step runs against the finished review before it posts. The lists are almost identical. They are edited as separate documents. By the time anyone notices, one has picked up *TODO without a ticket reference* and the other has not. A rule that lives in two harness files drifts because each file is edited independently and no compiler checks prose for consistency. When a new smell shows up in a missed review, the next edit lands in whichever file is open. The other file goes one more cycle without the rule. The reviewer step now knows to flag a pattern the audit step will not catch. The harness is silently inconsistent with itself, and the silence is the part that matters — there is no error, no log line, no exception. Just two pieces of prose that have started disagreeing. Markdown has no type checker. Nothing in the file system tells you that the smells list in one file is a superset of the smells list in another, or that the blocker-class list in one file has acquired an entry the other has not. The contract between the two files exists only in the operator's memory of having written them, and memory is the wrong layer for a contract to live in. ## The second move The fix is a structural refactor of the harness, not an additive one. Extract every review rule — tone, smells to flag, blocker-class issues, what the reviewer skips, sign-off — into a new file called `review-rules.md`. The Rules section disappears from `reviewer.md`. The smells and blocker-class sweeps disappear from `final-pass.md`. Each is replaced with a single line pointing at `review-rules.md`. A grep for `SELECT *` across the harness now returns one file. Rules that live in one canonical file cannot drift, because there is only one place to edit and every consumer reads the same version. Consolidation removes the surface area drift needs to occur on. The next time a review surfaces a new smell, there is exactly one place to add it. The reviewer step picks up the new line on its next run. So does the audit step. The lists cannot disagree, because there is only one list. The cost is one indirection on the reading side — `reviewer.md` and `final-pass.md` now follow a pointer instead of inlining the rule. The benefit is that the rule has one home, and the next edit cannot accidentally fork it into two slightly different rules. That is the move the ratchet framing leaves out. Accrete, then put the accretion somewhere it cannot fork. ## What this means for the rulebook you already have Osmani cites HumanLayer's discipline of keeping AGENTS.md to about sixty lines. The reasoning is that long rulebooks dilute the individual rules — the model treats line 41 with less weight than line 4, and an over-long list trains the system to skim past most of it. The advice is good. It is also the accretion half. The length signal is real, but length is not the underlying problem — distribution is. A 60-line AGENTS.md that is also repeating four of its rules inside a hook script, three more inside a subagent system prompt, and one more inside a tool description is already drifting, because each copy was edited on a different day and each is one paragraph off from the others by now. The line count looks healthy and the rulebook is still incoherent. The consolidation move is a single grep. For each rule in AGENTS.md, search the rest of the harness — hooks, subagent prompts, tool descriptions, audit checklists, any markdown file the pipeline reads — and look for the same idea phrased differently. If the rule already exists somewhere else, neither location is canonical, and the next edit will pick one of them by accident. Pick a file, leave the rule there, and replace every other copy with a pointer. The cost is one minute of grep per rule. The benefit is that the next edit cannot fork the rulebook. Osmani's ratchet is right; every mistake should become a rule. The post just stops one beat early. A harness that only accretes is a harness that drifts, and the drift is invisible until two files disagree about the same word. The full move is two beats — accrete, then canonicalise. The first beat is where the rules come from. The second is what keeps them meaning the same thing. --- # Thinking in code URL: https://murn.eu/blog/thinking-in-code Date: 2026-05-23 Tags: engineering, llm There is a story about software development that has become dominant in the last two years. You decide what you want, you write a plan, and a system implements it. The plan is the thinking; the code is the artifact. For a class of problems this is true and useful. For another class — the one I want to talk about — the arrow points the other way. The code is the thinking. The plan is the residue. ## What the spec leaves out A spec can stay internally consistent while being externally incoherent because prose tolerates contradictions that types do not. You can write "the system processes inputs and routes them to the right handler" and the sentence reads correctly. It is also empty. It does not say what an input is, which handlers exist, what "right" means, or how the routing is decided. The sentence is well-formed; the design is not. Code does not allow that. A function signature is the cheapest form of commitment a software design can make — it has to name a thing, list its parts, and say what comes back. A class is a claim about which fields belong together and which behaviors live in the same room. A folder layout is a claim about which concepts are siblings. None of these are visible in prose, and none of them survive being skipped. The first time I usually notice this is when I sit down to write a function I thought I understood and find I cannot name its arguments. I do not know whether the second parameter is a string or a record. I do not know whether the response includes the original request or only the new fields. The spec did not tell me, because the spec never had to. Prose let me keep both interpretations open at once. Code does not. That is what specs are quietly leaving out: the commitments that only exist once a syntax forces them. You can spend a week refining a spec without making any of them. The first hour of typing usually makes a dozen. ## Why typing is doing work Typing the code surfaces decisions the prose was skipping because constrained syntax forces a choice the unconstrained syntax did not. Names are the most obvious version of this. When I name a function, I am claiming what it does. When I name a parameter, I am claiming what kind of thing flows through it. When I name a type, I am claiming where the boundaries of the concept are. Every name is a small commitment, and the commitments compose into a model. I spent an afternoon last year writing what I thought was a clean spec for a workflow before I had touched the data. Three pages of prose. The audience, the inputs, the outputs, the steps. It read well. It also passed three reviewers. When I started typing it, two things happened in the first hour. Three of the entities I had described separately turned out to be the same thing under different names — the spec had introduced them in different paragraphs and never noticed. One of them, which I had described as a single concept, turned out to be two — a request and an event, identical-looking at the document level, but with different lifecycles that the types refused to share. None of that was a typing mistake. The contradictions were already in the spec. They survived because prose does not check for them, and the reviewers were reading for clarity, not for closure. The compiler is not just a syntax checker. It is the first reader I have ever met that refuses to fill in gaps. ## The ambiguity asymmetry Spec-first workflows feel less ambiguous than code-first workflows because prose hides ambiguity inside well-formed sentences while code exposes ambiguity as a missing branch or a name that does not resolve. The feeling is backwards from the substance. The spec feels finished because it has no errors; it has no errors because it has no compiler. The code feels rough because everything not yet decided shows up as something that does not compile. The rough version is the honest one. This holds at the human end. It holds harder at the model end. A model reading a spec fills the ambiguity in with the most plausible reading and hands back something that looks correct, because the most plausible reading is exactly what its training optimizes for. A model writing code against a typed contract cannot fill in the same way — the contract refuses some readings outright, and the wrong output is visible in a way the spec version was not. The constraint travels into the inference. I have written about a related shape of this in [prompts as pipelines](/blog/prompts-as-pipelines): constraints decay with proximity, but they decay much less when the constraint is mechanical instead of textual. A type signature is not a request that the model behave a certain way. It is a shape the output has to fit into, or it does not fit at all. That is the asymmetry the spec-first framing misses. It treats prose and code as two notations for the same thinking and picks the one that feels lighter. They are not two notations. They are two pressures, and only one of them pushes back. ## Where the orchestrator model breaks The orchestrator framing breaks on ambiguous problems because it assumes the thinking has already happened upstream of the typing. The implementation step is then a transcription job that can be handed off — to a junior, to an agent, to anything that can produce code from instructions. The framing works when the assumption holds. For a known input mapped to a known output by a routine I have written ten times before, the spec really does close, and the agent really is doing transcription. I delegate those happily, and I will keep delegating them. The framing breaks when the typing was the medium of the thinking. The pattern I watch for is small and specific. A feature seems clear in conversation. It gets vaguer when I write it down in a planning doc. It only becomes specific when I sit down to write the function and find I have to name the second argument. The naming is what made it specific. If I had handed the feature off at the planning-doc stage, the second argument would have been named anyway — plausibly, smoothly, and wrong, because the right name was not in the document. It was downstream of work that had not happened yet, and the only place that work could happen was in the typing. The honest move is to notice when typing is doing the thinking and stay in the medium that is doing the work. I covered the inverse case in [judgement-shaped problems](/blog/judgement-shaped-problems) — for inputs an integration can close around, the spec is enough and the orchestrator framing is fine. This post names the other side. The two shapes need different stacks. Sending one through the other's pipeline wastes both. Not every problem is like this. Some inputs really do close at the spec; for those, delegating implementation is a clean win and I take it. The mistake is using one framing for both. Before I let the typing be done somewhere else, I want to know which medium the thinking is in. If it is in the code, the code stays with me. --- # Judgement-shaped problems URL: https://murn.eu/blog/judgement-shaped-problems Date: 2026-04-30 Tags: engineering "Agent" has become a label for anything with an LLM in it. Some of the systems wearing the label do work that integrations could not reach. Most do not. The difference is not what is in the box — it is whether the problem itself is judgement-shaped, and whether the if/else has actually moved out of the code into the model's inference. If neither is true, the system is an integration in costume. ## What integrations were built for Take the simplest possible automation: an invoice arrives, a notification posts to Slack, the sheet updates. Most automation looks like that. There is a known input, a known output, and the work is wiring the two together with as little surprise as possible. Payment integrations, ETL pipelines, scheduled syncs, webhooks routing events between systems — the shape integrations were built for is "I have a known input and a known output, and I want them connected reliably." Integrations encode that input space at design time because that is the shape of problem they were built for. Every input class is a branch I write. Every branch is code I maintain. As long as my branches cover the inputs that arrive, the system runs cleanly. The trouble starts when the real input space is open. Free-form text, mixed-mode requests, exceptions I did not enumerate when I shipped. Each new shape is a new branch, and the branches start interacting. Surface area grows quadratically with input variance, and the integration architecture — which scaled gracefully under enumerable inputs — becomes a maintenance liability under non-enumerable ones. Not because the architecture is wrong. Because the problem changed shape underneath it. ## What "agent" means when the word means something A useful definition of "agent" should make it clear which systems are doing something integrations could not. Mine is narrow on purpose: an agent is a system that has moved the input → output decision from my code to the model's inference. The if/else does not vanish. It gets relocated. Where the integration's branches live in source I write, the agent's branches live in the prompt I send and the reasoning the model does at runtime. This is an architectural change, not a marketing one. Relocating the if/else changes who maintains the input space — me, ahead of time, or the model, in the moment. The cost shifts from code I must write to inference I must trust. Take support-email triage. The integration version classifies by keywords or by a per-intent classifier. It works for the cases I anticipated, and fails on the ones I did not — the customer who is angry but polite, the bug report disguised as a feature request, the urgent thread buried in pleasantries. Each new failure mode is a new branch. The branches multiply. The system grows brittle. The agent version reads the email and decides what to do with it. The decision logic is no longer in my code; it is in the model's interpretation of the prompt and the email. I have not removed the if/else. I have moved it to a place where new shapes do not require new code. That gives the reader a diagnostic. *Can I write the flowchart?* If yes, the input is integration-shaped, and an agent is overkill. If every attempt hits "and 200 edge cases," the input is judgement-shaped, and the relocation is what lets the problem be answered at all. ## The label is uncalibrated Most systems calling themselves agents are not. They are integrations with a model call somewhere in the middle. The shape is familiar: read input, ask an LLM to extract structured fields, run a deterministic flow on what the LLM returned. The if/else still lives in my code. The model is doing fuzzy parsing, not judgement. I have a name for this pattern: *model-assisted integration*. It is not bad architecture. For many problems it is the right architecture — fuzzy parsing is a real capability when the input is structured-but-messy. The error is not in building one. The error is in calling it an agent and inheriting the runtime properties of one in marketing copy without inheriting them in code. Most production "agents" are this shape because the label gets applied for marketing rather than for architecture, and there is no standard yet to push back. The presence of an LLM call is not the test. The test is where the decision lives. If the flow is fixed and the LLM is filling in fields, the system is a model-assisted integration. If the flow is decided by the model at every step, the system is doing what the word "agent" should mean. Most things in production are the first one wearing the second one's clothes. ## The failure mode trade-off Even when the input is genuinely judgement-shaped, an agent is not always the right answer. The relocation comes with a permanent cost: how the system fails. Integrations fail loud. A schema does not match. A field is missing. A request times out. The error is visible at the boundary between systems, easy to log, easy to alert on. Whatever the bug is, I see it, and I can fix it. Agents fail soft. The output is plausibly wrong — confident, well-formed, in the right shape — and it passes through the rest of the system as if it were correct. There may be no exception, no log line, no alert. The error becomes visible only downstream, when something acts on the wrong output. Sometimes I never see it. This is not a bug in any particular agent. It is a runtime property. Agents fail soft because inference is non-deterministic by construction; the output is sampled from a distribution that includes plausibly-wrong answers. Better models reduce the distribution's tail. They do not eliminate it. That makes the choice between integration and agent partly a choice of which failure mode I can afford. A financial transaction routing system cannot tolerate plausibly-wrong outputs; integration is the only honest answer there, regardless of how judgement-shaped the input feels. A content-tagging system can tolerate the occasional miscategorisation; soft failure is a cost the system can absorb. The shape of the input matters; the cost of soft failure matters more. ## Where agents earn their cost Three shapes of problem reliably reward the relocation, because each satisfies all three conditions at once: the input space is open, soft failure is acceptable, and inference is cheaper than the alternative. Under-determined intent extraction. Support-email triage, customer-feedback classification, free-form ticket routing. Input cannot be enumerated; output is one of a manageable number of buckets; soft failure is annoying but recoverable. Cross-domain reasoning. Legal documents to action items. Procurement notices to compliance checks. Call transcripts to CRM updates. Input format varies; target schema varies; the mapping requires interpretation no fixed code path can carry. Generative work. CMS content drafts, marketing copy, product descriptions. The output does not exist before the system runs; there is no input to map *to* — only an input to think *about*. I have shipped systems in each of these shapes, and the diagnostic from earlier is what I run before I pick a stack. *Can I write the flowchart?* If yes, ship the flowchart. If every attempt hits "and then it depends," check whether the failure mode I would inherit is one I can afford. If both answers point to an agent, the relocation pays. Tedium of maintaining the integration is recoverable. Plausibly-wrong output that nobody catches is not. Most production systems wearing the agent label sit between those two — integrations in costume, where the if/else still lives in the code. The label will keep moving until something forces it not to. Until then, the question to ask is shape, not name. --- # LLM catch URL: https://murn.eu/blog/the-catch Date: 2026-04-21 Tags: engineering, llm You know the feeling. You have an idea. You open ChatGPT. You type: "Design me a complete architecture for this feature. Include database schema, API endpoints, error handling, deployment strategy." Ten seconds later you have twelve pages of perfect text. It looks right. It reads like something a senior engineer would write. It has tradeoffs mentioned. It has numbered lists and bullet points and everything you asked for. You save it to a file. You close the tab. You never look at it again. ## The production gap This is the quiet failure of LLM-aided work that nobody talks about. We have become incredibly good at generating specifications, plans, architectures, roadmaps, designs, and proposals. We have become no better at actually building any of them. The ratio is something like 100:1. For every one thing that gets built, there are a hundred complete, perfectly reasonable plans sitting in markdown files, chat histories, and Notion pages, never to be opened again. The LLM doesn't care. It will happily write you the full specification for a distributed message broker in the time it takes you to blink. It will explain all the edge cases. It will argue with you about consistency models. It will do everything except type the first line of actual code. Nobody reads these documents. Not really. We scan the first page. We nod. We think "yes that makes sense". And then we move on, because the actual work of building was never the part we were stuck on in the first place. ## Planning as procrastination The hardest part of building something was never figuring out what to build. It was building it. Before LLMs, you had to think through the plan. You had to write it down. You had to argue about it. That process forced you to confront the hard parts early. Now you can skip all that. You can have a complete, internally consistent plan in less time than it would take you to explain the problem to a colleague. But the plan doesn't remove the work. It just hides it. All the messy, boring, tedious parts that make something actually work are still there, waiting for you after the impressive document ends. This happens because the plan looks so complete, so thorough, so done, that you get the psychological feeling of having accomplished something without having done anything at all. It is productive procrastination at industrial scale. ## The test Here is a simple test for any LLM output: will this document make me type code tomorrow? If the answer is no, it doesn't matter how good it is. It doesn't matter how clever the architecture is. It doesn't matter how well it explains the tradeoffs. It is dead weight. It is a simulation of work, not work itself. The best LLM outputs are not the longest ones. They are not the most detailed ones. They are the ones that end with "and then you write these seven lines of code, and that's the whole thing". Everything else is just reading material. ## What gets built The things that actually get built are almost never the ones with the perfect twelve-page specification. They are the ones where someone got frustrated, stopped planning, and just typed the first ten lines of code. They are messy. They have missing features. They cut corners. They don't handle all the edge cases. But they exist. They run. They do something. The difference between a plan and a product is not quality of thinking. It is tolerance for imperfection. It is willingness to start before you have all the answers. It is accepting that the first version will be bad, and building it anyway. We are living through the greatest supply of plans, specifications, and designs the world has ever seen. And we are living through the greatest shortage of things that actually work. Anyone can ask for a plan. The hard part is stopping at the point where you have just enough information to start, and then closing the tab. --- # Asking is not enough URL: https://murn.eu/blog/asking-is-not-enough Date: 2026-03-18 Tags: engineering, llm The usual workflow goes like this: write a prompt, read the output, decide it looks reasonable, move on. Repeat for the next step. By the end, something is broken, and it's not obvious where — because every individual answer *sounded* right. This is the query reflex. It treats an LLM call like a search query: ask, receive, accept. It works fine for one-off questions with no downstream consequences. It fails, quietly and consistently, everywhere else. ## Plausible is not correct Language models are trained to produce coherent output. Coherence and correctness are different things. A model will confidently describe a codebase it hasn't seen, summarize a document with subtle inversions of meaning, or extract fields from text and miss edge cases that only matter in production. None of this looks wrong on first read. That's the problem. Plausibility bias — the tendency to accept output that reads well — is why unvalidated LLM output breaks workflows at the worst moment. The failure doesn't surface at the prompt; it surfaces three steps later, in a place that seems unrelated. By then, the original output is already treated as ground truth. Validation isn't a nice-to-have attached to the end of the process. It belongs at the point of output, as a condition of continuing. ## The prompt that describes nothing useful Weak prompts fail for a specific reason: they describe a *topic* rather than a *task*. "Summarize this document" is a topic. The model can do something coherent with it. What it can't know is: what does the summary need to contain for the next step to work? What format does downstream code expect? What's the maximum length before another process breaks? What happens if a field is missing? A task-shaped prompt defines the output contract. Not through over-engineering — not temperature settings and system prompt tuning — but through a simple prior question: *what does success look like, and how would I know?* Prompt technicalities (model selection, token budgets, formatting tricks) matter, but they're downstream of that question. Getting the technical settings right while leaving the task undefined produces well-formatted nonsense. ## Outputs are inputs The thing that changes how you write prompts is thinking of each LLM call as a transformation node rather than a question. A node takes input, does something to it, and produces output. That output is the input to the next node. Which means the output needs to satisfy a contract — a shape, a schema, a set of conditions — that the next step depends on. When you design prompts this way, several things become obvious that weren't before: - What structured data does the next step actually need? - What happens if a field is absent or ambiguous? - Where does the chain assume the previous step was correct? The last question is the most important one. Silent assumptions propagate. A workflow that assumes each step succeeded — without checking — doesn't just have a bug. It has a bug that compounds. I've seen this in agentic systems where an early classification step returns a plausible-but-wrong category, and every subsequent step proceeds as if the category were verified. The end state is coherent and completely wrong. No single call was obviously bad. The problem was the absence of checks between them. ## The check before the call The practical change isn't about prompting technique. It's about what you define before you write the prompt. Before calling the model, answer three things: - What specific data does this step need to produce? - What are the conditions under which that data is good enough to pass forward? - What does the next step do if this one returns something malformed? These questions force you to think about the call as a step in a flow rather than an isolated question. They make the validation obvious — because you've already decided what the output is supposed to be. And they make weak prompts visible, because a prompt that can't answer "what does success look like" hasn't been thought through yet. The output of an LLM call is only as useful as the step that uses it. Designing backwards from there — from consumer to producer — is the difference between a pipeline that holds and one that fails somewhere you're not looking. Asking is the easy part. Knowing what you needed to hear is the work. --- # Prompts as pipelines URL: https://murn.eu/blog/prompts-as-pipelines Date: 2026-03-01 Tags: engineering, llm There is a common misconception that prompt engineering is the craft of writing better prompts. Most of the advice online — the tactics, the lists of techniques, the templates — takes the single prompt as the unit of work. After two years of writing, rewriting, and living with the prompts I actually use, I have come to disagree. The unit of work is not the prompt. It is the pipeline. ## The single-prompt trap The first prompt I ever kept in a file was a thousand words long. It told a model how to turn a rough topic into a finished article: define the audience, pick the angle, draft a headline, write the piece, check the facts, tighten the prose. It was exhaustive. It was also unreliable. The model would ignore the fact-check step when the draft got interesting. It would forget the audience by the time it reached the conclusion. It would quietly invent statistics it had been explicitly told not to invent. This happens because instructions in a long prompt do not keep their weight. Each rule you add dilutes the ones before it. The model is not reading your prompt as a checklist; it is reading it as evidence of what kind of reply you want. The twelfth constraint lands softer than the first, and the first lands softer than it did before the twelfth arrived. You can keep adding rules, but past a certain length, you are no longer teaching — you are wishing. ## What a pipeline gives you My current setup is five separate prompts. Research. Strategy. Writing. Audit. Distribution. Each one has its own inputs, its own output, and its own narrow job. Research runs first and feeds verified facts forward so the later phases never have to invent what they should already know. The strategy prompt never sees the body text; the writing prompt never sees the distribution plan. This is not an aesthetic choice. It is a reliability choice. A pipeline works because the context of each phase only contains what that phase needs. Strategy thinks about the reader and the angle. Writing thinks about the outline and the voice. Audit thinks about weak sentences and unverified claims. When a phase fails, it fails on its own terms, in a bounded way, and I can see exactly what went wrong — because the only thing in the room is the phase that broke. ## The checkpoint is the artifact Between each phase I stop and read what came out. I approve it, rerun it, or rewrite it by hand. Nothing advances until I say so. This is the part most agent frameworks want to remove. Auto-chain the phases, they say. Let the model decide when it is done. I have tried it. It produces output faster and worse. Errors in phase N compound into phase N+1 because each phase trusts its inputs. If the strategy phase picks a weak angle and nothing stops it, the writing phase faithfully drafts a thousand words in service of a bad idea. The audit that follows then reads those thousand words against criteria that assume the angle was chosen well, and misses the root problem entirely. The checkpoint is not a courtesy. It is the only thing between a fixable drift and a finished piece that has to be thrown away. The prompts in my pipeline are valuable. The checkpoints between them are what make the prompts valuable. If someone copied the four prompt files without the four stops, they would have something that looks like my system and behaves like a worse one. ## Constraints that travel Here is something I did not expect. The rule *Do not fabricate data* sits at the top of the writing prompt, in its own section, at the start of a short file. It used to sit at the top of my mega-prompt, also at the top, also at the start. Same words. Different behavior. In the pipeline, the model does not fabricate. In the mega-prompt, it did. I think this is because constraints decay with proximity. A rule stated once, at the start of a long document, competes with everything that follows it for the model's attention. The further the model reads, the more the document as a whole becomes the signal, and the opening constraint becomes one of many voices in a noisy room. In a short, focused prompt there is no noisy room. The constraint is still there when the model stops reading, because the model never left its neighborhood. This is the part I did not know until I had run it both ways. You can write the same instruction in the same words, and have it enforced in one version and ignored in another, purely because of where it sits. Short prompts are not just easier to write. They are the substrate that makes constraints hold. ## What this changes on Monday If you have been rewriting the same prompt for the fourth time this week, stop. You have probably confused two problems. One is that the prompt is unclear; the other is that the prompt is trying to do too much. The fix for the first is more words. The fix for the second is fewer words, and more prompts — because a single prompt cannot hold two responsibilities without leaking one into the other. Try splitting it at the first natural seam, often between deciding and doing, and see whether each half behaves better on its own. My guess is that it will. The right question to ask about a prompt is not how good it is. It is what it is responsible for, and what happens between it and the next one. Prompts compose. They do not concatenate. --- *Inspired by the disciplined approach to prompt systems? See how [Dunking Devils](https://dunking-devils.com/) applies structure and craft to high-performance systems. Explore structured approaches from [OpenAI](https://openai.com/sl-SI/) and build orchestrated AI systems with [Mastra](https://mastra.ai/).* --- # Boring software URL: https://murn.eu/blog/boring Date: 2026-02-05 Tags: engineering The highest compliment I can give a system is that it is boring. Not boring as in unambitious. Boring as in: nothing unexpected happens. Deploys go out on Tuesday and nobody holds their breath. The on-call phone doesn't ring. Customers don't notice, because there is nothing to notice. Everything just works. This is extraordinarily hard to achieve. ## The glamour problem Our industry has a glamour problem. We celebrate the heroic fix, the all-night debugging session, the engineer who saved production at 3 AM. We write blog posts about surviving scale, war stories about incidents, post-mortems that read like thrillers. These make for great conference talks. They make for terrible engineering cultures. If your team regularly needs heroes, your system is telling you something. It's telling you that the work that *should* have been done — the planning, the testing, the documentation, the careful thinking about failure modes — was skipped in favor of speed. Speed is not velocity. Shipping fast and fixing later is just borrowing time from your future self at a brutal interest rate. ## What boring looks like Boring software is written clearly. Not cleverly — clearly. Every function does what its name says. The code reads like prose, not puzzles. A new engineer can open any file and understand what it does and why. Not because the code is simple, but because someone took the time to make it *legible*. Boring software is documented. Not as an afterthought, not as a ticket that never leaves the backlog — documented as a first-class part of the work. The architecture decisions are recorded. The runbooks exist *before* the incident. The README actually tells you how to run the project. Boring software is tested. Not with a handful of optimistic happy-path tests, but with the kind of testing that comes from genuinely asking: what could go wrong? Edge cases are not surprises — they were anticipated, discussed, and handled. The test suite is a living document of every assumption the system makes. Boring software is deployed predictably. There is a pipeline. It runs the same way every time. Rollbacks are a button, not a prayer. Feature flags gate new behavior. Migrations are backward-compatible. Nobody is SSHing into production. ## The cost of excitement I've worked on exciting systems. Systems where every deploy was an event, where monitoring dashboards were watched like live sports, where Slack channels lit up at odd hours. It felt important. It felt like we were doing real work. We were. But most of that work was a loan we didn't remember taking out. A heroic fix is not a one-time cost. It's a loan whose collateral is the next hero hour. The artifacts of last night's save — the untested patch, the undocumented workaround, the deploy nobody quite understands — become the substrate of next month's incident. This is why exciting teams stay exciting. Each rescue makes the system slightly less legible, which makes the next failure slightly harder to diagnose, which requires a slightly bigger hero. Boring is not the absence of heroism. It is the refusal to take out the loan in the first place. There's a way to tell whether your team has stopped paying interest. Count your last ten incidents. Not by severity — by root cause. If most of them are variations of a cause you've already seen, the team isn't encountering the frontier of its system. It's failing to learn from it. A boring team is not the team with the fewest incidents. It's the team whose incidents are all *new* — each one a genuinely novel discovery, never the same race condition twice. ## The visibility problem Boring has a visibility problem. You cannot demo a thing that didn't happen. You cannot put "prevented fourteen outages" on a performance review, because the counterfactual doesn't exist — there is no parallel universe to compare against. Organizations can only see what *fails*, never what was *prevented*, which means the incentive gradient inside almost every company points away from boring and toward visible heroism. This is not a culture problem you fix with a values poster. It is a measurement problem. The teams that stay boring are the ones whose managers have learned to read negative space — to ask "what didn't happen this quarter that should have?" instead of only "what did you ship?" Until someone is rewarded for the absence of incidents, the system will keep paying its best engineers to create them. ## The quiet pride There's a particular kind of satisfaction that comes from running a system that just works. No war stories. No heroics. No dramatic saves. Just steady, reliable, unremarkable service — day after day. It won't get you on stage at a conference. Nobody writes threads about the deploy that went exactly as planned. But your team sleeps well. Your users trust you without thinking about it. And when you do need to change something, you change it calmly, because the system was built to be changed. That is not the absence of craft. That is craft at its highest form. The best software is boring. I intend to keep it that way. --- # On simplicity URL: https://murn.eu/blog/on-simplicity Date: 2026-01-10 Tags: design There is a common misconception that simplicity means doing less, or removing features, or leaving things out. This couldn't be further from the truth. True simplicity — the kind that feels inevitable when you encounter it — is the result of deeply understanding a problem. It requires you to go through complexity, not around it. You have to hold the full weight of what something could be, and then make careful, sometimes painful decisions about what it *should* be. ## The cost of simplicity Dieter Rams understood this. His ten principles of good design are not a checklist for making things minimal. They are a framework for making things *honest*. "Good design is as little design as possible" does not mean the designer did little work. It means the designer did so much work that the result appears effortless. This is the paradox at the center of every meaningful design decision: the simpler the outcome, the harder the process. The reason is mechanical, not mystical. You cannot decide what to throw away from a room you have not entered. Reduction is impossible without first carrying everything — every option, every edge case, every user you imagined. The minimal answer is the one that survives after you have held all the others in your hands and put them down on purpose. ## Reduction as a practice I've found that the most useful question in any design process is not "what should we add?" but "what can we remove?" Not as a cost-cutting exercise, but as a form of respect for the person who will use what you make. Every element on a screen is a demand on someone's attention. Every feature is a promise you have to keep. Every option is a decision someone has to make. When you remove something, you're not just making the interface cleaner — you're giving someone back a small piece of their cognitive freedom. Call this the cost of options. The user always pays it — in attention, in hesitation, in trust spent on choices that should never have been theirs to make. ## The discipline of restraint Restraint is not a natural instinct. We want to show our work. We want to demonstrate capability. But the moment a design starts to feel like it's trying to impress you, it has already failed. The goal is not invisibility. It is inevitability. A simple thing is one the user could not imagine being any other way — not because it disappeared, but because it answered the question they came with so completely that no other shape was left to consider. Here is a test: if you cannot draw your own interface from memory, neither can the people who use it. The fix is not to label things better. It is to make fewer things. --- # Return value URL: https://murn.eu/blog/return-value Date: 2026-01-05 Tags: philosophy Most blog posts get published because someone wrote them. That is not the same as earning a place in someone's attention, and most never do — they are read once, if that, and forgotten. Every post on this blog is held to the second standard, not the first: would the reader keep it? ## A short bio Fifteen years of building software, currently full-stack and AI-powered tools at [PROGMBH d.o.o.](/cv#progmbh) — the rest of the bio is on [my CV](/cv) for anyone who needs it. I bring it up only to flag a parallel. Most software ships because the work happened, not because the result earned its place in the system it joins. Most blog posts ship for the same reason. The standard worth keeping is the same in both domains: a thing that exists is not the same as a thing worth keeping. This blog applies the second test to writing. ## What most posts miss Most blog posts fail return-value because the test that gates their publication is writer-side, not reader-side. Did I write something today? Did I hit the cadence? Did I cover the keyword? Each of those is a question only the author can answer, and a yes from the author lets the post ship. None of them ask anything of the reader. The genres prove it. SEO listicles exist to rank, not to inform — the reader is incidental, a vehicle for impressions. Weekly-cadence posts get written to the calendar; the topic is whatever was due. Consensus restatements take an idea everyone already nodded at and put cleaner sentences around it. The reader closes the tab, learns nothing, and the blog still counts the page view. The default state of a published post is that no one needed it. That is the bar most posts clear, because most posts only have to clear the bar the writer set. ## The return-value test A post passes the return-value test because the reader takes away more than they spent. The currency on the reader's side is attention; the currency on the writer's side is words. The trade is asymmetric — the writer pays once, the reader pays each time — so the burden of proof sits on the post, not on the reader's patience. What does return value look like, concretely? A reframe instead of an exhortation. A named pattern the reader can carry into a meeting next week. A mechanism that explains something the reader had felt but not articulated. One applicable claim per post, promoted to its sharpest sentence and given a handle. Bookmarks, returns, and shares are evidence that the trade landed — they are not the goal of writing, they are the receipt. If a post does not name something the reader did not already have words for, it did not pay back. That is the bar the rest of this blog wants to be measured against. ## The experiment I can publish LLM-assisted writing under this bar because the unit of work is not the prompt. It is the pipeline. Constraints survive across phases — grounding, plan, draft, audit — that a single mega-prompt would dilute long before the closing paragraph. The fuller argument is at [Prompts as pipelines](/blog/prompts-as-pipelines); this section only names the pipeline as the reason the return-value bar is enforceable rather than aspirational. Right now, every post on this blog is manually checked and corrected before it ships. The pipeline does most of the work; my hand is on the brakes for the rest. That is the honest state of the experiment as I write this. The day a post clears the bar without my correction is the day the experiment worked. Until then, every post here is held to the same question, by hand if it has to be: would you keep this? If the answer is no, the post does not ship. That is the only test I want this blog to be measured by.