Paths Are Promises

Paths Are Promises

Some of the most consequential decisions in a codebase are the ones nobody remembers making. Where a file lives is one of them. Loa, the framework layer our repos share, kept its grimoire and its state files at fixed locations for a long time, and those locations quietly became load-bearing. Scripts assumed them. Documentation restated them. Agents, reading that documentation, treated them as facts about the world rather than choices someone once made.

A drawer in the plate room opened to a different depth that week; the emulsion remembers it differently.
A drawer in the plate room opened to a different depth that week; the emulsion remembers it differently.

We recently promoted a decision to change that. Grimoire and state file locations are now configurable through .loa.config.yaml, with environment overrides (LOA_GRIMOIRE_DIR, LOA_BEADS_DIR, LOA_SOUL_SOURCE, LOA_SOUL_OUTPUT) and a single rollback switch, LOA_USE_LEGACY_PATHS=1. The change requires yq v4 or later. That is the whole surface area. This essay is about why a change that small took deliberate design, and what it taught us about running repos where the primary readers of documentation are agents.

A hardcoded path is a promise you did not mean to make

In a repo maintained by humans, a hardcoded path is a mild annoyance. Someone who wants a different layout reads the source, finds the constant, and works around it. Humans are good at recognizing that a string literal is an implementation detail.

Agents are not, or at least not reliably. An agent reading a document that says "state lives in this directory" will write to that directory, reference it in new documentation, and generate scripts that assume it. Each generation of derived work hardens the assumption. Across a fleet of repos that share the same framework, this compounds: the path stops being a detail of one repo and becomes an unwritten invariant of all of them. The path was never an interface, but it got treated like one, and things that get treated like interfaces eventually have to be maintained like interfaces.

Making the locations explicit configuration is how we convert that accidental promise into a deliberate one. The config file is the statement of intent. Everything downstream can read intent instead of guessing at convention.

Leave a lamp where the old path used to turn.

The override chain is the actual design

The list of knobs is short, but the ordering among them is the part we spent the design effort on. The pattern will be familiar to anyone who has read the twelve-factor notes on config: configuration that varies between deployments belongs in the environment, and configuration that expresses a repo's standing intent belongs in a checked-in file. We use both, layered.

The .loa.config.yaml file is the durable layer. It travels with the repo, it is visible in review, and an agent auditing the repo can read it without reconstructing any runtime state. The environment variables are the ephemeral layer, for the cases where a single invocation needs to point somewhere else: a test harness writing to a scratch directory, a migration reading from the old location while writing to the new one, a sandboxed agent that must not touch the real state files.

This is the same layering that git's own configuration uses, with system, global, and local scopes each able to override the last, and it is the layering the XDG base directory specification codified for desktop software. We did not invent anything here, and that is the point. When agents encounter a precedence scheme, the best gift you can give them is a scheme they have already seen a thousand times in training data and in the wild. Novelty in configuration semantics is a cost, and it is a cost paid on every future read.

The rollback flag is the most important line

LOA_USE_LEGACY_PATHS=1 restores the old behavior in one move. It would have been easy to skip. The migration was simple, the new defaults match the old locations for most purposes, and rollback flags have a way of feeling like an admission that you do not trust your own change.

We think the opposite. A rollback flag is what makes a change like this safe to ship across a fleet rather than one repo at a time. Any single repo can hit an edge we did not anticipate: a script with the old path baked in, a state file referenced from outside the repo, an agent mid-task with stale context. Without the flag, the remedy for any of those is a revert, coordinated across whatever else has landed since. With the flag, the remedy is one environment variable, applied locally, while the underlying issue gets fixed at leisure. The flag converts an incident into a note.

There is also an agent-specific reason. When an agent encounters breakage, the cheapest recovery path is one it can execute without judgment calls. "Set this variable, then file the issue" is a recovery procedure an agent can follow correctly on the first try. "Assess whether to revert" is not.

What the change costs

The honest ledger has a debit: the path resolution now depends on yq, and specifically on v4 or later, whose expression syntax is incompatible with earlier versions. A framework that previously needed nothing beyond a shell now needs a specific tool at a specific major version to read its own configuration.

We accepted this rather than hand-rolling a YAML parser in shell, which is the kind of decision that looks like avoiding a dependency and is actually acquiring a worse one, maintained by nobody. But we want to be plain that it is a real cost. Version-gated dependencies are exactly the sort of thing that fails differently across machines, and "requires yq v4+" is now a sentence that has to appear anywhere the setup is described. Declaring the requirement loudly, in the decision record itself, is the mitigation we chose.

Paths as interface

The general lesson we keep relearning across the fleet: anything an agent can read will eventually be treated as an interface, whether or not it was designed as one. File locations, error message wording, directory naming conventions, the order of fields in a config file. The choice is never between having an interface and not having one. It is between an interface you declared, with a config file, an override chain, and a rollback flag, and an interface that accreted, with none of those things and all of the same consumers.

Declaring it costs a design discussion and a few environment variables. Letting it accrete costs a migration later, performed under load, with the promises already made and no record of who made them. We know which side of that trade we want to be on, and this change, small as it is, is us paying the cheap price on purpose.


Get the next post

Free membership: new posts on how the fleet is built, delivered by email. Subscribe free

№ 4052