Skip to content

test(session) [BRNS-DESK-037]: the [context] net stops keying on how a builder is spelled - #75

Merged
stefan-ssv-labs merged 1 commit into
devfrom
fix/desk-037-ledger-net-audit-blind-spots
Aug 14, 2026
Merged

test(session) [BRNS-DESK-037]: the [context] net stops keying on how a builder is spelled#75
stefan-ssv-labs merged 1 commit into
devfrom
fix/desk-037-ledger-net-audit-blind-spots

Conversation

@stefan-ssv-labs

Copy link
Copy Markdown

Closes BRNS-DESK-037. Test-only: no product behaviour changes.

What was wrong

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 on its
allowlist — 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_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. It is not 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.

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. None of the
spans 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.ts spells the marker in its own header prose, so the old
detector 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, and
what it buys is that a builder cannot hide behind a lexing bug, because nothing
is lexed.

One list, not two. BUILDERS plus MENTIONS is the natural shape and the
wrong 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 MENTIONS passed. There is now one list, with builds noted
inline 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 grep is how
the 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 that
names 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 .woff2 files under src/
was decoded as lossy UTF-8 and searched as something that was not the file.

--untracked --exclude-standard scopes it to what this repo would ship or
commit: 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.ts calls "the single most expensive thing a turn can
carry" and which INJECTORS had no entry for; and a prompt with no typed half at
all, 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 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 21 cases and run
    by 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 .md prompt asset, one under vendor/, one never git-added, a listed
    path mistyped, the marker arm dropped from the search, a broken
    handoverBlockLabel, and verbatimTurn no longer collapsing blank lines. 4
    must 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-review pass did not complete: the skill and three of its
ten 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 grep exits 1 both for "nothing matched" and for "that pathspec is not
    there", so a mis-resolved REPO_ROOT was being swallowed as a real empty
    answer. An empty result is now refused outright, since this module imports from
    src/.
  • --untracked means working-tree litter reddens the audit. .gitignore gains
    *.orig, *.rej and *.bak — not the whole of that class, but the two ways
    it actually happens here.
  • gap()'s "exact even for a prompt with no typed half" had no assertion behind
    it. It has one now.
  • CLAUDE.md §6 says nothing outside src/apps/<id>/ may name a removable app, so
    a 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:127 joins contextNames unsanitized — a page title containing a
    blank line splits the seed, and the tail lands in the user's own chat bubble.
    Same shape in the tool/notice branches of verbatimTurn/compressTurn.
  • handoverBlockLabel's regex is unanchored and breaks on a targetLabel
    containing ).
  • prompt-blocks.ts:171's mention fallback is unconditional — a block whose
    prefix drifted is titled after the wrong thing rather than reported as unknown.
  • A contextBlock() factory in context-block.ts, so six builders stop
    hand-spelling the literal.
  • Moving this census to scripts/build/ as a lint would delete both the SIZE
    NOTE 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.
  • Three stripper copies survive elsewhere: 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

…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 nir-ssvlabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 under src-tauri/ names the marker, and the only non-src matches are scripts/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 while main passed, 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.

@Chris-ssvlabs Chris-ssvlabs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@sebastian-ssvlabs sebastian-ssvlabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed-at: c877c6e

@stefan-ssv-labs

Copy link
Copy Markdown
Author

Pixel eval (headless) is red here for a reason that predates this branch — evidence below, not an assertion.

Six of seven legs pass, including the one that matters most for this change: Rust (windows-latest) carries the only Windows execution of a vitest spec in this repository, and its Path-portability spec (Windows) step ran green —

✓ src/layout/panes/session/__tests__/ledger-net.test.ts (32 tests) 331ms
  Test Files  1 passed (1)

That is the claim #71's step exists to hold. The hazard it watches moved from join() to a git grep shell-out with an absolute -C root that fileURLToPath spells C:\… on Windows; git answers in forward slashes on every platform, and this is what proves it rather than asserts it.

On the pixel leg: this branch's only non-test change under src/ is comments.

-// Four things ride a prompt invisibly, each built by a different half of the
+// Six things ride a prompt invisibly, each built by a different half of the
- *  (ledger-net) keys on the spelled-out literal. */
+ *  (ledger-net) sees either spelling, so composing it costs no coverage. */

The goldens were last captured at f6eb11c (2026-08-12, #60). UI commits have landed on dev since, touching exactly the surfaces that diverge — c05e596 (the chat chip), 2ebfc77 (composer scope CSS the chip refactor left behind), 6535fe8 and e880110 (gmail), b60d3c9 (surface copy). The two surfaces that still pass are start-menu and settings, which are the two that show neither the composer nor gmail. gmail-thread is the worst at 0.389% px / RMSE 0.0155 against limits of 0.01% / 0.0005.

A local baseline on a clean origin/dev@5bac1a6 worktree returns BLOCKED rather than a comparable number, and says why: the goldens were rendered with Chrome 146.0.7680.153 and this machine's puppeteer cache holds 152.0.7977.42. CI runs 146, so CI's DIVERGED verdict is a real pixel difference — of dev against goldens that predate dev. ci.yml does not run on dev pushes (only "Dev channel release" does), so there is no dev baseline run to point at.

Re-capturing the self goldens is a deliberate change of its own and does not belong in a test-only PR; it needs --update-self on a Chrome-146 runner. Happy to file it as its own finding.

@stefan-ssv-labs stefan-ssv-labs left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ review-pr (self-review, cannot approve own PR): clean — no blockers or criticals found.

@stefan-ssv-labs
stefan-ssv-labs merged commit 1a75878 into dev Aug 14, 2026
6 of 7 checks passed
@stefan-ssv-labs
stefan-ssv-labs deleted the fix/desk-037-ledger-net-audit-blind-spots branch August 14, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants