<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>spectroscope blog</title>
    <link>https://blog.spectroscope.dev/</link>
    <description>Engineering notes from spectroscope: what broke, what it measured, and the commit that fixed it.</description>
    <language>en</language>
    <lastBuildDate>Fri, 31 Jul 2026 09:00:00 +0000</lastBuildDate>
    <atom:link href="https://blog.spectroscope.dev/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>the green build that ran nothing</title>
      <link>https://blog.spectroscope.dev/the-green-build-that-ran-nothing/</link>
      <guid isPermaLink="true">https://blog.spectroscope.dev/the-green-build-that-ran-nothing/</guid>
      <pubDate>Fri, 31 Jul 2026 09:00:00 +0000</pubDate>
      <description>spectroscope shipped five releases with no CI. Wiring one up meant confronting a build that reports success in half a second having executed zero tests, and deciding that a tick is not evidence unless a number stands next to it.</description>
      <content:encoded><![CDATA[<p>A passing tick is a claim like any other, and the cheapest evidence you can attach to it is a count. Until you attach one you are trusting a colour.</p>
<p>The other half of this story is that "works on my machine" has a precise mechanism. A developer machine accumulates state a fresh checkout does not have, and a clean runner is the only thing that reliably takes that state away.</p>
<p>spectroscope went out five times, 0.1.0 through 0.4.1, entirely by hand against the nine steps in <code>docs/RELEASE-PLAYBOOK.md</code>. There was no <code>.github</code> directory. The only automation in the estate was Cloudflare's git build on the website repos, where a push is a deploy. Card 125 opened to fix that, and the first thing it had to deal with was not a workflow at all.</p>
<h2 id="the-half-second">the half second</h2>
<p>On this tree, this command is a lie:</p>
<div class="code"><span class="code-lang">bash</span><pre data-lang="bash"><code>./gradlew test</code></pre></div>
<p>It reports <code>BUILD SUCCESSFUL</code> in about half a second, having executed zero tests. Gradle's up-to-date checks look at unchanged inputs and skip the task whole. Even <code>cleanTest test</code> comes back <code>FROM-CACHE</code>. The behaviour is documented in the project's own CLAUDE.md, and it had already misled a human here at least once.</p>
<p>That fact decides whether a pipeline is worth building at all. A CI that runs <code>./gradlew test</code> on every commit is actively harmful: it produces a green badge that means nothing, forever, and it does so with more authority than a developer's local run because it looks institutional. So the flags are the whole point, and the workflow says so where somebody editing it will read it. From <code>.github/workflows/gate.yml</code>, lines 50 to 60:</p>
<div class="code"><span class="code-lang">yaml</span><pre data-lang="yaml"><code>      # --rerun-tasks --no-build-cache is NOT optional. On a warm tree a plain
      # `./gradlew test` reports BUILD SUCCESSFUL in half a second having
      # executed zero tests (up-to-date checks skip the whole task, and even
      # `cleanTest test` comes back FROM-CACHE). These flags force every test
      # to execute on every run. The javadoc legs are here because javadoc
      # otherwise only runs at release, which is the one place a broken doc
      # comment is expensive to discover.
      - name: Java gate
        run: &gt;
          ./gradlew test --rerun-tasks --no-build-cache
          :spectro-core:javadoc :spectro-orchestrator:javadoc</code></pre></div>
<p>The javadoc legs ride along for the reason the comment gives. Card 87 once orphaned three doc comments from their <code>stream()</code> methods, and nothing noticed until the release gate ran javadoc, which is the most expensive possible moment to find out.</p>
<h2 id="the-tick-is-not-the-measurement">the tick is not the measurement</h2>
<p>Comments do not enforce anything. Somebody will eventually simplify that command, so neither job trusts its own exit status alone. Both extract the real test count and fail on zero.</p>
<p>The Java side sums the attributes over every JUnit XML result file and writes the totals into the job summary, so a reviewer sees <code>JUnit: 1162 tests, 0 failures, 0 errors, ...</code> rather than a tick that hides it. The web side reads vitest's summary line out of the captured gate log, and that is where the pipeline nearly acquired its own silent failure.</p>
<p>vitest prints a fixed-format line on pass, <code>Tests  1841 passed (1841)</code>. A naive grep for that is right on a laptop and wrong on a runner. Here is the same line pulled out of a real CI log, rendered through <code>cat -v</code> so the escapes are visible:</p>
<div class="code"><pre><code>^[[2m      Tests ^[[22m ^[[1m^[[32m1841 passed^[[39m^[[22m^[[90m (1841)^[[39m</code></pre></div>
<p>The gate captures that through a pipe, and it still arrives coloured, with the escapes sitting between the word and the digits. An anchored grep for <code>Tests</code> followed by a number never matches it, so the count would have come back empty and the guard would have failed every green run. It was caught before the first push by reproducing against a real <code>CI=true</code> vitest log, and the extraction strips the escapes first:</p>
<div class="code"><span class="code-lang">bash</span><pre data-lang="bash"><code>line=<span class="s">"$(sed $'s/\x1b\\[[0-9;]*m//g' "</span>$log<span class="s">" 2&gt;/dev/null | grep -E '^[[:space:]]*Tests[[:space:]]+[0-9]+ passed' | tail -n 1 || true)"</span>
count=<span class="s">"$(printf '%s\n' "</span>$line<span class="s">" | grep -oE '[0-9]+' | head -n 1 || true)"</span></code></pre></div>
<p>The guard's job is narrow. The count is a reported measurement; the pass/fail authority stays with the gate's exit status. A count cannot tell you the tests are good. It tells you they ran, which happens to be the one lie this tree is capable of telling.</p>
<h2 id="proving-the-gate-by-breaking-it">proving the gate by breaking it</h2>
<p>An untested test harness is a contradiction, so card 125's acceptance criteria were written as things to break rather than things to observe. There are seven. Four could only be settled on GitHub itself, and those four have now run.</p>
<p>PR #4 carried two tests deliberately flipped, one Java (<code>NotesServerRealProcessTest:74</code>) and one web (<code>format.test.ts</code>). It went red on exactly those two and nothing else, with zero infrastructure errors in either log. main is green with the real counts in the job summary, JUnit 1162 and vitest 1841, and both count guards fail on zero. A deliberately broken doc reference planted at <code>Spectro.java:31</code> killed the javadoc leg with <code>error: reference not found</code>, which is precisely how card 87's break would have been caught a release earlier.</p>
<h2 id="what-a-clean-machine-knew-that-this-one-did-not">what a clean machine knew that this one did not</h2>
<p>The pipeline's first execution was red in 43 seconds, and it kept finding things into the next morning. Seven defects, not one of which this machine could see.</p>
<p>Two arrived together in the very first run (<code>6842506</code>). <code>native/spectro-pty.c</code> did not compile on Linux: glibc hides <code>kill</code>, <code>sigaction</code> and <code>usleep</code> behind feature-test macros under strict <code>-std=c11</code>, and Darwin exposes them regardless, so every macOS build had been silent about it. The fix is two lines that have to sit above every include:</p>
<div class="code"><span class="code-lang">c</span><pre data-lang="c"><code>#define _XOPEN_SOURCE 700
#define _DEFAULT_SOURCE</code></pre></div>
<p>The second is the better story. <code>spectro-web</code>'s drift tests import <code>node:fs</code>, but <code>@types/node</code> was nobody's declared dependency. tsc had been resolving the types sideways out of <code>spectro-desktop/node_modules</code>, a directory that exists on a developer machine and not in a fresh checkout. As the commit message puts it:</p>
<blockquote><p>Locally even `tsc -b --force` was green off the borrowed types.</p></blockquote>
<p>That is the shape of the whole category. The local build was not passing by luck, it was passing by borrowing, and no amount of forcing a rebuild could reveal it because the borrowed thing was still there.</p>
<p>Two more were the PTY helper meeting Linux naming. The build script's verify pattern only knew Darwin's <code>/dev/ttysNNN</code>, so a helper whose child correctly answered <code>/dev/pts/0</code> failed its own build (<code>1afdb68</code>), and <code>SpectroPtyHelperTest</code> awaited the same Darwin-only substring and burned a ten second timeout on a child that had a real terminal (<code>0a6614c</code>). Neither weakened a check: none of the probe's reachable failure strings contain either substring, which was measured rather than assumed.</p>
<p>The fifth was a genuine race in a test, not the server. <code>aBlankModelSwitchTakesTheTargetsDefaultNotTheCarriedModel</code> made two back-to-back WebSocket <code>sendText</code> calls and discarded both futures. The JDK contract completes the second exceptionally while the first is in flight, so on a starved CPU one frame was silently never transmitted. Reproduced 3 times in 300 on a 0.5-CPU container with the exact CI signature, and a dead-port run passed, which ruled out the obvious reachability theory.</p>
<p>The last two came off the same runner over two days and wanted opposite responses. Three <code>ProcessBusWireTest</code> failures were choreography inside the tests. A fourth failure, in a test that had been in the suite since the bus was written, was a real and silent data-loss bug in the message bus. Both surfaced only because a shared two-vCPU runner kept losing the schedule lottery, which is what makes the pair worth <a href="/the-cursor-that-leapfrogged-a-hole/">its own post</a>.</p>
<p>One red is still unexplained. <code>SpeechRendererTest.abortClearsTheQueueAndStopsThePlayer</code> failed once, on 31 July, and has been green on every run since. Nobody has touched that file. It sits on the list, unfixed and uncounted.</p>
<h2 id="the-lesson">the lesson</h2>
<p>All seven hid here for the same kind of reason. This machine has a warm Gradle cache, a sibling <code>node_modules</code>, a libc that hands out the POSIX surface without being asked, and fourteen cores against the runner's two. A fresh checkout on a shared runner has none of that, and every one of them was quietly holding something up.</p>
<p>The pipeline's value was never the badge. It was that a clean environment disagrees with a working one, and the disagreement is where the bugs live. The part that made the disagreement legible was the smallest piece of the whole thing: printing the number next to the tick.</p>]]></content:encoded>
    </item>
    <item>
      <title>the cursor that leapfrogged a hole</title>
      <link>https://blog.spectroscope.dev/the-cursor-that-leapfrogged-a-hole/</link>
      <guid isPermaLink="true">https://blog.spectroscope.dev/the-cursor-that-leapfrogged-a-hole/</guid>
      <pubDate>Fri, 31 Jul 2026 09:00:00 +0000</pubDate>
      <description>A shared CI runner failed four times inside thirteen hours. Three were test choreography. The fourth was permanent, silent frame loss in the message bus, and the reflex that would have made the suite green would also have hidden it.</description>
      <content:encoded><![CDATA[<p>A high-water mark is the standard way to make an at-least-once stream behave like an exactly-once one. You remember the highest sequence number you handed to the consumer, and you discard anything at or below it on redelivery. It is four lines and it is usually right.</p>
<p>It stops being right the moment the receiver is allowed to decline a frame. If a receiver can legitimately drop one without consuming it, the high-water mark lets the next frame close over the hole, and the later replay of that hole is indistinguishable from a duplicate.</p>
<p>There is a second story tangled up in the first, about triage. Four things failed on a shared runner inside thirteen hours. Three were test choreography and one was the product, and the fastest way to make the suite green would have buried the one that mattered.</p>
<h2 id="the-failure">the failure</h2>
<p>spectroscope's orchestrator has a process bus: agents publish typed events, a hub fans them out, clients subscribe per topic. <code>ClientCore</code> is the client-side bookkeeping, and its contract is exactly-once delivery to the consumer with announced gaps. Loss is the one direction it is not allowed to fail in. If the hub's replay ring evicts something, the client must raise a <code>BusGap</code>, loudly.</p>
<p>A client can legitimately end up with a frame it does not consume: the hub fans out on a topic while every local handle on that topic has been closed. Dropping that frame is correct, and so is not advancing the cursor past it, since nobody saw it. The bug was what happened next.</p>
<p>It arrived as one red line in a CI log: <code>framesForAFullyUnsubscribedTopicDoNotEatTheReplayOfALaterConsumer</code>, at <code>ProcessBusWireTest:351</code>. That test has been in the suite since the bus was written, and no gate run before that one had ever tripped it. It took a machine with two shared vCPUs to lose the right race. From the commit message on <code>e52b8ac</code>:</p>
<blockquote><p>The bug (found because a shared 2-vCPU runner kept losing the schedule
lottery): a frame fanned out while every local handle was closed is dropped
without advancing the cursor - correct - but ClientCore.accept's plain
high-water then let the NEXT live frame leapfrog the hole, and the hub's
replay of the hole was rejected as redelivery. Permanent, silent frame loss
on that client, no BusGap, a wrong resume cursor forever: exactly the loss
direction the class's exactly-once contract forbids.</p></blockquote>
<p>Read that sequence slowly, because every individual step is defensible. Frame 7 arrives with no consumer, so it is dropped and the cursor stays at 6. Frame 8 arrives live and is consumed, so the high-water goes to 8. The hub, doing its job, later replays 7. The client compares 7 against a high-water of 8, concludes redelivery, and discards it. Nothing anywhere is wrong on its own. The client has now lost a frame permanently, will announce no gap, and will send a resume cursor that lies about what it has seen, forever.</p>
<h2 id="the-fix-is-bookkeeping-not-policy">the fix is bookkeeping, not policy</h2>
<p>The correction does not touch the delivery path's decision rules. It gives the client a second thing to remember: the holes. From <code>spectro-orchestrator/src/main/java/dev/spectroscope/orchestrator/ClientCore.java</code>:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>    <span class="c">/** topic → sender → epoch → highest sequence handed to the consumer. */</span>
    <span class="k">private</span> <span class="k">final</span> <span class="t">Map</span>&lt;<span class="t">String</span>, <span class="t">Map</span>&lt;<span class="t">String</span>, <span class="t">Map</span>&lt;<span class="t">Long</span>, <span class="t">Long</span>&gt;&gt;&gt; consumed = <span class="k">new</span> <span class="t">LinkedHashMap</span>&lt;&gt;();
    <span class="c">/** topic → sender → epoch → sequences the wire delivered while NOBODY
     *  here consumed the topic (all handles closed): holes above the cursor.
     *  {@link #accept} must never advance PAST one — the replay that fills
     *  it would be misread as redelivery and the frame lost silently, with
     *  no gap announced. Only that replay ({@link #accept}) or an announced
     *  eviction ({@link #noteGap}) removes a hole. Growth is bounded by the
     *  frames the hub fans out during one consumer-less window: a reconnect
     *  only re-subscribes topics somebody still consumes. */</span>
    <span class="k">private</span> <span class="k">final</span> <span class="t">Map</span>&lt;<span class="t">String</span>, <span class="t">Map</span>&lt;<span class="t">String</span>, <span class="t">Map</span>&lt;<span class="t">Long</span>, <span class="t">NavigableSet</span>&lt;<span class="t">Long</span>&gt;&gt;&gt;&gt; undelivered =
            <span class="k">new</span> <span class="t">LinkedHashMap</span>&lt;&gt;();</code></pre></div>
<p>The unbounded-growth question is answered in the same comment rather than left for a reviewer to ask, which is the house rule: the set only grows for as long as a consumer-less window lasts, because a reconnect re-subscribes only topics somebody still consumes.</p>
<p>Recording a hole has one subtlety worth showing, which is that not every undelivered frame is a hole:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>    <span class="k">void</span> noteUndelivered(<span class="t">BusEnvelope</span> env) {
        <span class="t">Long</span> known = consumed
                .getOrDefault(env.topic(), <span class="t">Map</span>.of())
                .getOrDefault(env.sender(), <span class="t">Map</span>.of())
                .get(env.epoch());
        <span class="k">if</span> (known != <span class="k">null</span> &amp;&amp; env.sequence() &lt;= known) {
            <span class="k">return</span>; <span class="c">// redelivered consumed history — not a hole</span>
        }</code></pre></div>
<p>A frame below the cursor that arrives with no consumer is history being redelivered, and treating it as a hole would wedge the cursor behind something that was consumed long ago. Above the cursor, it is a hole. <code>accept</code> then refuses to advance past an outstanding one, and a hole is cleared by exactly two events: its own replay, or an announced eviction. <code>accept</code>'s own javadoc states the new refusal case:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>     * @<span class="k">return</span> <span class="k">true</span> when the consumer must see it; <span class="k">false</span> on redelivery, or
     *         when the frame rides above a hole and its own redelivery is
     *         already in flight behind that hole's replay</code></pre></div>
<h2 id="pinning-a-race-without-a-sleep">pinning a race without a sleep</h2>
<p>A bug that appears because a two-vCPU runner lost a scheduling lottery is not proved fixed by a suite that passes once. The pin is <code>theCursorNeverLeapfrogsAFrameNobodyConsumed</code>, and it was red 2 of 2 before the fix with the exact CI signature. It is a pin and not a coin flip because a witness forces the ordering: the test proves the state it depends on before it proceeds, instead of waiting a plausible number of milliseconds and hoping. Three <code>ClientCore</code> unit tests cover the bookkeeping directly.</p>
<p>The full gate after the change was JUnit 1162, which is the previous 1158 plus exactly those four new pins, 0 failures, with the orchestrator suite green three consecutive runs. The red and the green were both re-measured independently by an adversarial review pass, because a test that only its author has seen fail is not yet a pin.</p>
<h2 id="the-three-that-were-not-bugs">the three that were not bugs</h2>
<p>The same runner also struck <code>ProcessBusWireTest</code> three more times, at lines 179, 219 and 298: once on 30 July, then twice inside a single run the next morning. Those were choreography. The tests subscribed on one connection and published on another with nothing ordering the two at the hub, so on a starved runner the subscription lost the race to the burst, and a ring with capacity 2 made the shortfall permanent instead of recoverable.</p>
<p>The fix is five lines and one assertion, and it is the same idea as the pin above: prove the state, do not wait for it.</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>    <span class="k">private</span> <span class="k">static</span> <span class="t">CountDownLatch</span> probeSubscription(<span class="t">ProcessBus</span> witnessSide) {
        <span class="t">CountDownLatch</span> heard = <span class="k">new</span> <span class="t">CountDownLatch</span>(1);
        witnessSide.subscribe(<span class="t">TOPIC</span>, env -&gt; heard.countDown());
        <span class="k">return</span> heard;
    }</code></pre></div>
<p>Callers send one probe frame and block on it before the real burst:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>                assertTrue(subLive.await(10, <span class="t">TimeUnit</span>.<span class="t">SECONDS</span>),
                        <span class="s">"the probe frame proves the hub registered the witness's sub"</span>);</code></pre></div>
<p>Every original assertion in those three tests is byte-identical afterwards. That constraint matters: a flake fix that also edits what the test asserts has quietly become a different test, and you no longer know whether the original claim still holds. All three strikes were predicted by analysis before two of them had ever fired. That prediction is the only reason anyone could be confident they were choreography and not symptoms.</p>
<h2 id="the-lesson">the lesson</h2>
<p>Telling the two apart is the whole story. Four failures, one shared cause on the surface (a slow, contended runner), and two completely different diagnoses underneath. The reflex response to a flaky suite is a retry, a longer timeout, or a sleep, and any of the three would have turned all four green. Three of them would have deserved it. The fourth would have gone on losing frames in production with a green suite standing over it.</p>
<p>The property worth carrying elsewhere is simple enough. A high-water mark is a compression of history, and it is only lossless if every frame below it was actually processed. The moment a receiver is allowed to say no, the mark stops being a summary of what happened and becomes a summary of what got far enough, and those two things drift apart silently.</p>]]></content:encoded>
    </item>
    <item>
      <title>the scanner found the lesser flow</title>
      <link>https://blog.spectroscope.dev/the-scanner-found-the-lesser-flow/</link>
      <guid isPermaLink="true">https://blog.spectroscope.dev/the-scanner-found-the-lesser-flow/</guid>
      <pubDate>Fri, 31 Jul 2026 09:00:00 +0000</pubDate>
      <description>CodeQL raised five high alerts on spectroscope. Four were false positives. The fifth was real, and the path it pointed at was the harmless one: the dangerous caller was a query parameter it never flagged.</description>
      <content:encoded><![CDATA[<p>A static analyser is a lead generator, not an oracle. That is a description of what the tool is for, and it changes how you are supposed to answer an alert. This particular run managed to be wrong in both directions. It flagged four flows that were already contained, and on the one method where it was right it pointed at the harmless caller and missed the caller that writes.</p>
<p>The habit worth stealing is small. When an alert lands, do not patch the line it points at. Identify the taint source class it has found, in this case "an id that becomes a path", then go find every caller that produces one, because the scanner only saw the callers its queries model.</p>
<h2 id="five-alerts">five alerts</h2>
<p>The code-scanning page showed five open alerts, all high, all <code>java/path-injection</code> or <code>java/xss</code>. Alerts 1, 2 and 4 were false positives: the containment checks in that code are real, but the query does not model them as barriers. One was a direct-child parent equality check; another was a <code>Files.walk</code> without <code>FOLLOW_LINKS</code> combined with <code>deleteIfExists</code>, which removes a symlink rather than the thing it points at. Each dismissal carries a source-anchored argument instead of a shrug, and each of those three then had the scanner-legible guard shape added on top anyway, on the theory that an alert you have to re-argue every quarter is a cost you keep paying.</p>
<p>Alert 3 was real. <code>SessionStore.readSessionEvents(id)</code> resolved its id straight into the sessions directory with no normalize and no containment check, unlike its guarded siblings <code>deleteSession</code> and the controller's <code>exportSession</code>.</p>
<h2 id="the-flow-it-saw-and-the-one-it-did-not">the flow it saw, and the one it did not</h2>
<p>CodeQL reached that method through <code>GET /api/sessions/{id}/events</code>. That is a path variable, and the servlet container rejects <code>%2F</code> inside a path segment, so exploitation there is awkward. Judged on that flow alone, this is a tidy-up.</p>
<p>The flow it missed is worse, and it is in the same method's caller set. The websocket reads <code>?resume=&lt;id&gt;</code>, a <strong>query parameter</strong>, which nothing pre-filters, and feeds it to <code>loadSession</code>, <code>eventCount</code> and the resume constructor. From the commit message on <code>3b97b2e</code>:</p>
<blockquote><p>the websocket's ?resume= feeds that seam with a query parameter no container
pre-filters, through loadSession, eventCount AND the resume constructor, whose
file every later append goes to. A ?resume=../../x could read and then append
outside the store</p></blockquote>
<p>The escalation is in the last clause. The read is bad; the resume constructor stores the resolved file as the store's append target, so every later event written in that session goes to whatever the traversal chose. A read primitive became a write primitive by being handed to a constructor.</p>
<p>The audience for this is bounded: <code>/ws</code> carries a local-origin fence from card 92, so the caller has to be on the machine. A browser page on a foreign origin is refused; a local page, or a local tool that sends no Origin at all, is not. That is not much comfort, because something running locally is precisely the attacker class the 0.3.0 hardening pass and card 74 were built around. Bounding an audience is a mitigation, not a fix.</p>
<h2 id="one-seam-so-every-caller-inherits-it">one seam, so every caller inherits it</h2>
<p>The correction is a single resolution point that every id-to-path conversion goes through. From <code>spectro-core/src/main/java/dev/spectroscope/core/session/SessionStore.java</code>:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>    <span class="c">/**
     * Resolves a session id to its JSONL file — the ONE place an id becomes a
     * path. The id is caller input on three surfaces (REST path variable, the
     * websocket's {@code ?resume=}, the CLI's {@code --resume}), so the resolved
     * path must stay inside the store: normalized, under the sessions directory,
     * and a DIRECT child of it. Containment rather than an id-shape regex on
     * purpose — any file that really lives in the store stays addressable; the
     * strict shape check remains the HTTP edge's job.
     *
     * @param id the session id as the caller gave it — never trusted as a path
     * @return the contained session file path (it may not exist)
     * @throws IOException when the id resolves outside the sessions directory
     */</span>
    <span class="k">public</span> <span class="k">static</span> <span class="t">Path</span> sessionFile(<span class="t">String</span> id) <span class="k">throws</span> <span class="t">IOException</span> {
        <span class="t">Path</span> file = <span class="t">SESSIONS_DIR</span>.resolve(id + <span class="s">".jsonl"</span>).normalize();
        <span class="k">if</span> (!file.startsWith(<span class="t">SESSIONS_DIR</span>) || !<span class="t">SESSIONS_DIR</span>.equals(file.getParent())) {
            <span class="k">throw</span> <span class="k">new</span> <span class="t">IOException</span>(<span class="s">"Not a session id: "</span> + id);
        }
        <span class="k">return</span> file;
    }</code></pre></div>
<p>Two checks, on purpose, and the comment says why: <code>startsWith</code> is the guard shape scanners recognise, and parent equality is the stronger rule. Only one of them is load-bearing for correctness. The other is there so the next scanner run does not re-raise this, which is a legitimate reason to write a line of code and worth admitting rather than dressing up.</p>
<p>Choosing containment over a shape regex is deliberate too. A regex on the id would have been shorter, but it would make any session file that really lives in the store, yet predates the current id format, unaddressable. The strict shape check still exists. It just belongs at the HTTP edge, where rejecting odd input early is free.</p>
<p>The constructor now refuses before it touches anything:</p>
<div class="code"><span class="code-lang">java</span><pre data-lang="java"><code>        <span class="k">try</span> {
            <span class="k">this</span>.file = sessionFile(<span class="k">this</span>.id);
        } <span class="k">catch</span> (<span class="t">IOException</span> outsideStore) {
            <span class="c">// A resume id is caller input (CLI --resume, the socket's ?resume=):</span>
            <span class="c">// one that resolves outside the store is an argument error, not an</span>
            <span class="c">// I/O failure — refused before anything is created or appended to.</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="t">IllegalArgumentException</span>(<span class="s">"Not a session id: "</span> + id, outsideStore);
        }</code></pre></div>
<p>Two tests went red first, <code>readSessionEventsRefusesATraversalId</code> and <code>theResumeConstructorRefusesATraversalId</code>. At the HTTP edge, <code>/api/sessions/{id}/events</code> picked up the same full-match <code>SESSION_ID</code> shape check that export and delete already had, pinned by <code>SessionEventsShapeTest</code>, and the export now serves through the shared seam with a modeled content type plus <code>nosniff</code>.</p>
<h2 id="the-alert-that-survived-being-fixed">the alert that survived being fixed</h2>
<p>There is a good coda. Alert 5, the fourth and last of the dismissals, was <code>java/xss</code> on the export response body. Session content is caller-shaped text, so the response was changed to stop being anything a browser would parse: <code>application/x-ndjson</code>, <code>nosniff</code>, and <code>Content-Disposition: attachment</code>.</p>
<p>CodeQL re-flagged it as <strong>alert 6</strong>, at the new line. Its <code>java/xss</code> query models neither a non-HTML content type nor an attachment disposition as a barrier at all, so the rewrite moved the code without moving the alert. That one was dismissed by hand, with the argument verified against the pushed code rather than the intent: the response never reaches an HTML parsing context, the id in the filename passed the full-match shape check so it carries no quote, CR or LF and cannot split a header, and the endpoint is local-origin fenced. The dismissal comment names the condition that reopens it, which is the response ever becoming renderable.</p>
<p>A dismissal is only honest if it says what would make it wrong. Otherwise it is just a way of turning the light off.</p>
<h2 id="what-the-gate-did-not-pick-up">what the gate did not pick up</h2>
<p>Unrelated to any alert, a javadoc break turned up on this branch. An <code>ImageCopyController</code> <code>@param</code> had drifted from <code>body</code> to <code>req</code>, which had been keeping <code>:spectro-server:javadoc</code> red on main.</p>
<p>The gate did not find it, and could not have. Its javadoc legs are <code>:spectro-core:javadoc</code> and <code>:spectro-orchestrator:javadoc</code>, two of the five Gradle modules, so <code>spectro-server</code> gets no javadoc coverage on any push at all. This one came out of a local <code>./gradlew javadoc</code> over everything, which is the less flattering version of the story. The <a href="/the-green-build-that-ran-nothing/">pipeline post</a> argues that javadoc belongs in the gate instead of at the release. On this repo that argument currently covers two modules out of five.</p>
<p>The outcome is a number rather than an adjective. Zero open alerts on the code-scanning page, read back through the API instead of eyeballed on the page. The gate on the fix commit reported JUnit 1166, 0 failures and 1 skip across 173 result files, and <code>./gradlew javadoc</code> ran clean over all five modules locally. The security tab picked up a <code>SECURITY.md</code> policy the same day. No Dependabot alerts are open either, though that boast is smaller than it sounds: twenty-four have been raised over the life of the repo, twenty-one closed by an actual dependency bump and three auto-dismissed.</p>
<h2 id="the-lesson">the lesson</h2>
<p>The scanner was wrong in both directions and useful anyway. It raised four flows that were already contained, which cost an afternoon of argument, and it missed the flow that could write, which no amount of staring at its output would have revealed. What it did do was name a taint source class precisely enough to be followed: an id that becomes a path.</p>
<p>Following that class through every caller is the part that found the real problem, and it is the part the tool cannot do for you, because the tool only knows the callers its queries model. The alert is where the work starts, not where it happens.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
