feat(agent): a permission for who may write above the cache line
Problem
agent.toml already governs what an agent may do. From the scaffolded template at src-tauri/src/core/agent/project.rs:197-203:
# read-only | deny | allow. read-only (default) exposes MCP tools and built-in
# reads; built-in writes/exec go through the permission gate. deny locks down
# ...
# Exposed even under deny; deny-list wins over everything:
deny = []Nothing governs what may write into the prompt. Every one of these composes into the same string, and none of them declares where it lands:
assistant instructions ─┐
project memory ─┤
skills ─┼──► one system prompt ──► byte 0 of the request
MCP-supplied guidance ─┤
plan-mode addendum ─┤
todo addendum ─┤
runtime environment ─┘
seven writers · zero declarations · one shared cache line#8957 and #8958 fix two of these seven. That is a fix, not an invariant — the eighth writer lands next quarter and the regression comes back, because nothing in review says "this goes above the cache line."
Prior art: the failure is compositional
oh-my-pi #11897 is the exact shape of this. Two pi.on("context") handlers each appended a per-request message. Each one was locally correct and reviewed as such.
0 injectors baseline $0.28 / call
1 injector 2.90× still tolerable
2 injectors consumed both rolling ← the cliff
cache breakpoints
3 injectors 4.3× $2.54 / call
measured across 17 sessions: $130.02 actual vs $25.69 due = 80.2% overspend
one 14-hour session alone: $1,281 wasted
98.7% of input tokens written to cache and never read backNo single diff introduced this. Neither handler's author could have seen it, and the agent's output looked completely normal throughout. This is a systemic failure, so it needs a systemic control — the same argument that made [tools] a config section rather than a convention.
Proposed change
Extend the existing permission model to prompt composition, with the same deny-wins semantics.
[tools]
default = "read-only"
deny = []
+
+ [prompt]
+ # Where a component's contribution is placed. Deny-wins, same as [tools].
+ # "prefix" — above the cache line. Must be constant for the session.
+ # "tail" — after the accepted history. Free to change per turn.
+ default = "tail"
+
+ # Components allowed to write into the cached prefix. Everything else is
+ # placed in the tail regardless of what it asks for.
+ prefix_allow = ["assistant_instructions", "tool_schemas"]Enforced at composition time, not by review:
compose_prompt(components)
for each component
placement = policy.resolve(component.id) # deny-wins
if placement == prefix
assert component.is_constant_for_session # hard failure, not a warning
prefix.push(component)
else
tail.push(component)Three rules make this hold:
- Default to tail. A new writer is safe by default and has to argue its way into the prefix. That is the inversion that stops the eighth writer from being the next #11897.
- A prefix component must be constant for the session. Not "usually stable" — constant. Assert it; a component that varies is a bug caught at composition rather than on the invoice.
- Deny wins, matching
[tools], so an operator can lock a route down without auditing every skill.
Surface the resolved placement in jan cli agent status, next to the tool permissions it already prints (core/cli/mod.rs:535-543). "Which of my skills is writing into the cached prefix" should be one command, not an investigation.
Acceptance criteria
- A
[prompt]section inagent.tomldeclares placement per component, defaulting totail. - Placement is resolved with deny-wins precedence, matching
[tools]. - Prompt composition enforces the policy; a component cannot reach the prefix without an explicit allow.
- A component declared
prefixthat varies within a session causes a hard failure, and a test asserts it. - A newly added composer defaults to
tailwith no config change, and a test asserts the default. -
jan cli agent statusreports the resolved placement for every registered composer. - Documented in the
agent.tomlspec alongside[tools].
Related
- Runtime counterpart to #8965's compile-time guard test. #8965 catches a new writer in CI; this one bounds the damage when something reaches a user's machine anyway.
- Turns #8957 and #8958 from fixes into invariants.
Source: janhq/jan