Your agent probably has a context budget somewhere. Maybe it's a cap on retrieved chunks, or a limit on tool output. Here's a different question: how many separate pieces of code add text to the request your model actually receives? I don't mean how many you designed. I mean how many exist right now. I thought I knew the answer for my own system, and the real number was more than twice what I had registered.
The plan listed five context lanes and the code had seven
Vodou builds each turn's prompt from lanes: recalled memory, this turn's tool output, a skill body, the session bootstrap, and the conversation history. The design doc listed five. Then an audit of the code found seven. A daemon-side auto-router and a session-start workspace bootstrap had both been writing into prompts, and the plan mentioned neither. Nobody decided to have seven. Each writer made sense locally.
So we agreed on one shared per-turn budget, with one arbiter deciding which lane gives up space when a turn runs long. Eleven minutes after we agreed, an eighth injector landed: a host rules file with a private budget of its own. Its author wasn't being careless. There was nowhere to register it, so they made the reasonable local choice and picked a number.
That became the ticket. Give new writers a place to register, and make skipping it fail the commit.
lanes.toml: one stanza per writer, each with a budget and a trust label
The registry sits at the root of the repo. Each lane that writes into a prompt gets one stanza. The stanza names the injector, a budget, a priority (0 means never evicted, 5 means evicted first), a trust label (owner, tool, child, policy, model), and a description of how overflow is handled, written so a receipt can show it. The budget is a character count, or "session" if the lane is paid once per conversation, or "uncapped" with a comment explaining why. The file is TOML. Here are the same fields as YAML:
name: tool_results
injector: MCP-servers/Vodou-Console/src/llm.ts assembleContext
budget: 1500
priority: 2
trust: tool
evicts: "cut at the budget with a '[N lines omitted]' marker the model reads"
Rule 8 in scripts/coherence-guard.py enforces it. The rule reads the added lines of a commit under the gateway, daemon and hook trees and pulls out lane-name literals:
LANE_LITERAL = re.compile(r"""\blane\b\s*[:=]\s*['"]([a-z][a-z0-9_]*)['"]""")
LANE_TERNARY = re.compile(
r"""\blane\s*:\s*[^,]*?\?\s*['"]([a-z][a-z0-9_]*)['"]\s*:\s*['"]([a-z][a-z0-9_]*)['"]""")
If a staged line introduces a name with no stanza, the commit exits 1 and the error names the lane. A stanza with no budget or no trust label fails too, because a registered lane with no budget is just the private budget again. I tested it on a temporary index. An unregistered lane was rejected by name, a registered one passed, and every lane literal already in the tree resolved to a stanza. At runtime the gateway reads the trust label back out, and each turn's receipt records how many characters each lane spent.
What Rule 8 does to a commit
The day-after recount found 9 of about 23 writers registered
The guard shipped with eight entries. One was the cached system prompt, which nobody had counted as a lane because a cache hit felt free. The next day I recounted by reading every place that writes into a prompt. The registry covered 9 of about 23.
The cause was built into the guard's design. It fires on a lane-name literal, and a site that never uses a lane name can't be seen by it. Operator instructions were appended word for word with no cap at all. They're capped at 600 characters now. A scope block was appended at five separate per-provider call sites, all of which ran after the single assembler had finished. Page text had its own 20,000-character cap, declared inside its own file. Attached documents, channel message envelopes, a rolling summary and job follow-up turns had all found their own way in. Several already had budgets, privately. I copied those numbers into the registry instead of inventing new ones, because a registry that disagrees with the code is worse than no registry.
A 1,000-character budget, 1,200-character rows, and nothing objected
Next I checked the registry against the code, and it was wrong in ways I hadn't expected.
The header said budgets were in tokens, but every consumer counted characters. A budget in the wrong unit is a number nobody can check. We changed the header to match what gets measured.
The hook-memory lane declared 1,000 characters, and receipts had recorded 1,200-character rows with no complaint, because no size check existed. That lane is really bounded by time, a 2.5-second deadline in the daemon. I kept 1,000 as the target and wrote down the real bound next to it.
On 2026-08-30, nine declared lanes had zero rows in the turn event log, for three different reasons. Some had no producer. Some had a producer and no traffic. For page context, the emitter worked, but its only caller sat behind a toggle that had been removed from the build that ships. That's a third category: unreachable. I added an emits field (log, receipt, or none) and a test that checks it against the code. Four lanes have still never emitted a row.
The last problem went the other direction: double counting. Two lanes were retired because their bytes already sat inside another lane's logged payload. A third, connections, wrote a section into the bootstrap packet, and the bootstrap lane already logged that packet whole. The registry-versus-emitter check went red on main when PR 123 added it and stayed red until PR 127 removed it. The rolling summary lane was retired and later came back, once a producer emitted it under its own name.
Invariant: every byte sent to the model has exactly one declared owner
Stated as a property you can check: for every model request, the text characters in the request body equal the sum of characters attributed to declared lanes. Every declared lane also has a producer that can run in the shipped build. That rules out three failures: dark bytes (text no lane claims), ghost lanes (declared, never produced) and double counts (one span claimed twice). A commit guard only approximates the first one, by looking at names. Measuring is the only way to catch all three.
Diff the request body against what your assembler accounted for
Run this against your own stack. Record how many characters your assembler thinks it produced, then count what actually goes over the wire, at the HTTP client, after every late append has happened:
import contextvars, json, httpx
from openai import OpenAI
accounted = contextvars.ContextVar("accounted", default=0)
def assemble(parts): # parts: [(lane_name, text), ...] from your own builder
accounted.set(sum(len(t) for _, t in parts))
return parts
TEXT_KEYS = {"system", "messages", "content", "text", "input", "instructions"}
def text_chars(node):
if isinstance(node, str):
return len(node)
if isinstance(node, list):
return sum(text_chars(n) for n in node)
if isinstance(node, dict):
return sum(text_chars(v) for k, v in node.items() if k in TEXT_KEYS)
return 0
def audit(request: httpx.Request):
if not request.url.path.endswith(("/chat/completions", "/messages", "/responses")):
return
sent = text_chars(json.loads(request.read()))
print(f"sent={sent} accounted={accounted.get()} dark={sent - accounted.get()}")
client = OpenAI(http_client=httpx.Client(event_hooks={"request": [audit]}))
A passing run prints dark=0 on every turn. Headers like ## Memory count, so give them to the lane that writes them. A failing run prints something like dark=3140 that shows up only on some routes, or grows as a conversation gets longer. That growth is a summary, a scope block or an envelope added after your accounting ran. A negative dark means you counted text that was later truncated or dropped, or you counted a span twice. The Anthropic client takes the same http_client argument.
For a quick static count before you instrument anything, run this and compare the result to the number of budgets you can name from memory:
git grep -nE "role['\"]?\s*:\s*['\"](system|user)['\"]|messages\.(push|append|insert|unshift)\(|(system|prompt)\w*\s*\+=" -- '*.ts' '*.js' '*.py' | wc -l
Injection research assumes the spans are already labelled
The strongest work on indirect prompt injection tracks where content came from. ROPE enforces policy based on the origin of what the agent reads. AttriGuard asks whether a tool call follows from the user's intent or from external data. CausalGuard taints untrusted flows. All three assume the system already knows which parts of the prompt are untrusted. In my code, a third of the writers didn't have a label to hand over. A trust model can only cover the writers it knows about.
ContractGuard makes the matching point about registries: "the gate is only as honest as its contracts." Mine had a wrong unit, a ceiling nothing enforced, and four entries with no producer. Poisoning is only one way a registry lies. Neglect produces the same result. Agent registry guidance registers agents and their system prompts, not the many code paths that edit a prompt after it's built. Verification-loop guides check an agent's output before it writes. None of these check what went into the prompt in the first place.
A writer that never spells a lane name still passes the guard
The guard works on names in the code. Anyone adding a new append that never uses the word lane gets through, the same way the 14 writers the recount found did. So the recount is still something I have to run by hand. The trust label also doesn't protect anything yet. The last time I counted, eight of nine untrusted lanes reached the model with no fence text around them. Only tool results were fenced. The label tells a receipt who wrote the text, but nothing tells the model. And the hook-memory ceiling is still a number that nothing checks.
Source: Your prompt has more writers than your context budget knows about by Chad Priest, from Building Vodou in Public.


Top comments (0)