Skip to content

9 min read

Precedence has to be written down, not left to prompt assembly

On this page

Two engineers filed the same complaint in the same week. One had asked the agent to add an endpoint to services/billing and got handlers that raised InvoiceNotFound and let the middleware map it to a 404. The other had asked for an endpoint in services/reporting, an almost identical task, and got handlers that returned Result.err(NotFound(...)) wrapped in a try/except that logged the traceback and swallowed it. Both engineers reviewed the other's PR, both were annoyed, and the shared conclusion in the retro was that the agent is inconsistent about error handling and someone should tell it to pick one.

The agent was consistent. Four sources reached it, all four said something about error handling, and no two of them agreed:

SourceWhat it says about errorsScope
Root AGENTS.mdRaise domain exceptions; the HTTP middleware maps them to Problem responsesWhole repository
services/reporting/AGENTS.mdNo exceptions across the service boundary; return Result[T, E]services/reporting/**
Skill add-http-endpointWrap handler bodies in try/except Exception and log with request contextInvoked when the model judges it relevant
Task prompt"Match the style of the surrounding module"This run

Given those four inputs, both outputs are defensible. The billing run had the root rule and no package override, so it raised. The reporting run had the root rule, a package rule contradicting it, a skill body that the model loaded and that contradicted both, and a task instruction pointing at the surrounding code — and it produced a blend of all four, which is what a reasonable reader of four conflicting documents produces. The inconsistency the engineers observed lives in the documents, and the agent is the component that made it visible.

Conflict is the normal state of a monorepo

The instinct after a retro like that is to eliminate the disagreement: one file, one rule, one error-handling convention across the repository. That instinct is wrong for the same reason a single lint configuration across a monorepo is wrong. services/reporting returns Result because it is called over a queue where an unhandled exception loses the message; services/billing raises because it sits behind an HTTP boundary with middleware that already knows how to render domain errors. Both are correct locally. A single root rule would be correct in one package and actively harmful in the other, and the team would spend the next year adding parenthetical exceptions to it until it was a cascade again, only undocumented.

So the goal is not agreement between sources. It is a stated, checkable answer to the question when two loaded sources disagree, which one governs, and the answer has to live somewhere a human can read and a script can apply. Three ways of getting there are in common use, and they trade off differently:

ApproachWhat happens on conflictCostFails when
One flat root file, no package configsNo conflict by constructionEvery rule ships to every session; file grows to cover every package's special casePackages genuinely differ; the file becomes a list of exceptions
Cascade with implicit nearest-winsWhatever the assembler concatenates last, or whatever the model prefersFree, until you need to explain a behaviourNobody can say which rule applied; the ordering is a harness implementation detail
Declared scope plus written precedenceResolved by a rule you wrote, before the request is builtA resolver, and metadata on each rulePrecedence itself is ambiguous, which is why the rule must be short

The third is what the rest of this chapter builds, and the reason it wins here rather than universally is the shape of the repository: eleven services, four shared packages, six teams, and rules that legitimately differ by subtree. In a single-service repository with one team, the flat file is correct and a resolver is ceremony.

Two sentences of policy

The whole precedence rule fits in two sentences, and it should, because a precedence rule that needs a page is one nobody will apply consistently.

The most specific source wins, where specificity is path depth of the declared scope, and a skill counts as more specific than any config file it was invoked from. The task prompt overrides convention, except for rules marked non-overridable, which nothing overrides.

Making that operational requires two pieces of metadata on every rule. The rule needs a declared scope — the glob it applies to, meaning a path pattern such as services/reporting/** that a program can match a file against, rather than the directory the rule happens to sit in — and it needs a flag saying whether a task instruction may override it. Both go in frontmatter on the rule itself rather than on the file, because a single AGENTS.md usually mixes an invariant with three pieces of taste:

markdown
---
id: err-001
scope: "**/*.py"
overridable: true
owner: platform-runtime
---
Raise domain exceptions from handlers. The HTTP middleware maps any
subclass of `DomainError` to a `Problem` response with the right status.

---
id: money-001
scope: "**/*.py"
overridable: false
owner: payments
---
All monetary values use `money.Money`. Never a `float` or a bare `Decimal`,
including in tests and fixtures.

money-001 carries overridable: false because a task prompt saying "just use floats for now, it's a spike" should not be able to switch it off; the failure mode is silent rounding error in production ledgers. err-001 is overridable because a task that says "this handler is called from the worker, return a Result" is a legitimate local decision. Roughly one rule in eight earns the non-overridable flag in practice. If a third of the file is marked non-overridable, the flag has stopped carrying information and the model will treat it the way it treats bold text, which is as decoration.

The resolver is small. It takes the discovered sources, expands globs against the files in play, sorts by specificity, and drops rules that a more specific source contradicts:

python
def resolve(rules: list[Rule], target: str, task_overrides: set[str]) -> list[Rule]:
    """Return the rules in force for `target`, most specific last."""
    applicable = [r for r in rules if fnmatch(target, r.scope)]
    applicable.sort(key=lambda r: (r.scope.count("/"), r.source_depth))

    winners: dict[str, Rule] = {}
    for rule in applicable:                      # later = more specific
        if rule.topic in winners and winners[rule.topic].overridable is False:
            continue                             # non-overridable already held
        winners[rule.topic] = rule

    for topic in task_overrides:
        held = winners.get(topic)
        if held is not None and held.overridable:
            del winners[topic]                   # task prompt takes it
    return list(winners.values())

The topic field is the part that does the real work and the part teams leave out. Two rules only conflict if they are about the same thing, and a machine cannot infer that raise domain exceptions and return Result are about the same thing from their text. Tagging each rule with a coarse topic — errors, money, logging, db-access — is a five-minute job on a hundred-line file and it converts conflict detection from a judgement call into a dictionary lookup. It also surfaces conflicts at authoring time: adding a second errors rule with a narrower scope now visibly shadows the first.

A dozen topics covers a large repository. On this one the list is errors, money, logging, db-access, http-client, auth, testing, migrations, imports, config, concurrency and docs, and the exercise of assigning them took an afternoon and immediately retired four rules that turned out to say the same thing as an existing one in different words.

Resolve before you assemble, not after

There is an implementation choice hiding in all of this, and it is the one that determines whether precedence does anything at all. The resolver can run as documentation — a script that explains, after the fact, which rule ought to have won — or it can run as part of prompt construction, so that the text injected into the request is the resolved set and the losing rules never reach the model.

The second is what makes the policy real. If both err-001 and err-014 are concatenated into the request, the model sees a contradiction and settles it by whatever means models settle contradictions, which is exactly the unspecified behaviour the chapter opened with. If the harness injects only err-014 for files under services/reporting, there is no contradiction to settle. For the reporting endpoint, the injected block becomes:

text
[instructions resolved for services/reporting/api/exports.py]
money-001 (non-overridable): All monetary values use money.Money...
err-014: No exceptions cross the service boundary. Return Result[T, E]...
log-003: Use obs.log, never print...

Assembling from the resolved set has a second benefit that shows up on the bill, which is that a package's session no longer carries rules scoped to ten other packages. On this repository the injected instruction block dropped from about 3,500 tokens to about 1,200 for a typical single-package session, because most of the root file is only relevant to a subtree.

The cost is that resolution is now a piece of infrastructure that can be wrong, and when it is wrong the rule is simply missing rather than outvoted, which is harder to notice. The mitigation is the log below, and a periodic check that the resolved set for a sample of paths still contains the rules a human expects. Some teams choose a middle position where the losing rule is injected with a marker — superseded here by err-014 — which preserves the reasoning at the cost of the tokens and of trusting the model to honour the marker.

Log what was resolved, per run

Precedence that is not logged is precedence you cannot audit, and the whole point of the exercise is that surprising behaviour should trace back to a source. Emit the resolved set as a record alongside the run:

json
{
  "run_id": "0f3c9a",
  "target": "services/reporting/api/exports.py",
  "resolved": [
    {"id": "money-001", "scope": "**/*.py", "source": "AGENTS.md", "overridable": false},
    {"id": "err-014", "scope": "services/reporting/**", "source": "services/reporting/AGENTS.md",
     "shadows": ["err-001"]},
    {"id": "log-003", "scope": "**/*.py", "source": "AGENTS.md"}
  ],
  "skills_invoked": ["add-http-endpoint"],
  "suppressed_by_task": [],
  "instruction_set_hash": "sha256:7b1e…"
}

The shadows array is what would have ended the retro in ten minutes: err-014 beat err-001 for this file, by policy, and the record says so. The hash is more useful than it looks, because it gives every run a label for the exact instruction configuration it ran under, which makes it possible to ask questions of the log — did compliance change when the instruction set changed, are the failures concentrated in runs with one particular hash, did anyone notice that a config edit last Tuesday changed the resolved set for thirty packages. Without the hash those questions require reconstructing the state of the repository at each run.

The skills_invoked line deserves attention, because a skill that loaded mid-run is a source that arrived after the resolver did its work. Either the resolver runs again when a skill body loads, or skills are exempted from precedence and simply win by recency, which is the pragmatic choice most harnesses make and which is worth knowing rather than discovering.

The add-http-endpoint skill from the opening table is a good illustration of what to do about that. Its instruction to wrap handler bodies in try/except Exception was written for a service that no longer exists, it contradicts both error rules, and it wins by arriving last and closest to the work. Giving skills the same frontmatter as rules — a declared scope, a topic, and an explicit statement of which rules they may override — brings them under the same policy, and the version that shipped here dropped the try/except guidance entirely and deferred to whatever errors rule was resolved for the target file. A skill that restates a convention is a fourth copy of it that will fall out of date independently.

What precedence does not buy you

A resolved instruction set is a statement about what should have applied. It is not evidence about what the model did, and the gap between the two is exactly the thing this topic is trying to measure. A rule can win precedence cleanly, appear once in the request, and lose anyway to forty files of contrary code, which the previous chapter's trace showed happening to a rule that had no competitor at all.

Neither does precedence make the rules good. A resolver applies err-014 over err-001 with total confidence regardless of whether err-014 is correct, current, or written by someone who left the company. Mechanising the conflict resolution removes one class of confusion and creates a new obligation, which is that somebody has to own the content.

The immediate consequence is that the team can now state, for any run, which rules were in force. The next question is whether stating that changes any outcome, and the honest answer at this point is that nobody knows: the argument for the whole apparatus is still an impression. The next chapter turns it into a number by running identical tasks with the conventions and without them.

Repo conventions as agent input — read the current version at https://daviddominguez.dev/learn/en/repo-conventions-as-agent-input/precedence-has-to-be-written-down-not-left-to-prompt-assembly

Back to top