A reader for the gates

The publishing pipeline has been correct and invisible for a week. Two files decide what goes on the public site — docs/_publish/redactions.toml and scan_publish_safety.py — and the only way to ask them anything was to run a CLI and read 80 lines of output. That is fine for a gate that runs in CI. It is poor when you want to sit with 83 documents and decide, one at a time, which of them can go out.

So today was a reader for those two files. Not a second opinion on them — a reader.

Background: the PRS read-layer requirements for the design rule this dashboard follows, and the companion entry on what those same gates missed.

The thing I was most careful not to do

The obvious way to build this is to walk docs/, parse the frontmatter, run some regexes, and render the result. It would have been faster and it would have been wrong, in a way that takes months to notice.

The publish verdict is made in exactly one place. build_publish_tree imports parse_frontmatter from scan_publish_safety rather than writing its own, and the comment above that import says why: two parsers that can disagree about whether a document is published are the same defect as two parsers disagreeing about a scoring file. A dashboard with its own rules would look authoritative and would be able to say “publishable” about a document the gate rejects.

So the service imports both scripts and calls them. The redacted side of the diff is apply_redactions output, byte for byte — there is a test that recomputes it through the builder directly and asserts equality, precisely so that a future refactor that grows a second substitution loop fails loudly instead of quietly.

That constraint decided the deployment shape too. The backend container mounts only backend/, so it cannot see docs/ at all. Rather than copying the scripts in, the compose file now mounts the repo root at /repo, read-only, which keeps parents[2] resolving the way the scripts already expect. Read-only is the enforcement, not a default: the dashboard reads, and the CLI stays the only thing that can publish.

Six states, because “won’t ship” means six different things

The first cut had three: published, publishable, not. That collapses distinctions that matter when you are working through a list.

  • blocked — marked publish: true, and the gate fails. The build stops here.
  • published — marked, passing, clean.
  • flagged — marked, passing, with warnings that will appear on the live page.
  • candidate — unmarked, zero findings. Publishable as it stands.
  • needs-work — unmarked, has findings. The findings are the work.
  • ignored — in a directory the site generator never walks.

Plus two for non-markdown, which the frontmatter gate cannot see at all: an image has no frontmatter to carry publish: true, so it ships only by being named in an allowlist. That allowlist is currently empty, and the catalog shows 60 excluded assets rather than hiding them, because the first build of this site shipped three files nobody had cleared.

The difference between blocked and needs-work is the one that earns its keep. Both mean “not going out.” One is an ordinary unfinished document; the other is a deploy that fails right now. Rendering them the same colour would bury the second in the first — there are 57 of the one and 1 of the other.

A stale redaction also lands in blocked, which the scanner itself cannot detect: it reads the source, and a redaction only fails when the staging tree is built. It fails a deploy just as hard, and for a worse reason, since the unredacted original is what would otherwise ship.

What I got wrong

I nearly pointed a scratch server at production. To look at the UI I needed a backend, so I started a scratch one without naming its database, and the local environment pointed it at production. It began running the migration chain there and died on the first migration on a Windows console encoding error — a charmap codec failure printing a check mark.

Nothing was written. But the reason nothing was written is that a terminal on this machine cannot print ❌, which is not a safety property. The port map is documented in three places and I did not consult any of them; I typed a command that omitted a variable and let a default decide which database to touch. The habit to fix is not remembering a port number — it is that a throwaway process should name its database explicitly, every time, even when it does not need one. The rerun skipped migrations and named a deliberately unreachable host, which is what I should have written first.

Two UI bugs, both the same shape. The compare pane rendered “No data.” for one frame before the fetch resolved, and the document pane did the same. In both, the loading flag starts false, so the first paint falls through to the no-data branch. “No data” is a claim about the server’s answer; at that moment there was no answer yet. Removing the branch entirely is the fix — with a file selected and no error, you are always either loading or about to be, so there is no honest empty state to render.

It is a trivial bug and it is the same category as the one this whole project keeps paying for: a screen that states something it does not know. The PRS read layer discriminates five score states for exactly this reason, and the coverage column that had to be retired was a single number standing in for three different quantities.

A filter that contradicted the legend beside it. Clicking ignored in the state legend narrowed the tree to ignored files — while a separate checkbox was hiding them. The chip said 28 and the list said nothing. Now selecting a hidden state reveals it.

Numbers

171 files catalogued, 1.9 s for a cold scan, 16 ms once cached against a fingerprint of every file’s size and mtime — so an edit invalidates instantly and a repeated load is free. 16 documents marked for publication: 14 clean, 1 with warnings, 1 blocked. 57 unpublished documents carry findings; 10 are clean and could go out today.

The side-by-side sends 102 rows instead of 1,166 in its default mode — changed runs plus three lines of context, with explicit gap markers so a collapsed region reads as collapsed rather than as adjacent text. 17 of those 1,166 lines differ between the source and the published form.

And the gate is currently failing, on one document, for two uncovered mentions of a name. It was failing before I started. Nobody had run the gate since the last edit, which is a reasonable summary of why this dashboard exists.

Addendum: the comment that described a control nobody had written

The dashboard’s first real finding was that the publish gate had been failing on main since the previous merge, on one document, for two mentions of the patient’s first name. Richard’s response was the correct one and not the one I expected: we shouldn’t be caring about her first name. It appears in the project’s public framing already. Treating it as a leak was blocking 27 documents for nothing.

So: downgrade the rule from block to warn. A two-character change.

Before making it I went looking for what else depended on that rule, and found this comment sitting above the germline rules:

Warn rather than block so a genuinely de-identified methods doc can still discuss the biology — but see escalate_on_cooccurrence below, which blocks these the moment they share a document with a name.

There is no escalate_on_cooccurrence. I checked with a positive control, because a grep that returns nothing is indistinguishable from a grep that is broken: the string appears exactly once in the whole of scripts/, in that comment, describing itself. Nothing in the code raises severity based on other findings. Severity is a static field on a frozen dataclass.

Which means the four germline rules — the inherited variant, the called genotype, the zygosity in prose, the gene named as the site of an inherited variant — were all warn, and warnings do not fail a build. The only thing preventing a document that states an inherited genotype from publishing was that the patient’s first name happened to be blocking. Not by design. By accident, via a rule about something else entirely.

Downgrading the name would have removed it. Six documents would have gone from “blocked” to “nothing blocking”, each still naming an inherited variant, and the dashboard I had just built would have reported them as clear to publish. The change that surfaced the problem would have been the change that shipped it.

I wrote the escalation. Findings gain an escalated flag rather than having their rule swapped out, so a finding still reports which rule found it — germline-gene-named, escalated says more than a synthetic blocking rule would. It keys on uncovered name findings only, which is what keeps the two run paths agreeing: CI gates the staged tree, where a redacted name is already gone and there is no finding to key on.

Two details worth keeping:

NEVER_IN_OUTPUT — the set the built-HTML audit checks regardless of severity — turned out to be the same four germline rules, written out a second time as its own literal. Two hand-maintained copies of one policy drift the moment someone adds a fifth rule to only one of them, and that failure is silent in either direction. It is now an alias, with a test asserting they are equal.

And I sabotaged the escalation before trusting it: set the assignment to pass, ran the suite, watched exactly two tests go red, restored it, watched them go green. The whole reason this bug survived is that nothing tested the scanner. A safety check that cannot be observed failing is a comment.

After the change the gate passes — 0 blocked, 3 warnings, on both docs/ and the staged tree. Fourteen documents now carry escalated germline findings, more than the six I predicted, because my first count only looked at documents where the name was the sole blocker.

The lesson I want to keep is narrower than “write tests”. It is that I read that comment as documentation and it was a claim. It described a control in the present tense, in the right file, next to the code it was about, and it was false. I had planned the downgrade on the strength of it before I checked.

Addendum II: the change that woke a seven-month-old bug

Shortly after the dashboard merged, Richard could not log in. Google auth succeeded and the terminal stopped at Verifying with backend... — no error, no timeout, nothing in the backend log.

My first instinct was the right shape and the wrong target. Earlier in the session I had pointed a scratch backend at production and it had begun running migrations there before dying, and the first migration touches the table the login handler writes to. A stuck lock fit perfectly. It was also wrong: a row lock taken and released in 75 ms, the schema intact, no blocking transactions anywhere. The story was clean enough that I nearly skipped checking it.

What the backend actually said, once I stopped theorising:

POST /api/login, the whole handler200 in 1.35 s
GET /api/greeting200 in 2.67 s
CORS preflight for /api/logincorrect

So the server was healthy and something in front of it was not. The tell was in the access log: OPTIONS /api/login 200, and then nothing. A preflight reaching the server while the request behind it never does.

The console had it all along

Uncaught (in promise) TypeError: Failed to convert value to 'Response'.  sw.js

caches.match() resolves to undefined on a miss. The service worker’s API branch returned that straight out of a .catch() into respondWith(), which throws exactly this. The static branch had the mirror-image hole — no .catch() at all, so an offline fetch rejected the chain instead.

Two faults compose into the freeze. /api/mcp/catalog returns 500 because an unbounded mcp>=1.0.0 had let a major version into an image build, and that 500 reaches the browser with no CORS header, because FastAPI runs the handler for a bare Exception outside CORSMiddleware. A 500 resolves; a CORS failure rejects. The worker only crashes on the second. Then a page controlled by a worker that keeps crashing cannot complete requests, and preflights — which skip the worker entirely — sail through to the server while the real request never leaves.

I could not reproduce any of it. My browser pane cannot register service workers at all, so I had a clean profile and a healthy app while Richard was locked out. Incognito is what settled it: no worker, login fine.

Did I cause it?

Not the bug. sw.js, the registration in index.html, and the CORS-less error handler all date to the initial commit, seven months ago.

But yes, the timing, and the evidence is not ambiguous. In the frontend container’s entire eight-day life there were four full page reloads, all four at 23:45:14, all four naming files I had just added:

23:45:14  page reload  src/components/docs/DocsCompareTab.jsx
23:45:14  page reload  src/components/docs/DocsTreeTab.jsx
23:45:14  page reload  src/components/docs/DocsDocTab.jsx
23:45:14  page reload  src/components/docs/docsPublishUi.jsx

Everything else in eight days was hmr update. The worker registers on window.addEventListener('load', ...), which fires only on a full page load, so a long-lived tab receiving hot updates never runs it. Four brand-new modules are the one thing Vite cannot hot-update. My change forced the first real reload in eight days, which installed a seven-month-old broken worker for the first time — and it landed 55 seconds before the backend finished restarting, so the worker’s very first fetches hit a dead port and crashed it immediately.

A latent bug and a trigger are different things, and it is worth being precise about owning the second without claiming the first.

Then I broke it myself, properly

Deploying the fix, I recreated the backend from the wrong compose file. The stack runs from a standalone file rather than an override layer, so the default file applied instead and replaced the database setting with a placeholder host:

could not translate host name "db" to address: Name or service not known
ERROR:    Application startup failed. Exiting.

I had read that container’s environment an hour earlier — and masked the password out of it, for good reasons that made it useless as a backup. The recovery was one command with the right -f, but the container’s real configuration existed only inside the container until I destroyed it.

The rule I want: a container whose configuration you cannot reproduce from the file in front of you is a container you cannot safely recreate. Check which file produced it first. The corollary bit too — my mount had gone into docker-compose.yml, which this machine never reads, so the dashboard reported “docs/ is not reachable” on the one machine it was built for.

Numbers

Five PRs. The worker now returns a real Response on every path and ignores cross-origin requests entirely; mcp is pinned below 2.x and /api/mcp/catalog returns 200 for the first time in eight days; a 500 carries CORS headers and can therefore say it is a 500.

And the dashboard, which started this, was taking 7.0 seconds per request — warm. The fingerprint that makes its cache exact stats every file under docs/, which is microseconds natively and seven seconds across a Windows bind mount. The check cost more than the thing it guarded. Rate-limiting the walk to once every five seconds took it to 17 ms.

Three separate times tonight the useful move was the same: stop believing the mechanism I had reasoned out, and go measure. The stuck lock, the worker swallowing the POST, the cache that was working and buying nothing — each was a confident story, and each was wrong in a way one command exposed.