test(session) [BRNS-DESK-037]: the [context] net stops keying on how a builder is spelled - #75
Conversation
…a builder is spelled
Closes BRNS-DESK-037.
`ledger-net.test.ts` ends with an audit that searches `src/`, finds every file
that builds a `[context]` block, and asserts the set is exactly the files in its
`BUILDERS` allowlist — so no unreviewed block can reach the wire. It was not
enforcing that. It proved "no unlisted file spells `"[context]` literally, in
what is left of the file after the comments are stripped out", which is a
narrower claim, and both halves of that qualifier were load-bearing.
**A builder that reuses the shared constant was invisible.** `BUILDS_A_BLOCK`
matched the marker only when spelled out as the opening of a string, so
`handover.ts:37`, which builds `HANDOVER_PREFIX` by interpolating
`CONTEXT_MARKER` into a template, was never seen — and it is not a hypothetical:
`buildHandoverPreamble` reaches the prompt through `seed()` on every vendor
switch. The incentive was backwards. Reusing `CONTEXT_MARKER`, the correct thing
to do, was exactly what hid you from the net; copy-pasting the literal was what
got you caught.
**The comment stripper deleted source before searching it.** `code()` stripped
block comments with `/\*[\s\S]*?\*\//g`, which cannot tell a comment opener from
the `/*` inside `import.meta.glob("./apps/*/app.json")`. That opened a span at
`main.ts:48` and closed it inside the next glob at `:56`, deleting 237
characters of removable-app discovery unread.
Measured against a string-aware stripper over the same tree, 71 files came out
cut differently, 44,691 characters in all — the worst single case 9,159
characters of live Rust in `engines/context/src/area_tests.rs`, where a `<!--`
inside a string paired with a `-->` 349 lines later. None of the spans the two
disagreed about held a marker, so the audit's answer was right, by luck.
The two blind spots were also entangled, which is why neither was found by
staring at one of them. `handover.ts` spells the marker in its own header prose,
inside a block comment — so the old detector would have caught it, on the wrong
evidence, if the stripper had not deleted that comment first.
**The stripper is gone rather than fixed.** A stripper is the half of this that
can fail in silence: every way it goes wrong deletes text, and text it deletes
is a builder it stops seeing. Writing a better one means knowing code from
comment in TypeScript, Svelte and Rust — string literals, template
interpolation, regex literals, Rust raw strings and lifetimes — inside a test
file, where the bugs would be found the same way this one was.
So nothing is stripped. The search covers whole files, which means prose counts,
which means every file that so much as names the marker has to be listed. That
sounded expensive until it was measured: 20 files under `src/` name it, once the
test tree is set aside. The cost is a line per file. What it buys is that a
builder cannot hide behind a lexing bug, because nothing is lexed.
**One list, not two.** The natural shape here is `BUILDERS` plus `MENTIONS`, and
it is the wrong one: nothing can check which array a file belongs in — telling a
comment from a string literal is precisely the lexing this change deletes — so
the second array is an escape hatch whose only property is which name somebody
typed. A real builder filed under `MENTIONS` passed the suite. There is now one
list, with `builds` noted inline on the nine that spell a block; the notes are
documentation and membership is the assertion.
**Git does the searching.** The audit runs one `git grep --files-with-matches
-z -I --untracked --exclude-standard -F -e '[context]' -e CONTEXT_MARKER --
src`, instead of listing paths and reading each one itself. Re-implementing
`git grep` is how the reading half acquires bugs of its own, and the read
version had them: a path listed a moment ago and deleted since throws `ENOENT`,
so a stale list reddens as a stack trace rather than as the diff that names the
file; a merge lists a conflicted path once per stage; a symlink to a directory
is a path that does not open as a file; and each of the 16 `.woff2` files under
`src/` was decoded as lossy UTF-8 and searched as something that was not the
file. `-I` skips binaries outright.
Asking git also means reading git's exit codes rather than its output alone, and
1 is two answers wearing one number: "nothing in the tree matched" and "that
pathspec is not there". The first is real and empty; the second is what a
`REPO_ROOT` resolved one directory off produces — and it would arrive as a
twenty-line assertion diff blaming the list rather than the root. So an empty
result is refused outright: this module imports from `src/`, so a tree where
nothing under `src/` names the marker is the one answer that cannot be true.
`--untracked --exclude-standard` scopes it to what this repo would ship or
commit, which is two claims. A builder written this minute is audited before it
is ever staged. And a git-ignored local-only app cannot redden this tracked
test — CLAUDE.md §6 allows one to exist and this file must not know about it —
with `node_modules` and `target` falling out for the same reason rather than by
a hand-kept skip list. Paths come back repo-relative with forward slashes on
every platform, which is how the list is written; `toEqual` on arrays is
order-sensitive, so a `src\layout\…` would be a phantom failure only a Windows
dev ever sees.
**What it deliberately does not cover is written down beside it.** The test tree
is the one exclusion, and it earns itself: 20 test files name the marker today,
none of them can put a block on anyone's wire, and carrying them would double
the list and churn it with every new spec. Its `.rs` arm is the one that had
been missing — a Rust test module fell through a TypeScript-shaped filter and
had to be carried on the allowlist as though it shipped.
Nothing else is dropped, `vendor/` included. The old walk skipped it, on the
reasoning that CLAUDE.md says to re-vendor rather than edit; true, and still a
directory the net could not see into. Nothing under `vendor/` names the marker
today, and if a re-vendor brings one in it costs one line.
The net still cannot see a block built from a DERIVED prefix — a module that
imports `HANDOVER_PREFIX` and appends to it spells neither form — nor anything
outside `src/`, `src-tauri/` included, which has no net of this kind at all, nor
anything git ignores, nor a live builder written inside the test tree. Callers
are not builders and are not listed: that a block reaches the wire is the
arithmetic half's claim, not this one's.
**And the arithmetic half gains the block it had never seen.** `INJECTORS` had
twelve entries and none of them was a handover, which `prompt-blocks.ts` calls
"the single most expensive thing a turn can carry". It was already correct — a
block in a 469-character prompt, gap 0, one row reading `handover: to OpenAI
Codex` at both doors — so this is coverage of an untested path, not a bug fix.
The fixture it carries holds a BLANK LINE inside one turn, because a blank line
is this convention's block delimiter: a carried transcript containing one is
where a seed would split into two paragraphs and the second would reach the
model unrowed. `verbatimTurn` collapses runs of newlines for exactly that
reason, and nothing had ever charged it for the job.
The handover row is pinned by its LABEL rather than by its prefix. Asserting
`block.startsWith(HANDOVER_PREFIX)` compares the constant with itself — writer
and reader share it — while the row title only appears if `handoverBlockLabel`
claimed the block. Without that pin a broken prefix still adds up: the seed
falls through to prompt-blocks' mention fallback and is titled after the wrong
thing, with every number in this file still zero. The snippet pin is a genuine
cross-module check by contrast, and now runs against BOTH of the builder's forms
— the singular sentence and the numbered list open the same way, and only the
plural was ever asked.
`INJECTORS` is also spelled out as a type now, so `sent` cannot be left off a
new entry — an omitted flag reads as false and the entry would be audited on one
door instead of both without saying so.
A second entry closes a claim the comment above `gap()` had been making without
proof. The subtraction charges two characters of separator per row on the
grounds that `withContextBlocks` puts a blank line after every block, the last
one included — so a prompt that is nothing BUT a block still balances. Every
entry had a typed half, so nothing had ever asked. One `@`-mention sent with no
words is 204 characters, one row, 202 of them charged, gap 0.
**The rest is comments and a gitignore line.** `prompt-blocks.ts` is comments
only: its header counted four hidden block kinds where the type beside it lists
six, and the note on `SNIPPET_BLOCK_PREFIX` still told you the builders' net
keys on the spelled-out literal, which this commit is what makes false.
`.gitignore` gains `*.orig`, `*.rej` and `*.bak`: untracked-but-not-ignored is
not harmless once the audit covers untracked files, because a `handover.ts.orig`
left behind by a conflict — or a `turn-context.ts.bak` kept while editing — is a
second copy of a real builder reddening a test that has found nothing wrong.
Three suffixes are not the whole of that class, and the class has no edge; what
they buy is that the two ways it actually happens here are covered.
`ci.yml` and `README.md` describe the Windows
vitest step from #71, whose justification named the `join()` walk this commit
deletes; the hazard moved rather than left, from `join()` to an absolute `-C`
root that `fileURLToPath` spells `C:\…` there.
Verified locally on macOS, Node 22:
- `npx vitest run --no-cache …/ledger-net.test.ts` → 32 passed
- full gate green: `check` (740 files, 0 errors), `lint:size`, `lint:imports`,
`lint:manifests`, `lint:resources`, `lint:css-vars`, `npm test`, `npm run build`
- BRNS-DESK-034's two-directional mutation check, extended to twenty-one and run
by hand (never committed). Seventeen must fail and did: an unlisted probe
written via `CONTEXT_MARKER` and naming the literal nowhere; the same written
with the literal; the same hiding behind a string holding a `/*`; one in a
`.md` prompt asset; one under `vendor/`; one inside the tracked removable app;
one never git-added; a listed builder removed; a listed path mistyped; an entry
for a file naming nothing; one file listed twice; the marker pattern dropped
from the search; a broken `handoverBlockLabel`; `verbatimTurn` no longer
collapsing blank lines; the singular snippet form's opening drifting; a `.orig`
copy of a builder with the new gitignore line removed; and a listed file
deleted from the worktree, which is the case that shows the reading half's
bugs are gone — the old reader met it with an `ENOENT` stack trace and this one
answers with a diff naming the path. Four must pass and did: a git-ignored
local-only app naming the marker in prose, which CLAUDE.md §6 requires this
tracked test to stay green for; that same `.orig` copy WITH the gitignore line;
a `.bak` copy an editor left beside a builder; and a probe written inside the
test tree, which is the documented blind spot behaving as documented. The
baseline passes before all of them.
- separately, by hand: an `INJECTORS` entry without `sent` is a `svelte-check`
error rather than a silently half-audited row, and `git grep` over a pathspec
that is not there exits 1 exactly as an empty tree does — which is what the
refusal above is for.
The file WARNs at 515 lines against the 400-line soft ceiling and carries the
size note CLAUDE.md rule 3 asks a test module for; the hard ceiling is 600.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nir-ssvlabs
left a comment
There was a problem hiding this comment.
The rewrite does what it claims, checked by running its own detector against this tree: the git grep query now returns src/layout/core/runtime/spine/handover.ts — the builder that composed the marker from the constant and was invisible to the old literal match — and the 20-entry NAMES_THE_MARKER list is exactly the detector's non-test output, so the allowlist is not drifting from what the net actually finds.
It also enforces rather than describes: dropping a new file under src/ that names the marker makes the detector return 21 paths against a fixed 20-entry list, so the toEqual reddens. Deleting the stripper instead of fixing it is the right call for the reason given — every way a stripper fails, it fails by deleting text, and deleted text is a builder it stops seeing.
- 🔵 The blind spot the header names — anything outside
src/,src-tauri/included — is real but unoccupied today: nothing undersrc-tauri/names the marker, and the only non-srcmatches arescripts/eval/specs and checks, which are eval assets rather than wire builders. Worth knowing the gap is currently empty rather than merely unaudited. - 🔵
Pixel eval (headless)is red and not from this branch — it also failed on at least five unrelated branches in the last day whilemainpassed, and it is the same job red on #74. Not a gate here, but somebody should own it; a permanently red check is one nobody reads.
Checked: the detector query run against this checkout, the allowlist against its output, enforcement by planting a rogue builder, and the outside-src/ gap. Not read: the vitest run itself — no toolchain here; Frontend, Rust (macOS) and Eval are green, Rust (Windows) still pending at review time.
Merge: ✅ into dev, once Rust (Windows) lands green.
|
Six of seven legs pass, including the one that matters most for this change: That is the claim #71's step exists to hold. The hazard it watches moved from On the pixel leg: this branch's only non-test change under The goldens were last captured at A local baseline on a clean Re-capturing the self goldens is a deliberate change of its own and does not belong in a test-only PR; it needs |
stefan-ssv-labs
left a comment
There was a problem hiding this comment.
✅ review-pr (self-review, cannot approve own PR): clean — no blockers or criticals found.
Closes BRNS-DESK-037. Test-only: no product behaviour changes.
What was wrong
ledger-net.test.tsends with an audit that searchessrc/, finds every filethat builds a
[context]block, and asserts the set is exactly the files on itsallowlist — so no unreviewed block can reach the wire. It was not enforcing
that. It proved a narrower claim: "no unlisted file spells
"[context]literally, in what is left of the file after the comments are stripped out".
Both halves of that qualifier were load-bearing.
A builder that reuses the shared constant was invisible.
BUILDS_A_BLOCKmatched the marker only when spelled out as the opening of a string, so
handover.ts:37— which buildsHANDOVER_PREFIXby interpolatingCONTEXT_MARKERinto a template — was never seen. It is not hypothetical:buildHandoverPreamblereaches the prompt throughseed()on every vendorswitch. The incentive was backwards. Reusing
CONTEXT_MARKER, the correct thingto do, was exactly what hid you from the net.
The comment stripper deleted source before searching it.
code()strippedblock comments with
/\*[\s\S]*?\*\//g, which cannot tell a comment opener fromthe
/*insideimport.meta.glob("./apps/*/app.json"). That opened a span atmain.ts:48and closed it inside the next glob at:56, deleting 237characters of removable-app discovery unread.
Measured against a string-aware stripper over the same tree, 71 files came out
cut differently, 44,691 characters in all — the worst single case 9,159
characters of live Rust in
engines/context/src/area_tests.rs. None of thespans the two disagreed about held a marker, so the audit's answer was right by
luck.
The two were entangled, which is why neither was found by staring at one of
them:
handover.tsspells the marker in its own header prose, so the olddetector would have caught it on the wrong evidence had the stripper not deleted
that comment first.
What this does
The stripper is gone rather than fixed. A stripper is the half that fails in
silence — every way it goes wrong deletes text, and text it deletes is a builder
it stops seeing. So nothing is stripped: the search covers whole files, prose
counts, and every file that so much as names the marker is listed. Measured, that
is 20 files under
src/once the test tree is set aside — a line per file, andwhat it buys is that a builder cannot hide behind a lexing bug, because nothing
is lexed.
One list, not two.
BUILDERSplusMENTIONSis the natural shape and thewrong one: nothing can check which array a file belongs in (telling a comment
from a string literal is precisely the lexing this deletes), so the second array
is an escape hatch whose only property is which name somebody typed. A real
builder filed under
MENTIONSpassed. There is now one list, withbuildsnotedinline on the nine that spell a block.
Git does the searching, in one
git grep --files-with-matches -z -I --untracked --exclude-standard -F -e '[context]' -e CONTEXT_MARKER -- src,instead of listing paths and reading each one. Re-implementing
git grepis howthe reading half acquires bugs of its own, and the read version had them: a
listed path deleted since threw
ENOENT(a stack trace instead of the diff thatnames the file); a merge listed a conflicted path once per stage; a symlink to a
directory did not open as a file; and each of the 16
.woff2files undersrc/was decoded as lossy UTF-8 and searched as something that was not the file.
--untracked --exclude-standardscopes it to what this repo would ship orcommit: a builder written this minute is audited before it is staged, and a
git-ignored local-only app cannot redden this tracked test (CLAUDE.md §6 allows
one to exist and this file must not know about it).
And the arithmetic half gains two prompts it had never seen — the handover
seed, which
prompt-blocks.tscalls "the single most expensive thing a turn cancarry" and which
INJECTORShad no entry for; and a prompt with no typed half atall, which is what charges the separator arithmetic for its own claim. Both were
already correct, so this is coverage of untested paths, not a bug fix. The
handover fixture carries a blank line inside one turn on purpose: a blank line is
this convention's block delimiter, so that is where a seed would split in two and
the second paragraph would reach the model unrowed.
Verified
npx vitest run --no-cache …/ledger-net.test.ts→ 32 passedcheck(740 files, 0 errors) ·lint:size·lint:imports·lint:manifests·lint:resources·lint:css-vars·npm test·npm run buildby hand (never committed). 17 must fail and did — including an unlisted probe
written only via
CONTEXT_MARKER, one hiding behind a string holding a/*,one in a
.mdprompt asset, one undervendor/, one never git-added, a listedpath mistyped, the marker arm dropped from the search, a broken
handoverBlockLabel, andverbatimTurnno longer collapsing blank lines. 4must pass and did, including a git-ignored local-only app naming the marker in
prose, which §6 requires this tracked test to stay green for.
The file WARNs at 515 lines against the 400-line soft ceiling and carries the
size note rule 3 asks a test module for; the hard ceiling is 600.
Review status — please read
The pre-PR
/code-reviewpass did not complete: the skill and three of itsten angle agents hit a weekly API limit that resets Aug 18. Seven angles did
finish, and their reproduced findings are folded into this branch:
git grepexits 1 both for "nothing matched" and for "that pathspec is notthere", so a mis-resolved
REPO_ROOTwas being swallowed as a real emptyanswer. An empty result is now refused outright, since this module imports from
src/.--untrackedmeans working-tree litter reddens the audit..gitignoregains*.orig,*.rejand*.bak— not the whole of that class, but the two waysit actually happens here.
gap()'s "exact even for a prompt with no typed half" had no assertion behindit. It has one now.
src/apps/<id>/may name a removable app, soa path inside one must never join this list. No removable app names the marker
today; the constraint is written down beside the list.
So this has had seven angles of automated review, not the consolidated
verdict. Worth a human eye on the two judgement calls: dropping the
vendor/exclusion the old walk had, and searching every extension rather than only source
files.
Follow-ups, not in scope here
handover.ts:127joinscontextNamesunsanitized — a page title containing ablank line splits the seed, and the tail lands in the user's own chat bubble.
Same shape in the
tool/noticebranches ofverbatimTurn/compressTurn.handoverBlockLabel's regex is unanchored and breaks on atargetLabelcontaining
).prompt-blocks.ts:171's mention fallback is unconditional — a block whoseprefix drifted is titled after the wrong thing rather than reported as unknown.
contextBlock()factory incontext-block.ts, so six builders stophand-spelling the literal.
scripts/build/as a lint would delete both the SIZENOTE and the bespoke Windows CI step from fix(ci) [BRNS-DESK-034, BRNS-DESK-035]: Windows runs the path spec, and the cache caches what cargo writes #71.
scripts/eval/checks/mentions-surface.mjs:28-29,chat-surface.mjs:34,src/apps/gmail/bridge/report.test.ts:150.src-tauri/src/**has no marker net of any kind.🤖 Generated with Claude Code