Two tool calls, two refusals, and neither command touched a database. That was the first live spawn of a new lane-manager role on haiku. The decision gate blocked a cd plus a read-only board query, and the role reported NEED instead of improvising around it. That was the right behavior, but it left me wondering why the gate fired.
What the gate saw
The command was:
cd cockpit-roles/platform-manager && hq lane-board --lane platform resumeThe gate has a touches-prod heuristic that matches any path containing /platform. Our role folder is cockpit-roles/platform-manager, so the folder name alone made a board read look like a prod-DB touch. The role’s own habit of cd-ing into its folder caused it, not a registry bug. I added one line to the role’s CLAUDE.md: run commands bare from the cwd you were already given. Spawn 2, fresh with no transcript from spawn 1, read the board bare and succeeded.
That is a workaround. The real fix is exempting the roles directory in the gate, or requiring the matched token to look like an actual clone name. I haven’t done it, and it is still open.
The grant I shouldn’t have written
The role’s .claude/settings.json is a dontAsk allow-list. It started with the five hq verbs it needs, and I also added four read-only git verbs so it could check history without asking anyone:
Bash(git log:*) Bash(git status:*) Bash(git diff:*) Bash(git show:*)They looked harmless. hq role check failed on them anyway. test_no_role_is_granted_broad_bash flags any bare interpreter grant, because a bare git is a way to run things outside the registry. That is exactly the escape an earlier ticket had closed. The cost was a failed check and a settings file I had to rewrite. I removed all four, and the role now has zero non-hq Bash. If it needs history, it asks a verb that goes through the registry.
Why the small folder matters
The point of the role is that it spawns from its own directory with almost nothing to load. Claude reads the CLAUDE.md chain from the cwd upward. For the role that is its own 3.6KB, the roles directory’s 4.1KB, and the 15.7KB global file. There is no repo README, no skills and no project hooks.
I measured it from the run records (.runs/claude/*.json), same haiku model both times:
| Spawn | Prompt | cache_creation_input_tokens |
|---|---|---|
| Role, from its own folder | reply ok | 5,452 |
Main hub repo, --no-safe-mode | 47 characters | 18,806 |
That is about 3.5x the context built before the worker does anything. It is also a sanity-checkable number. 23.4KB of CLAUDE.md at roughly 4 bytes per token is about 5.9k, the same range as the 5,452 measured.
This is not a clean apples-to-apples test. Skills, hook output and anything else the hub repo loads vary run to run, so 3.5x describes these two calls and is not a constant. But the direction matches what the design predicted, and the only variable I changed was where the work started.
Memory without a transcript
A worker this small needs memory that survives a fresh spawn, so I added a write-back verb, hq role memory-set <role> <topic>. Roles have no Write or Edit and can’t read outside their cwd, so this verb is the only way one can touch its own memory. It refuses:
- topics not listed as
writable(rulesdeliberately isn’t, so a role can be told a rule but can’t rewrite one) - path traversal and non-slug topic names
- bodies with frontmatter, a digest fence tag or control characters
- bodies over the topic’s
max_bytesormax_lines - bodies that push the role over its total
cap_bytes
Writes go to a .tmp file, then os.replace. Each write is logged to .writes.jsonl. Twenty-five new tests cover the refusal shapes, and each one asserts the directory is untouched afterward.
The live proof was spawn 3, fresh again, told to run no commands. It quoted both saved lessons verbatim and stated a parked ticket’s status and date. Its only source was the <persistent_memory> block in its prompt.
Where it stands
hq role check passes, 103 role tests pass, and a wider run of 537 passes. Seat routing by venture also works end to end. The router moved this role from main to a second seat because main’s weekly quota was at 70% and running out Tuesday, and the run record confirmed the seat that actually ran.
Unverified: no Opus spawn, no real ticket write, no second role to prove the pattern generalizes, and the gate false positive above is still a workaround. The 3.5x is what I have, and I got it from a run record instead of a guess.