Chapter 5 of 6 · 9 min read
Whatever enters context becomes editable in practice
On this page
The task was small. services/checkout needed the tenant identifier that packages/shared-auth puts in a session token, so the builder pulled in the checkout module, its tests, and — for completeness, because the reviewer of the builder had argued that partial views of a dependency cause exactly the local-rules failures of the previous chapter — the whole of shared-auth, 4,200 lines and about 52k tokens.
The patch came back in two parts. The checkout half was correct and well-tested. The other half was this:
--- a/packages/shared-auth/src/claims.py
+++ b/packages/shared-auth/src/claims.py
@@ -88,9 +88,9 @@ def decode_claims(raw: str, *, audience: str) -> Claims:
payload = _verify_signature(raw)
- if payload.get("aud") != audience:
- raise InvalidAudience(payload.get("aud"))
+ if payload.get("aud") not in (audience, None):
+ raise InvalidAudience(payload.get("aud"))
return Claims(**payload)Checkout's staging tokens are minted by a fixture that omits aud, the audience claim that names the one service a token may be presented to, so the strict check was failing there, and relaxing it was a two-line change that made the checkout tests pass. CI was green. It was green because the test selector runs the suites belonging to the services touched by a diff, and its dependency graph covers service-to-service imports and does not traverse from a package back to the fourteen services that consume it. Six days later, a token minted for the internal admin console was accepted by the payments service, which is what an audience claim exists to prevent.
The reviewable failure is the CI selector, and it was fixed. The failure that concerns context selection happened earlier, at the moment the builder decided that shared-auth's implementation belonged in the window.
The model did what an engineer would do
There is nothing surprising in the patch. Given a failing test, a fix inside your own module that requires understanding a fixture, and a fix in a dependency that is two lines and obviously works, plenty of engineers would take the second one — and the ones who do not are usually stopped by something other than judgment, by not having write access, by knowing fourteen teams will be paged, or by a review that will bounce it. None of those exist for the agent. Its access is whatever the harness grants, its knowledge of consumers is whatever is in the window, and there are no consumers in the window.
So what looks like a boundary violation is better described as the absence of a boundary. The model saw one repository with two directories in it and no signal that they were owned differently, since ownership in this monorepo lives in CODEOWNERS, in a package.json field, and in the heads of the four people who maintain shared-auth. Context conveyed none of that. It conveyed 52k tokens of editable Python.
Measuring it makes the effect concrete rather than anecdotal. Across 300 runs on this monorepo, tasks where an out-of-scope package's implementation was in context produced a patch touching that package 12% of the time. When the same tasks ran with only the package's interface in context, the figure was 1.3%, and every one of those attempts was a call to a function that did not exist rather than an edit — a failure that surfaces immediately as an AttributeError instead of shipping.
The security world has a name for the shape of this. Ambient authority is the property of a system where any code that can name a resource can act on it, and the fix is to make the ability to act require holding something specific, so that not holding it is the default. Context is the agent's ambient authority: everything in the window is nameable, and everything nameable is editable by a tool that takes a path. Withholding an implementation is the closest thing a context builder has to not handing over the capability. It is a weaker guarantee than a real one — more on that below — and it moves the base rate by an order of magnitude, which is what the 12% against 1.3% is saying.
What the interface view contains
The alternative is to give a dependency a different representation from the file being worked on. What checkout needed from shared-auth was the shape of Claims, the signature of decode_claims, and the guarantee its docstring makes about the audience check — all of which fit in a fraction of the implementation.
# packages/shared-auth — interface view, 1,940 tokens (implementation: 52,000)
@dataclass(frozen=True)
class Claims:
subject: str
tenant_id: str
audience: str
issued_at: datetime
expires_at: datetime
def decode_claims(raw: str, *, audience: str) -> Claims:
"""Verify and decode a session token.
Raises InvalidAudience if the token's `aud` does not match `audience`
exactly. A missing `aud` is an error: tokens are always minted with one.
Test fixtures must use shared_auth.testing.mint(audience=...) rather
than constructing payloads directly.
"""
class InvalidAudience(AuthError): ...
class ExpiredToken(AuthError): ...Three properties of that view are doing the work. The docstring states the invariant the deleted patch violated, in the place the model will read it, which is more effective than the same sentence in a design document nobody retrieved. The reference to shared_auth.testing.mint names the sanctioned way to solve the fixture problem, so the easy fix that exists is the correct one. And the absence of function bodies means there is no two-line change visible to make; the model can call this package and cannot reach into it, which is the dependency boundary rendered as tokens.
Generating these views is mechanical. A Python interface view is stubgen output — the tool ships with mypy and prints a module's declarations and types with every body removed — plus the docstrings it drops by default, a TypeScript one is the .d.ts declaration file the package already emits beside its compiled JavaScript, and either can be built once per package and cached against the commit, which matters because building it per task at 52k tokens of parsing is a latency cost paid on every run. Where a package publishes a real interface — a .pyi stub file, an OpenAPI document describing an HTTP surface, a .proto schema — that artifact is the view and no generation is needed.
The rule that decides which representation a package gets is the task's target. If the task is to change shared-auth, the implementation enters and the fourteen consumers' call sites enter with it, because that is the change whose blast radius — the set of callers an edit can break — has to be visible. If the task is anything else, shared-auth appears as its interface. This is the same distinction as the previous chapter's skeleton and it is drawn for a different reason: the skeleton was about spending tokens where they teach the most, and the interface view is about what the run is entitled to touch.
That gives three levels rather than two, and being explicit about the third one avoids an argument teams keep having with themselves. The reviewer who asked for the whole of shared-auth was applying the previous chapter's lesson correctly to the wrong file: local rules matter for the file you are editing, and a package you are only calling has no local rules you need, because everything you are entitled to rely on is in its published contract.
| level | what enters | for | failure it accepts |
|---|---|---|---|
| implementation | full file, or header + skeleton | the file being changed and its immediate collaborators | the token cost, and the distraction cost of sibling bodies |
| interface | signatures, types, docstrings, exceptions | dependencies the task calls but does not own | a genuine bug inside the dependency stays invisible |
| absent | nothing; the name may still appear in an import | packages the task neither calls nor owns | the agent cannot discover a capability it should have used |
The bottom row is the one that gets skipped, and it is where most of the budget saving lives. A monorepo has a hundred packages and a task touches four; the other ninety-six are not interface views waiting to be generated, they are absent, and the retrieval step's job is to decide which four are which.
A dependency of this technique that nobody mentions is documentation quality. An interface view is signatures plus docstrings, so a package whose docstrings say """Decode a token.""" produces a view that is a type signature with decoration, and the model calling it has to guess at the invariants — which is how the audience check got relaxed in the first place, since nothing it could see said the check was load-bearing. Teams that adopt interface views find themselves writing better docstrings for exactly the functions their agents call most, because the effect is immediate and visible in the patches. Where the docstrings are hopeless, the cheaper substitute is to include the package's own tests as the contract, since a test that asserts InvalidAudience is raised for a mismatched aud states the invariant in a form that cannot go stale.
The boundary is soft, and needs a hard one behind it
An interface view lowers the probability of a cross-boundary edit and does not prevent one. The model can still write to a path it has never read, and coding agents do this routinely and legitimately — creating a new test file, adding a migration. A patch that guesses at the contents of shared-auth/claims.py and rewrites it is unlikely and not impossible, and the 1.3% figure above is not zero.
So the durable version of the rule is a write allowlist derived from the same decision that shaped the context. The builder already knows the task's target package; the harness takes that decision and enforces it on the write path, rejecting a patch that touches anything outside it with an error the agent can read and act on.
{
"task": "CHK-4417",
"target_packages": ["services/checkout"],
"writable": ["services/checkout/**", "services/checkout/tests/**"],
"readable_interface_only": ["packages/shared-auth", "packages/ui-kit"],
"on_violation": "reject_with_message"
}The rejection message matters more than the rejection. services/checkout is the only writable package for this task; shared-auth changes need a separate ticket and its consumer tests gives the agent somewhere to go, and the run that produced the incident above would instead have ended with the checkout fixture fixed, or with an honest report that the task needs a change it is not allowed to make. The second outcome is a good outcome, since a task that genuinely requires touching a shared package should surface as a decision for a human rather than as a green build.
The other thing the allowlist buys is an audit trail, which the incident above conspicuously lacked. Reconstructing what had happened took two days, and most of that was establishing what the agent had been able to see, because the context was assembled in memory and thrown away. Persisting a manifest per run — the resolved list of paths, the representation each got, its token count, and the policy version that produced it — turns that reconstruction into a query, and it is a few hundred bytes against a run that costs dollars. It also makes the 12%-against-1.3% comparison possible to compute at all, since the question "was the implementation in context" is only answerable if someone wrote it down.
Two things this does not do are worth stating. It does not protect against a change that is confined to the target package and still breaks consumers — an edit to checkout's own public API has the same blast-radius problem, and the interface view has nothing to say about it. It also does not help when the boundary is wrong: if shared-auth genuinely needs a tenant_id accessor, a policy that keeps the agent out of it slows the correct change down. Both are arguments for the boundary being explicit and reviewable rather than for it being absent.
That leaves the shape of the whole policy unaddressed. Interfaces for dependencies, skeletons for context, a ceiling on the first turn's fill and an eviction order are four rules that each solve one failure, and a builder needs a single default that says what enters for an arbitrary task — along with the exceptions for the parts of a repository that break the default, which is where a policy earns or loses its keep.
Context selection over a codebase — read the current version at https://daviddominguez.dev/learn/en/context-selection-over-a-codebase/whatever-enters-context-becomes-editable-in-practice