← All articles

Intentional engineering: write the intent, let the machine verify the rest

We have all had that slightly uncomfortable moment working with agents. A 400-line pull request opens, the tests are green, the code runs, and yet we cannot explain what was produced. Not whether it is correct, the tests take care of that, but why this decomposition, this naming, this dependency rather than another.

The ambient vocabulary has a name for this way of working: vibe coding. The term has become the default label for AI-assisted development, and I do not recognise myself in it. Not because it is pejorative, but because it suggests that using agents means giving up part of the intent behind what you produce. For me it has been rather the opposite. I use LLMs from morning to night to design, prototype, arbitrate and implement, and I have probably never made my choices and constraints so explicit.

The term I do recognise myself in, which I came across recently, is intentional engineering. Eight months, 42 repositories and close to 570,000 lines added later, here is what I put behind it, and how it plays out on two real projects.

The problem is not the model’s ability

Trouble with agents is usually blamed on their technical level. That was a good explanation two years ago; it is much weaker today. The difficulty has moved, and it is structural: an LLM is a non-deterministic function.

Ask the same architecture question twice and you get two different answers. Neither is wrong, both are plausible and defensible, and nothing guarantees they are compatible with each other. On a prototype that variability is a good thing, it is in fact what you are after when you explore. On a system several people will maintain for two years, it is expensive.

The usual response is to try to reduce that non-determinism by adding constraints: framing the prompt, spelling out the request in detail, reaching for a stronger model, lowering the temperature. It helps, but it runs out of road quickly, because it amounts to expecting a probabilistic generator to behave like a source of truth.

Intentional engineering comes at the question from the other end. Rather than making the model deterministic, you build determinism around it. Which requires agreeing on what the word covers, since it is often used as a vague synonym for “rigorous”.

What “deterministic” means here

A program is written in a formal language. Its grammar is finite and unambiguous, and its semantics are fixed by the language definition. The same input therefore produces the same output, once you set aside the explicit sources of indeterminacy that are the clock, the network and thread scheduling. That property is guaranteed by construction rather than observed empirically.

But execution is not the interesting part. What those same formal languages mainly give you is mechanisable verification. A typical type system is a decision procedure: hand it a program, it terminates, and it returns the same verdict on the same code. Not all of them guarantee this, C++ templates being Turing-complete, where the compiler hits a recursion limit rather than a verdict. But in the common case, with the toolchain and lockfile pinned, cargo build on a given commit answers the same thing today and in six months, on my machine and in CI. Some type systems go further: across all safe code, Rust’s borrow checker establishes before execution the absence of an entire class of errors, data races and use-after-free.

At the extreme, proof assistants take this logic all the way. You state a property, and the machine checks the proof that the program satisfies it. The CompCert C compiler is written and proved in Rocq (formerly Coq); the seL4 kernel was done in Isabelle/HOL. The cost is out of reach for an ordinary project, but the direction is instructive: the property is stated by a human, its verification is entirely mechanical.

An LLM has none of these properties. It samples a probability distribution over tokens. Temperature zero buys reproducibility at best, under identical conditions (same weights, same compute kernel, same batch), and reproducibility is not verification: getting the same output twice guarantees nothing about whether it satisfies anything. There is no verdict, only a plausible output.

The asymmetry is right there, and it is what gets exploited. Generating an architecture cannot be a decision procedure, whereas checking that code respects a given rule very often can. A type checker, a lint rule, a schema validator, a test run against real data all share the same nature: they terminate, and they return a reproducible verdict on the artifact.

Those two ends frame a spectrum every project sits somewhere on. Human review sits at the bottom: neither total nor reproducible, and dependent on how much attention is left at 6pm on a Tuesday. The proof assistant sits at the top, at a cost almost no project can fund. In between, the question is the same for every property I care about: can I write it in a form a machine knows how to check? A naming rule becomes a lint rule, a dependency constraint becomes a build rule, an invariant over the data becomes a test. Whatever resists that translation stays on me.

The definition, in two rules

  1. Intent lives outside the model, in artifacts that are versioned, reviewed and binding: specs, ADRs, LLDs, schemas, conventions.
  2. Whatever must be true is verified by a decision procedure, not by human review.

Between those two bounds the agent gets considerable freedom, and that is deliberate. The problem only shows up when the bounds are missing: with no intent written upstream and no mechanical verification downstream, nothing separates a good decision from a plausible one.

flowchart LR
    subgraph det1["Deterministic: what I write"]
        Intent["SPEC / ADR / LLD<br/>schemas, conventions"]
    end
    subgraph nondet["Non-deterministic: what I delegate"]
        Agent["Agent + LLM<br/>design, code, docs"]
    end
    subgraph det2["Deterministic: what decides"]
        Gates["Hooks, CI, lint, tests<br/>schemas, supply chain"]
    end
    Intent --> Agent --> Gates --> Artifact["Shippable artifact"]
    Gates -.->|"failure: the agent fixes the code"| Agent
    Gates -.->|"recurring gap: I fix the intent"| Intent

The feedback loop matters just as much as the rest, and it cannot close without human intervention. When a gate fails, the agent fixes the code; on my side, I fix the intent that allowed the gap to occur. A recurring failure is rarely the agent slipping, it usually reveals an intent that was not precise enough. My role does not disappear, then, it changes level: less time spent reading lines, more spent writing the decisions and tuning the gates.

1. Write the intent before the code

At my current client, an enterprise infrastructure platform, the architecture corpus holds 85 ADRs, 9 LLDs and one HLD, built since February 2026. On an open source project I maintain, an API gateway written in Rust, it is 8 specs and 31 ADRs for 92,500 lines of code.

In both cases the order is the same: the decision is written before the agent produces anything.

The non-obvious part is the shape of the template. The one we use at the client enforces a section many teams skip:

## Options considered

### Option 1: Option name
| Advantages | Disadvantages |
| ---------- | ------------- |

### Option 2: Option name
...

## Decision
> **Decision**: Option X - Option name
**Justification**: ...

## Consequences
### Positive / ### Negative / ### Risks

That section is what makes the template useful with an agent, for a simple reason: an agent you ask to implement will implement. It will not stop to point out that three other approaches existed. And if you ask it to justify its choice afterwards, it will brilliantly justify the one it already made. Forcing the alternatives to exist as text, before the decision, is the only way I have found to keep the trade-off arguable. It is also the only part I genuinely read line by line.

The template has a second effect: decisions chain together. A recent ADR on DNS as code opens by citing the two decisions it inherits from, and delimits exactly what was left open:

ADR-049 decides the DNS server […]. It does not decide where records are authored, nor how they reach the server. This ADR does.

That paragraph is not after-the-fact documentation. It is what gets handed to the agent before it starts: it fits in a context window, it is unambiguous, and it stops an agent from reopening a settled debate six months later.

2. Make conventions machine-readable

A convention that only lives in its author’s head will not be applied by an agent, simply because the agent has no access to it. So it has to be written somewhere the agent reads again every session.

At my client, an .agent/conventions.json file describes the documentation mechanics:

{
  "role": "design",
  "branch_pattern": "docs/{slug}",
  "commit_convention": "conventional",
  "docs": { "check_cmd": "npm run build" },
  "adr": {
    "dir": "docs/business-it/architecture/adr",
    "filename_pattern": "{nnn}-{kebab}.md",
    "template": "docs/business-it/architecture/adr/000-template.md"
  },
  "lld": { "dir": "...", "template": "..." }
}

On the gateway, the code-side equivalent is a CLAUDE.md that goes all the way down to idiom level:

- Never `unwrap()` or `panic!()` in production code
- Use `.expect("reason")` only for provably infallible operations
- Use `parking_lot::Mutex`/`RwLock` over `std::sync` (no poisoning = no unwrap needed)
- Prefer `.is_some_and()` over `.map(...).unwrap_or(false)`

The field that matters most in that file is check_cmd. It does not describe a convention, it gives the agent the means to verify its own work without me, which is what turns it from an instruction into a decision procedure.

It is worth being precise about what these files do and do not do, because this is where the method tends to tell a nicer story about itself than it deserves. They do not encode the intent of the system. They encode local guardrails on how to write code. If check_cmd runs cargo fmt, clippy and the unit tests, an agent can produce code that is perfectly clean against all of those rules while having made a bad business or architectural decision along the way.

The two levels are complementary, and conflating them is the classic mistake. The ADR carries the architectural decision and stays read by a human. The conventions file carries what verifies itself without me, and stops taking up review time. Every rule I manage to write into the second frees up reading time for the first, and that is the only trade-off I care about here.

3. Make the gates blocking

A gate only becomes a decision procedure once it refuses. As long as it merely prints a warning, it stays one piece of information among others, and it gets arbitrated case by case, therefore by a tired human, therefore non-reproducibly.

On the gateway, the gates stack on three levels:

LevelWhenContent
pre-commit hookbefore every commitcargo fmt --check, cargo clippy -- -D warnings
CIon every PR16 jobs, among them fmt, clippy, MSRV, audit, build, unit tests, control plane, plugins, integration, coverage, security suite, benchmarks, UI, e2e
Scheduledcontinuouslycargo deny check (advisories, licenses, bans, sources), keyless cosign signing + syft SBOM

The hook’s -D warnings changes the nature of the gate, since it turns a clippy opinion into a refused commit. The agent cannot produce a commit clippy disapproves of, so it fixes it itself before I see anything. A whole class of review comments leaves my field of view that way, not because it is ignored, but because it is handled by a procedure that does not get tired.

On the client side, the same logic applies to prose. markdownlint and prettier run through husky and lint-staged, and the Docusaurus build acts as the final check. A dead link in an ADR breaks CI just as an unwrap() breaks the build.

4. Make verification bear on the intent itself

A lint rule, a unit test or a type checker verify the form of the code, its syntax, its style, its idioms. None of them says whether the architectural decision it implements is the right one. On both projects that decision is nonetheless written in a format the machine can check, and that changes what the gate covers.

On the gateway, the founding decision (ADR-0004) states that the OpenAPI and AsyncAPI specs are the only configuration, with no proprietary DSL. The spec does not document the gateway, it defines it, and it is compiled into an artifact. Validation is strict by default, with a non-conforming request rejected as a 400. This is no longer a matter of code style: checking that a request conforms amounts to checking the architectural decision itself.

Still at the client, the netbox-data repository applies the same principle to infrastructure. YAML files are the authoring surface, OpenTofu reconciles NetBox from them, a plan is posted on every PR, and a scheduled job does drift detection, filing an issue whenever someone changed NetBox by hand rather than through the repository.

The most instructive part is the test suite that ships with that YAML. Its docstring states what it is looking for:

These tests catch the kinds of bugs that mocked unit tests can’t: unquoted # comments dropping data […], duplicate slugs, references to undefined manufacturers.

The # case deserves a word, because it illustrates the limit of review nicely. In YAML, a # preceded by a space inside an unquoted value opens a comment, and everything after it on the line is ignored at parse time. The file stays valid, the data is simply lost. A human reviewer can catch that sort of thing, but not reliably and not every time. A test that parses the real files catches it systematically.

Not everything can be written that way. No schema captures the choice between two equally valid service decompositions, nor the fact that a technically correct decision may be a bad one for the team that will operate it. Those trade-offs stay on me, and I read them in the ADRs rather than in the code.

What it costs

The method has a cost, and it is better stated up front.

  • Writing an ADR takes longer than writing the corresponding code. It stays worth it because the code will be rewritten and the decision will not, but on a 200-line script it is waste.
  • A corpus has to be maintained. At 85 ADRs, some decisions supersede others. Without discipline on the Status and Superseded by fields, you are no longer documenting an architecture, you are piling up its history. That is real, thankless work, and it demands a lot of rigour.
  • Gates have a price. 16 CI jobs means minutes on every PR, caches to maintain and flaky tests to chase. A gate that is too slow ends up bypassed, with a --no-verify or a job flipped to non-blocking.
  • A strict gate needs documented escape hatches. The gateway’s deny.toml explicitly ignores two advisories, each with its rationale in a comment (transitive dependency, no safe upgrade). Without that release valve, the first emergency turns the strict gate into a disabled one.
  • The relevance threshold is real. My rule of thumb: more than one participant, counting agents, or a lifespan beyond a few months. Below that, prototype freely.

Where to start

The order below is the order of return on investment.

  1. An ADR template, and a first ADR deciding that you will use ADRs. Five minutes.
  2. A conventions file the agent can read (CLAUDE.md, .agent/conventions.json), containing above all the verification command.
  3. Blocking gates, as strict as the team can bear: a pre-commit hook, code quality, a test coverage floor.
  4. Only then, delegate widely, without ever stepping out of the loop.

The non-determinism of an LLM is what makes it useful: it produces options I would not have thought of on my own. It becomes a risk at the precise moment it reaches the final artifact with no decision procedure in between. The work consists of putting one there, then patiently handing it a larger and larger share of what I used to read myself.