# Your prompt has more writers than your context budget knows about

> A lane registry and a commit guard stopped new context writers arriving without a budget. A recount still found about 23 where 9 were registered.

- Author: Chad Priest
- Published: 2026-09-14
- Canonical URL: https://blog.vodou.ai/prompt-context-writers-lane-registry-guard/
- Tags: llm, ai-agents, architecture, observability

---

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:

```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:

```python
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.

**Diagram: What Rule 8 does to a commit**

A staged line naming a lane is checked against the registry for a budget and a trust label before the commit is allowed

```text
  [Staged line] --> [lanes.toml]
  [lanes.toml] --no stanza--> [Commit fails (problem)]
  [lanes.toml] --stanza--> [Budget and trust both set?]
  [Budget and trust both set?] --either missing--> [Commit fails (problem)]
  [Budget and trust both set?] --both--> [Commit passes (fixed)]

  notes:
    Staged line: lane: 'x'
    lanes.toml: stanza for x?
    Commit fails: names the lane

  The guard reads names. It cannot see a writer that never names itself.
```

## 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.

**Diagram (timeline)**

From seven unplanned injectors to a guard, a recount, ghost lanes and double counts

```text
  audit   ->  plan says 5 lanes, code has 7 (problem)
  Aug 27  ->  guard ships, 8 stanzas (fixed)
  Aug 28  ->  recount: 9 of ~23 registered (problem)
  Aug 30  ->  9 lanes with zero rows, emits field added (problem)
  Sep 3   ->  2 lanes retired for double counting
  Sep 10  ->  connections retired, rolling_summary earns its way back (fixed)
```

## 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:

```python
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:

```bash
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](https://arxiv.org/abs/2608.27496) enforces policy based on the origin of what the agent reads. [AttriGuard](https://www.usenix.org/system/files/usenixsecurity26-he-yu.pdf) asks whether a tool call follows from the user's intent or from external data. [CausalGuard](https://github.com/112Hunter112/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](https://arxiv.org/html/2606.18550) 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](https://aitutorialmaker.com/knowledge/what_are_the_essential_agent_registry_best_practices_for_securing_and_managing_ai_agents_in_production.php) registers agents and their system prompts, not the many code paths that edit a prompt after it's built. [Verification-loop guides](https://self.md/guides/agent-verification-loop/) 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](https://blog.vodou.ai/prompt-context-writers-lane-registry-guard/) by Chad Priest, from Building Vodou in Public.
