Skip to content
All articles

What makes a codebase easy for an AI agent to change?

More explicit semantics, less representation: how linking specs to code lets AI agents navigate a codebase by intent.

For most of my 20+ years writing software, choosing a language or a framework meant weighing the same list: productivity, runtime performance, ecosystem maturity, hiring, operational complexity, maintainability. This year I added a question to that list, and it changed how I design systems.

If a large part of a codebase is going to be written and maintained by models like GPT, Claude and GLM, what makes that codebase easy for an agent to understand, modify and verify?

That is a different question from "which language does an LLM generate best from a short prompt". An agent working on a real application has to find the relevant files, recover the architectural intent, understand the invariants, change several modules in a coordinated way, run the tests, read the failures, and do all of that again hundreds of times. Context becomes an engineering resource. Every ambiguity in the architecture has a cost, and every duplicated representation of a concept is one more place for things to drift.

The principle I landed on is the first line of the AGENTS.md in the Welaborate repository, the project-management app I'm building almost entirely with agents: maximize explicit semantics while minimizing representational and architectural overhead. This edition is about what that means in practice, why it took me to Elixir and Phoenix for this product, and how we connected OpenSpec requirements to functions and tests so the agents can navigate by intent instead of by grep.

Where I agree with Akita, and where I get off

Fabio Akita published a thought experiment in February asking what programming would look like if source code were designed for AI agents rather than for humans. He goes well past comparing Rust, Elixir and TypeScript. He imagines human intent written as a fairly formal specification, agents working on a richer machine representation (types, effects, dependencies, invariants, provenance), and readable source code as a projection over it.

I agree with the part most people skip: provenance. Knowing which requirement caused a piece of code to exist is enormously useful to an agent. Where I get off is the conclusion that we need a new language or an intermediate representation to get there.

One tempting reading of his article is that if machine-verifiable semantics are good, we should pick the most formal language we can find. Rust over Elixir, Idris over Rust, Lean over Idris, until we're writing proofs. That misses how coding agents actually work. They don't operate only through compilers. They operate through context, and they have to understand the application before they can touch it. A system that proves everything but needs five times the representation to say "an admin can remove a member" is harder for an agent, not easier.

Semantic density

Two concepts made this concrete for me. Explicit semantics is how much the code makes visible about what values mean, which states are valid, how transitions happen, which failures are expected, what side effects occur, and who owns a behavior. Representational overhead is how many artifacts the system needs to express that behavior: modules, DTOs, schemas, interfaces, client types, infrastructure components. The ratio between the two is what I started calling semantic density.

This changes an old rule. Lines of code were always a poor quality metric, and they still are. But when an agent is doing the maintenance, volume has a new consequence: it is retrieval surface. A feature spread across fifteen modules in two languages forces the agent to discover, load and reason about all fifteen before it can safely change anything. More retrieval means more chances to miss one. More representations mean more chances for inconsistency. The target is less representation per unit of domain behavior, not fewer lines.

The other half is what I'd call solution entropy. A model knows Prisma, TypeORM, Drizzle, MikroORM and Sequelize. It knows several queues, several validation libraries, several ways to structure a NestJS app, several state managers. That flexibility is useful for a human team. For an agent, every legitimate choice is one more decision it makes without you, and three different models will make it three different ways.

Why not TypeScript, then?

The fair objection is that TypeScript has something Elixir historically lacked: a mature static type system. A discriminated union like { type: "success" } | { type: "already_following" } | { type: "blocked" } is checked by the compiler, narrowed, and exhaustively handled. From the verification angle that is excellent, and strong verification matters.

Two answers. First, verification is one of at least four dimensions an agent runs into: how many reasonable ways there are to implement something, how much representation expresses it, how much of the repository must be understood, and how easily tooling can prove the implementation wrong. TypeScript is great on the fourth. Elixir and Phoenix are interesting because of the first three. (And the type story is moving: Elixir 1.20 now infers types and gradually type checks programs without annotations.)

Second, convention. In TypeScript I can build a beautiful Result<T, E> type and require it everywhere. The next respectable TypeScript codebase uses exceptions, the next one returns undefined, the next one uses neverthrow. The language allows all of them. In Elixir, {:ok, value} and {:error, reason} are not merely possible, they are what everyone writes. The best abstraction for an agent is often not the most expressive one. It's the one whose ecosystem already converged on how to use it.

Why Elixir and Phoenix for this app

The case is the combination, with no single feature carrying it. A standard Phoenix application already has one conventional answer for the questions an agent would otherwise have to decide: domain logic lives in contexts, persistence is Ecto, validation is changesets plus domain functions, transient broadcasts are PubSub, durable background work is Oban, process lifecycle is supervisors, interactive UI is LiveView. The agent doesn't invent an architectural vocabulary per project. Phoenix has gone far enough down this road that version 1.8 generates an AGENTS.md by default with every new app, specifically to stop frontier models from making common Elixir mistakes. Framework authors now treat agents as a consumer of conventions. That is a small feature with a big implication.

OTP adds what I think of as semantic compression. "If this worker crashes, restart it without taking its siblings down" is a supervision tree and a restart strategy. The source is tiny, the runtime semantics behind it are large and well understood, and the agent selects a primitive instead of writing fault recovery. Supervisor, GenServer, PubSub, Presence and Oban each do that for a chunk of application mechanics. The BEAM doesn't make distributed systems easy (durable state, consistency, partitions and failover are still real problems), but a large class of concurrent application behavior stays inside one programming model, which means fewer external systems for both me and the agent to hold in our heads.

LiveView does the same on the representational side. In an API plus React setup, one domain concept shows up as a migration, an entity, a service, a DTO, an endpoint, a serializer, an OpenAPI schema, a generated client type, a query hook, client state, a form, a component, and two test suites. Every layer can be justified, and the agent still has to find all of them and propagate every change through each one. With LiveView the path is spec, Elixir domain, LiveView, HTML. JavaScript is not forbidden (maps, rich text and media processing earn their hooks); what I'm avoiding is a second application architecture the browser didn't ask for.

Why not Rust, Go or Rails? Rust maximizes explicit semantics, but for "create a team" or "remove a member" the borrow checker is overhead unrelated to the product. Go was the hardest comparison and is probably the strongest alternative for autonomous backend services: small language, consistent tooling, no magic. It trades density for simplicity, and it doesn't come with OTP's integrated lifecycle model. Rails is where I spent much of my career, and it scores very high on density and convention. Rails with Hotwire may be one of the best stacks there is for agent-driven CRUD. I went with Phoenix because this product will grow into realtime collaboration and background work, and I'd rather not change programming models when that happens.

Connecting specs to code without a new source of truth

This is the part I'd like you to steal.

The Welaborate app is developed with OpenSpec, by Fission AI. Product intent starts in a PRD, becomes a proposal, then behavioral specs, a design document and tasks, then implementation and verification, and finally the change is archived and its deltas merge into the baseline specs. The specs describe what the system does, not how.

I liked Akita's idea of provenance, but I didn't want a semantic database or a generated index to get it. So every requirement gets a stable, domain-prefixed ID, and every scenario extends its requirement's ID. Here is a real one from the teams capability:

### Requirement: Remove a member [ACC-TEAM-R003]

Owners and admins SHALL be able to remove any member except the owner and
except themselves. Removal MUST revoke the person's access to the team
immediately and MUST NOT delete anything they created...

#### Scenario: Removed person loses access on the next request [ACC-TEAM-R003-S02]

- **WHEN** a removed person who is still signed in makes their next request in that team
- **THEN** it is refused with the message "you are no longer a member of <Team>"

The public domain operation carries the same ID in its documentation metadata, and its typespec states the outcomes an agent would otherwise have to dig for:

@doc openspec: ["ACC-TEAM-R003", "ACC-INV-R005"]
@spec remove_member(Scope.t(), Ecto.UUID.t()) ::
        {:ok, Membership.t()}
        | {:error, member_management_error() | :cannot_remove_self}
def remove_member(%Scope{} = scope, membership_id) do

And the scenario gets an executable counterpart:

@tag openspec: "ACC-TEAM-R003-S02"
test "removed person is refused on the next request" do

The design document of each change closes the loop with a traceability table from ID to operation to verifying test. That's the whole mechanism: Markdown, module attributes and ExUnit tags. No graph database, no YAML manifest, no special spec language. Today the repository has three capabilities (accounts, teams, team invitations), 25 requirements, 108 scenarios and around 170 scenario-tagged tests, over roughly 8,000 lines of application code and 7,000 lines of tests.

What it buys is a navigation axis. The AGENTS.md tells the agent to move from requirement ID to context to public operation to scenario-tagged tests, and only then into internals. Instead of "which of these 200 modules touch membership removal", the question becomes "what implements ACC-TEAM-R003", and the answer is one function and a handful of tests. Documentation follows the same rule: explain purpose, authorization, valid and invalid transitions and visible effects, never narrate the code. And I deliberately keep IDs out of private helper comments. Traceability lives at semantic boundaries or it becomes noise.

The agent is at the bottom of the authority order

The last piece is what keeps three models from building three architectures. Every model has preferences. One adds a service layer, one reaches for a new dependency, one uses a GenServer where a table would do, one buries business logic in a LiveView event handler. If GPT, Claude and GLM can all modify the same repository, the repository has to be more authoritative than any of them.

So the AGENTS.md defines an authority order: the explicit instruction, then the approved OpenSpec specs, then the active change's design, then the project's architectural rules and ADRs, then the PRDs for intent, then existing code, and only then the agent's judgment. Existing code does not override an approved spec. A PRD provides intent, not a contract. And the file reads like constraints, not style preferences: mutations go through context APIs; LiveViews are adapters, not owners of logic; expected failures are tagged tuples; durable state is PostgreSQL and never only a GenServer; durable jobs are Oban and ephemeral broadcasts are PubSub; new infrastructure needs a demonstrated requirement in a design doc or an ADR; no Redis, Kafka or RabbitMQ because another ecosystem uses them; IDs are permanent; implementation is test-first, one task group at a time. (Oban isn't even installed yet. The rule says to add it inside an approved change that needs durable jobs, not before.)

The objective is to reduce how much the agent has to infer. Which ORM, which queue, where does this live, how do errors work here, where is durable state, how does this worker restart: when the environment has one conventional answer to each, that reasoning disappears and the budget goes to the actual requirement.

What this does not prove

I have not run a benchmark showing that a Phoenix implementation uses fewer tokens or ships fewer defects than a NestJS one. That would take controlled multi-step changes, several models, repeated runs and equal specs. The argument here is architectural: lower solution entropy, higher semantic density, smaller retrieval surfaces, fewer duplicated representations and explicit contracts should help agents. Those mechanisms are plausible enough that I'm building on them, and they should be measured. That may be a future edition.

The lesson is also not "use Elixir". For a storage engine, Rust. For small independent services, Go. For a CRUD business app, Rails is very hard to beat. The general lesson is the criterion: evaluate a language and a framework by how much relevant semantic information they expose to the agent relative to how much representation the agent has to manipulate. That criterion barely mattered when humans wrote all the code.

We spent decades optimizing software for human cognition: naming, modules, design patterns, IDE navigation, code review. All of it exists because human attention is limited. Agents have different limits: finite context, imperfect retrieval, tool-call cost, and a tendency to pick a reasonable pattern that isn't the one the codebase uses. AI-native engineering keeps that discipline and recognizes that the consumer of the architecture has changed.

If your team is adopting agents and the output is getting harder to review rather than easier, the fix is usually in the environment, not the model. Send me a message or use the link in the first comment to book a free 30-minute diagnostic call.

Sources