Skip to content

9 min read

An instruction loaded for the session travels into every package

On this page

The pull request is titled "fix: retry transient SMTP failures" and it touches packages/notifications/, a package that has sent email the same way for three years by throwing on failure and letting the caller's error boundary deal with it. The reviewer stops on line 31:

ts
// packages/notifications/src/send.ts
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

export async function send(msg: Message): Promise<Result<void, SmtpError>> {
  const attempt = await transport.deliver(msg);
  if (!attempt.delivered) {
    return { ok: false, error: new SmtpError(attempt.code) };
  }
  return { ok: true, value: undefined };
}

Nobody asked for this. Result is not a type this package has ever used, so the agent defined it locally, and the eleven call sites it then updated now branch on .ok while the rest of the package still uses try/catch. The convention comes from packages/checkout/CLAUDE.md, added six weeks earlier by the payments team, which says that checkout code returns Result rather than throwing because a thrown exception in the payment path had once been swallowed by a middleware and turned into a silent order loss.

The author of that PR had spent the morning in packages/checkout/. Around noon she asked the same session to look at an unrelated flaky email test, and everything the agent had read that morning was still in the conversation.

Discovery narrows what is loaded, not what it applies to

The directory-scoped file did its job on the discovery side. It was found because the working directory was inside packages/checkout/, and a session that had never gone near that package would not have seen it. What the directory boundary does not do is participate in generation. Once the text is in the conversation, it is 40 tokens of instruction sitting alongside every other instruction, with no metadata attached saying which paths it came from and no step in the loop that re-evaluates the instruction set when the agent opens a file in a different package.

The shell comparison is close enough to be useful. export RUST_LOG=debug in a terminal sets a variable for that shell and everything launched from it, including the commands you run an hour later in a different directory, whereas RUST_LOG=debug cargo test scopes the variable to one invocation. Instruction files are the first kind. There is no per-command form, and there is no cd that unsets them.

Sequence: engineer → repository (work in packages/checkout/); repository → session context (load checkout/CLAUDE.md ("return Result")); engineer → session context (turn 4 — "fix the flaky email test); session context → repository (edit packages/notifications/src/send.ts); session context → engineer (send() now returns Result<void, SmtpError>).

It is tempting to call this a design flaw, and the alternative is worse than it sounds. Re-evaluating the instruction set on every file touch means rebuilding the prefix of the conversation each time the agent changes directory, which invalidates the prompt cache — the provider's reuse of an unchanged prefix, billed at a fraction of the normal input rate — and costs both money and latency: a 3,000-token instruction block re-sent uncached on each of forty file operations in a session is 120,000 tokens of input that would otherwise have been cached at a fraction of the price, and the added latency lands on every single tool call rather than once at the start. It would also produce behaviour that is harder to reason about than what we have, since the agent's understanding of the task would shift underneath it mid-edit — a refactor that spans two packages would be governed by two different conventions depending on which file was open at the moment of generation. Loading once per session is a defensible trade, and the cost of the trade is that scope becomes a property of the text rather than of the loader.

The likelihood that a travelling rule fires is not uniform, and the pattern is worth knowing because it tells you which rules to worry about. Instructions phrased as general engineering principles — prefer this error style, name things this way, avoid this pattern — travel readily, because nothing in the sentence limits them and they read as universally good advice. Instructions containing a proper noun the other package cannot satisfy travel poorly; "always call PaymentLedger.record() after a state change" does not fire in the notifications package because there is no ledger there. On the team where this happened, an audit of one week of sessions found the Result convention had reached 8 of the 23 files edited outside packages/checkout/, while a checkout rule about idempotency keys had reached none of them.

Make the sentence carry its own boundary

Since the loader will not restrict the instruction, the text has to. That means the scope goes inside the sentence, close enough to the imperative that a model reading the paragraph cannot separate them, and it means naming the boundary in terms the model can evaluate against the file it is currently editing.

markdown
<!-- packages/checkout/CLAUDE.md — before -->
## Error handling

Use `Result` types rather than throwing.
markdown
<!-- packages/checkout/CLAUDE.md — after -->
## Error handling in packages/checkout only

Files under `packages/checkout/` return `Result<T, E>` from
`@acme/result` rather than throwing, because a thrown error in the
payment path was swallowed by middleware in INC-2291 and lost an order.

This applies to `packages/checkout/**` and nowhere else. When editing a
file outside that path in the same session, follow that package's own
conventions; the rest of the monorepo throws, and converting a file to
`Result` is a deliberate migration that needs its own ticket.

Four things changed and each does separate work. The heading names the path, so the constraint survives even when only headings are skimmed. The imperative sentence contains the path rather than referring to "this package", because this resolves against whatever the agent believes it is working on. The reason is stated, which gives a model something to reason with when it hits an adjacent case — an incident number and a mechanism make the rule feel local to a specific hazard rather than like general good practice. And the negative case is spelled out, since a rule that says where it applies still leaves open what to do elsewhere, and "follow that package's own conventions" is the instruction that keeps inherited defaults intact.

The last part is the one teams skip. A nested file that lists everything the package does, including the parts it shares with the rest of the repository, is not scoping — it is a fork of the root file that will drift. If the root says integration tests are required for new endpoints and the checkout package agrees, the checkout file should say nothing about integration tests at all, so that when the root policy changes there is exactly one place to change it. The nested file earns its existence only through the instructions that differ.

Nested file containsEffect on discoveryEffect on driftWhen it is right
Only the differences, boundary namedAdds ~40–200 tokensRoot changes propagateAlmost always
Differences plus restated shared rulesAdds 400+ tokensRoot changes silently divergeRarely
A full replacement of the root's topicsDoubles the assembled textTwo policies, one repositoryWhen the package is genuinely a different product

Scoping every line is its own failure. A file where all fourteen bullets begin with "Files under packages/checkout/" reads as boilerplate, and the qualification stops carrying information once it is on everything — the reader, human or model, skims past a phrase that never varies. Reserve the explicit boundary for the instructions that would be plausible advice elsewhere, which in practice is a small minority: error-handling style, naming conventions, testing strategy, dependency preferences. A rule about the checkout package's ledger table does not need a boundary because it has one built in.

Measuring whether it leaked

The audit that produced the 8-of-23 number is cheap enough to run monthly. Take the package-local conventions that are phrased generally, pick a token that would appear in code following them, and count occurrences outside the package in commits authored through the agent.

bash
$ git log --since='1 week ago' --author='Agent' --name-only --pretty=format: \
    | sort -u | grep -v '^packages/checkout/' > /tmp/outside.txt
$ wc -l < /tmp/outside.txt
23
$ git grep -l 'Result<' -- $(cat /tmp/outside.txt) | wc -l
8

This is a blunt instrument and it is enough to turn an argument into a number. Two of the eight in that run turned out to be legitimate — an existing utility package had used Result for years — which is the ordinary rate of false positives for a grep and does not change the conclusion. Running it again after rewriting the checkout file is what produced the 1-in-19 figure below, and the comparison is only meaningful because the same command was run both times.

Boundaries in prose are not sandboxes

Naming the path improves the odds and provides nothing you could describe as containment. The instruction is still 60 tokens of English competing with everything else in a 3,000-token assembly, and on a long session where the agent has been writing Result returns for two hours, the pattern in the recent conversation carries weight of its own regardless of what the instruction file says. The measured improvement on the team above was from 8 leaks in 23 files to 1 in 19 over the following week, which is a good return on twenty minutes of editing and is not zero.

The mechanisms that actually contain a convention live outside the instruction text, and they are the ordinary ones. A lint rule that forbids Result in packages/notifications/ fails the build in eight seconds whether or not the model was persuaded. A per-package TypeScript config that does not export the Result type makes the import fail. Neither of these is about agent configuration, which is the point: the containment guarantee comes from the same tools that contain a careless human, and it holds under the same conditions.

The other lever is the session boundary itself, since the travelling text is a property of the conversation rather than of the repository. Finishing the checkout work and starting a fresh session before touching notifications removes the checkout file from context entirely, at the cost of the accumulated understanding of the task that made the session useful. Delegating the email fix to a subagent — a second agent the host starts with a context of its own, whose result comes back into the parent conversation as a summary — achieves something similar without losing the main thread, if the host supports it. Both of these are worth knowing because they are the only interventions that make the instruction genuinely absent rather than merely qualified.

It is worth being clear about what directory scoping is not, because two adjacent ideas get attached to it. It is not access control: nothing about a scoped file prevents the agent from reading, editing or deleting code in another package, and a team that hears "scoped to packages/checkout/" and pictures a boundary the agent cannot cross has bought a guarantee nobody sold them. It is also not the mechanism for excluding files from being read — that is a separate ignore mechanism, and confusing the two produces instruction files placed in directories in the hope that the agent will stay there.

The conflicts that scoping cannot dissolve

Everything in this chapter treats leakage as an accident: a rule written for one place, applied in another, fixable by saying where it belongs. Some pairs of rules are not accidents and cannot be fixed this way, because both are about the same code and both are meant. The organisation requires conventional commit messages across every repository and one team's project file mandates a local prefix scheme for the same commits, which is not a leak in any direction; both statements are exactly where their authors intended, both are in context, and the agent alternates between them. Naming a boundary does nothing there, because neither rule is out of bounds.

That kind of collision has to be settled by a decision about who owns the policy, followed by an edit that makes the losing side stop speaking. The next chapter is about making that decision explicitly rather than letting load order make it, and about the case where the local rule is a genuine exception rather than a rival, where neither file can go silent and the resolution has a different shape.

Scoping and precedence — read the current version at https://daviddominguez.dev/learn/en/scoping-and-precedence/an-instruction-loaded-for-the-session-travels-into-every-package

Back to top