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_cooccurrencebelow, 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 handler | 200 in 1.35 s |
GET /api/greeting | 200 in 2.67 s |
CORS preflight for /api/login | correct |
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.