0164 — A local gate refuses; CI only reports
Decision
The publication-privacy scan runs on every push, beside the release
metadata gate, outside the verify memo. It is not memoised, not conditional
and not part of the suite the memo can skip.
The arithmetic
verify remembers a green run under a working-tree hash, and that hash is taken
with git add --all . into a scratch index — so it counts untracked files. The
scan reads the real index, so it does not. The two notions of “the tree”
agree everywhere except one transition:
| step | tree hash | what the scan sees |
|---|---|---|
| write a new file | H |
not there |
| run the gate | H — remembered green |
not there |
git add |
H (no content changed) |
there |
| push | H — memo hits, suite skipped |
never asked |
Measured on a clone rather than argued: the same hash yields 30 findings before
git add and 31 after. The key does not move; the verdict does.
What it cost
920f7c3 put a real machine home path into a comment in
packages/core/scripts/check-declarations-strict.mjs, in a public repository.
It is not quoted here: this gate refused the first push of this very file for
containing it, which is a better demonstration than the quotation would have
been — and the file was new and untracked, so the memo would have skipped it.
The rule that matches it, non-synthetic Linux home path, fires on that exact
line; confirmed by calling privateShapes directly.
The scan was not blind. It was late. CI ran it and went red on that commit, naming the file and line:
+ "packages/core/scripts/check-declarations-strict.mjs:111 non-synthetic Linux home path"
But a push to a public repository publishes at the moment of the push. CI answers afterwards, so its red is a report about something the world can already read. The path stayed public for a day and was scrubbed by hand in 0.80.1 — which repaired the string and left the mechanism, so the next new file would have gone the same way.
This is the distinction the pre-push hook exists for and the reason it is worth its seconds: a local gate can refuse, and CI can only report. For anything whose damage is done by publication rather than by being wrong, that is the whole difference.
Where it runs, and why that matters most on the quietest push
A push earns one of three local gates: none for a tag, fast for an ordinary
branch push, full for one carrying a release commit. The scan is a
straight-line statement before all three, so no profile can skip it — and the
profile that would otherwise do the least is the one where this matters most.
A tag push is what triggers publication to npm, and an npm version is immutable:
there is no scrubbing it in the next release, the way the git leak was scrubbed.
That the leak in check-declarations-strict.mjs never reached npm was luck
about which directory it landed in — scripts/ is not in the packed files — not
a property of the system. Now the last thing that happens before a tag goes up
is the question “is there anything private in what git carries”.
Why not widen the memo key
The memo already carries two inputs a tree cannot show — the PostgreSQL server version and the installed browser set — precisely because a key that does not describe a gate’s input is a key that lies. Trackedness is a third such input, so folding the index into the key looks like the consistent move.
It is the wrong trade. git add would then invalidate every remembered run,
and a release push would re-spend thirteen minutes of heavy lanes — building
Next twice, driving three browsers, running a supervisor — to satisfy a check
that takes 367 milliseconds. The expensive lanes read the working tree,
where nothing changed; only this one reads the index.
So the cheap gate leaves the memo instead of the expensive ones joining it.
Consequence
- A file cannot enter history without the scan having looked at it.
- An untracked file is still not flagged: the scan’s scope is deliberate, and
a
.gitignored scratch file that no clone will receive stays unflagged — verified from both sides. STITCHKIT_EXEMPTIONSmoved from the test intopublication-privacy.ts. Two callers now need it, and of two copies the one that drifted would be the one deciding what reaches a public repository.scripts/is not in the published package, so this leak reached git and never npm. That was luck about which directory it landed in, not a property of the system.
Related
- ADR 0161 — a gate that recognises one error is blind to every other.
- ADR 0162 — a gate that reddens from load teaches you to disbelieve red.