Originally published on hexisteme notes.
On October 23 the daily drift report for my gate suite contained this line (translated from Korean):
check_offer_subject_alignment.py: PASS → REGRESSION ← a fix you'd made broke again.
That note is the loudest thing the drift watcher can say. It is reserved for the case where something I had already fixed has stopped working. The same gate had printed it on October 18, gone back to PASS on October 21, and printed it again on the 23rd. Before that day's fix, the drift log already held ten verdict changes for this one gate, going back to September 7.
When I opened it, the live data the gate checks was nowhere near the failure it guards against. What had failed was the gate's ability to look. The suite had no verdict for that, so it used the loudest one it had.
The two Stop hooks behind this note are on GitHub under MIT: hexisteme/hard-gate-hooks. They ship with their tests and a read-only scanner that prints what they did on **your* machine, not mine — including the case where it tells you they aren't worth wiring up yet. No email, no signup.*
What the gate checks
The gate is one of 22 scripts in a suite I run over my publishing pipeline. This one checks, against live data, that a link I put in some dev.to posts sits on the posts it belongs on (the ones tagged agents) rather than drifting onto unrelated ones. It reads articles/me/published, then makes one comments?a_id= call per commented article: about 100 live API calls per run. A launchd job runs the suite once a day; on October 23 the run landed at 00:19 UTC.
The gate ends with one of three exit codes:
-
0ALIGNED: the link sits where it should -
2REVERSED: the link has drifted onto the wrong posts, which is a real finding -
1UNMEASURABLE: it could not read the live data
Inside the gate those three meanings are distinct. The runner only saw two of them.
The runner knew one kind of non-zero
The suite runner turns each gate's exit code into a verdict. Before the fix, the decision was this (comments mine):
def judge_gate_outcome(exit_code, declared_reason, output="", expect=None):
if exit_code == 0:
return STALE_BLOCK if declared_reason else PASS
if exit_code == 124:
return CRASHED # the runner's own timeout
if not declared_reason:
return REGRESSION # any other undeclared non-zero lands here
if expect and expect not in output:
return REGRESSION
return BLOCKED
declared_reason comes from a file, gate_human_blocks.json, where I can declare that a gate is red because it is waiting on a person. A declared gate that fails reads BLOCKED instead of REGRESSION, and a declared gate that passes reads STALE_BLOCK, so exemptions can't outlive their reason. This gate had no declaration. Exit 1 and exit 2 went down the same branch: "I couldn't read dev.to at 00:19" and "the link has drifted onto the wrong posts" both came out as REGRESSION, and the watcher put its broken-fix note on both.
How I worked out that the 00:19 run never measured
The drift log stores verdicts only. It keeps neither the exit code nor the stderr of each scheduled run, so I could not look up what happened at 00:19. I had to reason from a side effect.
The gate caches its last successful live measurement and reuses it for 24 hours. It writes the cache only right after a successful fetch. If loading the API key or the fetch raises an error it expects, such as a network error or a timeout, it prints UNMEASURABLE to stderr and exits 1 before the write. Any other exception in that span also exits 1, without that line, and also before the write.
At 13:32 UTC I ran the gate by hand, without the flag that forces a refetch. It reported its data as 0.0 hours old: it had found no cache young enough to reuse and had fetched everything live. As far as my session logs and the scheduler list show, nothing else had run the gate in between. The daily launchd job is the only scheduler that calls it, and no session of mine had run it since the previous day's scheduled run. That leaves two ways the 00:19 run could have produced a verdict from data:
- It fetched live successfully, whatever it found. Then it would have written a cache about 13 hours old by 13:32, and the afternoon run would have reused it instead of fetching. It didn't.
- It reused an older cache. Any cache still young enough at 00:19 had to come from the previous day's scheduled run, and the verdict is computed from the cached data alone. That data had produced PASS the day before, so reusing it would have produced PASS again, not REGRESSION.
Neither fits. What fits is a run that failed somewhere before the cache write, before it had any data. The fetch from dev.to is the only network step on that path, so the likeliest reading is that the gate could not read dev.to at 00:19. The logs can't tell me which error it was, or whether the gate caught it.
Here is what the afternoon run measured:
- 76.5% (26 of 34) of
agentsposts carry the link, against 32.0% (32 of 100) of the other posts - a second, comment-based measure pointed the same way: 58.8% against 34.0%
That is the alignment the gate exists to protect, by a wide margin. At 00:19 the gate never got to look, and the suite reported that as the gate finding a problem.
Why exit 1 couldn't become "unmeasurable"
The obvious patch is one line in the runner: read exit 1 as unmeasurable. That would have broken other gates. Exit codes are a protocol between the gates and the runner, and in this suite 1 already meant something else. check_reader_turn.py, for one, returns EXIT_MISSING_TURN = 1 when it finds a real defect. Remapping 1 suite-wide would have quietly downgraded that gate's real failures. Python also exits 1 on an uncaught exception, so 1 is what a crashing gate produces by default.
The suite already had a convention. It just wasn't written down where the runner could enforce it. Seven other gates declared EXIT_UNMEASURABLE = 3. This gate declared the same constant with a different value, 1, and it was the only one that did.
The fix
Three small changes:
-
The runner documents
EXIT_UNMEASURABLE = 3as the suite-wide convention and returns a new verdict, UNMEASURABLE, for exit 3. A human-block declaration does not turn it into BLOCKED. A measurement gap is not a failure waiting on a person, and letting a declaration absorb it would hide a dark gate behind a reason written for something else. Exit 2 still returns REGRESSION, because a gate that found a bad state should still alarm. - The drift watcher reports a transition into UNMEASURABLE with its own note ("couldn't measure at all; neither a pass nor a regression; check live access and rate limits") and never with the broken-fix note. In both the suite report and the watcher, UNMEASURABLE sorts just below REGRESSION and CRASHED, and the suite report still prints the tail of the gate's output for it, as it does for those two.
-
The gate changes its
EXIT_UNMEASURABLEfrom 1 to 3, joining the other seven.
The runner now reads:
EXIT_UNMEASURABLE = 3 # suite-wide: "I could not measure", not "I found a problem"
def judge_gate_outcome(exit_code, declared_reason, output="", expect=None):
if exit_code == 0:
return STALE_BLOCK if declared_reason else PASS
if exit_code == 124:
return CRASHED
if exit_code == EXIT_UNMEASURABLE:
return UNMEASURABLE # a declared human block does not convert this
if not declared_reason:
return REGRESSION
if expect and expect not in output:
return REGRESSION
return BLOCKED
Three tests keep it from drifting back:
- the verdict table gets rows for exit 3 with and without a declared reason, and both must be UNMEASURABLE
- a guard test walks every gate the runner discovers and fails if any of them declares
EXIT_UNMEASURABLEwith a value other than 3, which is the exact drift that caused this - a watcher test asserts that a PASS → UNMEASURABLE transition is worded as a measurement problem and carries no broken-fix wording
The guard test is short:
def test_every_gate_declaring_unmeasurable_uses_the_shared_exit_code():
offenders = []
for gate in discover_gate_scripts(REPO_TOOLS):
text = gate.read_text(encoding="utf-8")
m = re.search(r"^EXIT_UNMEASURABLE\s*=\s*(\d+)", text, re.MULTILINE)
if m and m.group(1) != "3":
offenders.append(f"{gate.name}={m.group(1)}")
assert not offenders
The tools test suite passed, 113 tests. I re-ran the drift watcher. It recorded REGRESSION → PASS for the gate and the false alarm cleared.
A check has three outcomes, not two
A check can look and find everything fine, look and find a problem, or fail to look at all. A runner that only has words for the first two folds the third into one of them.
Fold it into the first and a check that cannot run reports something that looks like a result. In A Measurement That Never Ran Became a Verdict, every read failed and the script still printed a normal-looking verdict built entirely out of failed reads; part of that fix was a guard that refuses to turn a failed read into a zero. In The Scheduled Step Failed on Night One. The Heartbeat Stayed Green., a nightly job's step failed on its first scheduled run while the job's own heartbeat kept reporting healthy.
Fold it into the second and you get what I had: a gate that cries regression on every transient outage. An alarm that is wrong often enough gets read as noise, and a real reversal printed on the same line, with the same note, gets read the same way. The exit code is also where Exit 0, Empty stdout: the Quota Died on stderr goes wrong, from the other side. There, a CLI worker hit its usage cap and exited 0 anyway, so a dispatcher that only checks the exit code would log a completed task. In that case the process sent the wrong code. In my suite the gate sent the code it meant, and the runner read it with the wrong dictionary.
Test runners already draw this line. pytest has a separate exit code for "no tests were collected", distinct from the one for failing tests: a run that tested nothing is not reported as a run where tests failed. A gate suite needs the same vocabulary. The measurement gap gets a verdict loud enough to see, because the gate went dark and someone should check live access, and quiet enough not to pass for a regression.
Applying this to your own gates
- Pick one exit code for "could not measure" and document it in the runner, not only in each gate. Avoid 1: Python exits 1 on an uncaught exception, so a crashing gate would read as unmeasurable.
- Give the runner a verdict for it that is neither PASS nor your regression verdict, and sort it near the top of the report so a dark gate is still seen.
- Don't let exemption mechanisms such as human-block declarations or skip lists absorb it.
- Keep "found a bad state" on its own code, and keep it loud.
- Test the convention across every gate the runner discovers, not just the one that broke.
- Record the exit code and the stderr tail next to each scheduled verdict. My drift log doesn't, which is why the diagnosis above rests on a cache side effect instead of a log line.
What this does not prove
- The 00:19 diagnosis is inferred from the cache and from what runs the gate. The drift log keeps verdicts only, so the specific cause (DNS, a rate limit, a timeout) is not pinned.
- If the 00:19 failure was an exception the gate doesn't catch, it exited 1 without printing
UNMEASURABLE, and the same failure would still read REGRESSION after this fix. Only the errors the gate catches now become UNMEASURABLE. - The cache argument covers the October 23 run alone. I have no exit codes for the earlier entries in the drift log, and some of those REGRESSION verdicts may have been real reversals.
- The suite is now quieter on measurement gaps, on purpose. A gate that is chronically unmeasurable shows UNMEASURABLE instead of REGRESSION: still visible, but it no longer screams. A gate that should hard-fail when it cannot measure must not use exit 3.
- The guard test reads only the declared
EXIT_UNMEASURABLEconstant. A gate that used 3 for a real failure without declaring that constant would now be read as unmeasurable, and the test would not notice. A grep of the current gates found none that does, but nothing stops a future one.
Email list for these notes: hexisteme.beehiiv.com — no issue has gone out yet, so you would be on it before the first one. No welcome sequence, no course, no upsell.
More notes at hexisteme.github.io/notes.
Top comments (13)
"The suite had no verdict for that, so it used the loudest one it had." That is the whole failure, and it is a naming problem one layer up from where it looks.
Exit 1 and exit 2 are different claims. Exit 2 says the link drifted. Exit 1 says nothing about the link at all. The runner collapsed them because REGRESSION was the only undeclared non-zero verdict in its vocabulary, and a vocabulary that small forces every unknown into the most alarming thing it can say. Ten verdict changes since September 7 is what that cost.
The part I would keep arguing about is what UNMEASURABLE should do once the runner can see it. A third verdict fixes the lie. It also creates a state that is neither pass nor fail, and those get learned around fast. The property that makes your declared_reason mechanism work is that a declaration cannot outlive its reason, because STALE_BLOCK catches it. UNMEASURABLE needs the same kind of expiry. Not "the gate could not read dev.to", but "the gate has not successfully read dev.to in N runs", which is a number that climbs on its own and that nobody has to maintain.
That also tells you what the gate is for. A gate that cannot reach the API at 00:19 UTC on a daily job has had one bad night. A gate that has not reached it in nine nights is not an availability problem any more. It is an unguarded link, and that is a finding.
I have spent the last week in a thread on the same shape, where a check reports green accurately about a question nobody asked: dev.to/mickyarun/your-health-check.... The sentence that moved it was @ilragioniere's, that a check's result is "a claim with a population, and the population is whatever the reader assumes it covers". Your REGRESSION had a population of two.
The "claim with a population" framing is sharper than my verdict taxonomy — it exposes that REGRESSION was doing double duty for both "link drifted" and "could not ask." Your expiry mechanic for UNMEASURABLE, where the count climbs without maintenance, is the missing piece that keeps a third verdict from becoming a quiet corner. Thank you for naming the population problem and the STALE_BLOCK parallel.
The expiry is the right shape. The sentence it ends on is the same error one rung up.
"It is an unguarded link, and that is a finding." Nine nights of not reading is a finding about the gate. It says nothing about the link. Unmeasured and unguarded are as far apart as exit 1 and exit 2, and the fix swaps them in its final line, which is the exact swap this post is about.
So keep two objects apart in the report:
One detail from your own diagnosis decides the counter's unit: count fetches, not verdicts. The 24-hour cache prints a reused PASS exactly like a measured PASS, so a counter keyed on "successfully read" can be fed by runs that never opened the API. Your October 23 inference only works because the cache is written right after a live fetch. Make that same line the counter's increment, and "N runs" stops being ambiguous: nine scheduled runs are not nine attempts, and a hand run against a warm cache is not one either.
Last, on "still visible, but it no longer screams". Once the count exists, chronic UNMEASURABLE is not quieter than REGRESSION. It is the same alarm with a better name: nothing has looked at this link in nine nights. The quiet should live in the wording, never in the state.
Agreed on the split. Nine nights without a read is a fact about the gate, and the link side of the report should stay empty, not say "unknown".
The line you quoted at the end is one I wrote, and you're right about it. "Still visible, but it no longer screams" put the quiet in the state. UNMEASURABLE sorts below REGRESSION and CRASHED, and nothing about a ninth night ranks it higher than the first. The difference was supposed to live in the note. It ended up in the sort order too.
On the counter's unit, I checked what each layer records. The gate's own stdout does print the data age ("measured at ... (N hours ago)"), so a reused PASS and a measured PASS differ there. But the runner turns both into the same PASS. The drift log has kept the exit code and the last four lines of output since October 24, but only for gates that didn't pass. So at the layer where a counter would live, they are the same row, and your point holds.
That makes the cache timestamp the natural increment. There is exactly one cache write in the gate, and it runs right after a successful live fetch, so "time since the last fetch" falls out of
measured_atwith no counter anyone has to maintain. One catch: the gate ignores a cache whose fields don't match its current schema (that happened when a page-views field was added), and a rejected cache reads as infinitely old. If the counter reads the timestamp through the same loader, a schema change makes the gate look like it has never fetched, when it fetched under an older shape. The timestamp has to be read on its own, not through the schema check.None of this is built yet. A gate-side line and an empty link field is the shape I'd give it.
The schema catch is the same swap in a smaller room: a record this reader cannot parse becomes a record that never existed.
Two objects again. That a fetch happened at a given time is one fact. Whether this reader can trust what was fetched is another. Let the cache carry its stamp outside the schema'd fields, a written_at the writer sets (or the file's own mtime), so age stays measurable by anything that can see the file. Then a schema mismatch reads as fresh and unreadable, which is a config bug that fails loud on the first night. Read through the schema check instead and it reads as never fetched, which is a slow death that looks like patience.
Your exclusion rule is right: a rejected cache should not be consumed. The only thing to move is where the clock lives. Keep the content gated, free the clock.
Gate-side line, empty link field, age from an ungated stamp. That is the shape I would build too.
The "ungated stamp" separation is sharper than my gate-level fix: moving the clock outside the schema'd payload lets a version mismatch read as fresh-and-unreadable instead of never-fetched, which turns a silent stall into a loud config failure on the first run. Your line — gate-side, empty link field, age from the file's own mtime — is the cleaner contract; it keeps content gated while making staleness measurable by anything that can stat the file. Thanks for naming the shape.
mtime is the half of that pair I would not trust as the attestation.
The fetcher does not set mtime. Whatever last touched the file does: a copy, a restore from backup, a container image built at 3am, an editor save, a sync that rewrites identical bytes. Each of those resets age without a fetch, and the cache now reads fresh and stale at once. That is the same substitution one layer down, only the liar moved from the schema to the filesystem.
So: written_at set by the writer is the stamp. mtime is a floor, not a clock. And if mtime lands later than written_at, treat that as a third state rather than youth. Something touched the file after the writer finished, so the contents may not be what the stamp attests. Unverified, and loud about it.
Keep the content gated, free the clock, and make sure the clock is the one the fetcher started.
You're right, and mtime was the wrong pick on my side. I checked the gate: it never used mtime. Age comes from
measured_at, which the writer stamps in the same function that returns the measurement, and nothing in the gate stats the file. On the current cache the two agree to the second, which is the one case where comparing them tells you nothing. So the ungated read should parse the JSON, takemeasured_atalone, and skip the schema loader. A file that isn't JSON or has nomeasured_atis unreadable, not never-fetched.Your last line caught a second thing.
measured_atis taken after the loop over every article finishes, not before the first request. The clock is the one the fetcher stopped. Against a 24-hour window that won't flip a verdict, but the stamp says the whole set was read at a moment when the first articles had been read earlier.Moving it to the start changes your third state. Today the stamp is taken just before the write, so mtime trails it by a second or so. Stamp at the start and mtime trails by the whole fetch on every run, so a strict "mtime later than the stamp" fires on every honest write. The writer would have to record two times: when the fetch started, for age, and when it wrote, for the mtime check, with a small tolerance on the second.
None of this is built yet.
The stop-clock catch is real, and it cuts my line more than yours. I said free the clock without saying which end. Age belongs to the oldest thing in the cache, and the oldest thing is the first article fetched, not the moment the loop closed. So:
fetch_startedis the stamp. Two times, agreed.But I would keep the second one out of the verdict.
Ask what "mtime later than written_at" is for. It is meant to say the file was edited after the writer finished. It cannot say that. A copy, a restore, a container image built at 3am, a sync rewriting identical bytes, all move mtime past written_at on content the writer genuinely wrote. The state fires on honest files, and honest files are the majority. The only way to keep it quiet is a tolerance, and a tolerance gets widened until it only catches edits slow enough not to be edits. That is the failure you just diagnosed, one rung up: a check that stays green because someone turned the dial.
And the edit it is meant to catch is not catchable by anything living in the same file, because whoever edits the measurement also writes the stamp. Timestamps attest order, never authorship. Authorship is a signature, which is a different build.
So my version of the contract:
fetch_startedjudges age, one clock, no tolerance.written_atstays, but as forensic output printed beside the verdict. When mtime disagrees with it, print "touched after the writer" as text on the failure page and add no state. Fresh-but-unreadable stays loud, stale stays alarmable, and the third state disappears along with the constant that would have silenced it.None of this is built yet, which makes it the cheapest hour to disagree.
Thanks for this. It was a better fix than the one I had, and it's built now, in both gates that keep a dev.to cache.
measured_atis stamped before the first request. The writer addswritten_atjust before the write, and mtime is only compared against that, with five seconds of slack. A small reader pulls the stamps without the schema check and returns one of four states: no file, no readable stamp, unverified (nowritten_at, or the file changed after it was written), or attested. A gate only uses a cache that is attested and young. Anything else gets re-measured, and if that fails, the UNMEASURABLE output ends with the last measurement time, which now survives a schema change.Your point found a real case while I was building it. A test called the gate's
main(), and because the default cache path was bound when the function was defined, it wrote a fixture over the real cache. That file had a currentmeasured_at, so the old gate would have trusted it for a day. The new reader calls it unverified, but only because the old writer never stampedwritten_at. The same mistake with the new writer would produce an attested fake. The stamp shows which code wrote the file, not that the code was pointed at the live API. That case needed a test that the real path stays untouched, not a better clock.The first live run after the change got a 429 from dev.to, and the new line printed "no cache file". That was true, but only because I had just deleted the fake.
The fixture is the best thing in this reply, and it answers its own question: a stamp attests the writer, not the address. So the fix is not a better clock, it is a namespace. A cache a test can reach through a default argument is in the wrong place, and the default path should not resolve to the live path at all. Keep the test you named, and make the live path unwritable from the test process, so the accident fails loudly instead of writing a plausible file.
The 429 line is the same lie one size smaller. "No cache file" was true, and it names one of four possible causes. Never fetched, deleted, restored from a backup, path moved: the state cannot tell them apart. But there is a structural problem under it that I did not see until your run. The last measurement time you print cannot survive the deletion that produces the absence, because that record lives inside the file whose absence is being reported. So "no cache file" is the only thing you can print in exactly the case where the difference matters, which is the case you just lived through.
If you want the difference, the last-known time has to live outside the object that gets deleted. An append-only line in the gate's own log, or a small pointer file that only ever advances. Then absence prints "no cache, last measured at T", which is a different line from "no cache, never measured", and the second line is the one that would have caught your fixture.
On the five seconds. A slack chosen once and never moved is a constant. A slack widened after an alarm fires is a liar, the same way the tolerance I warned about was. Keep it fixed, and write down why five and not fifteen, so the next person cannot tune it into silence.
Built in both gates, one reader, four states: that is the part worth keeping. The state I would trust at 7am is the one printed next to the absence, not the one printed next to the file.
The namespace half is partly there. The default path is resolved when the function runs, not when it's defined, and both gates have a test that runs main() against a patched path and checks that the live cache's bytes are unchanged afterwards. That catches the accident after it happens. Your version prevents it, and I don't have that: nothing stops a test process from writing the live path.
The absence line is worse than you said. When the file is missing, the reader prints "no cache file, never measured". The second half is a guess, and in the run I described it was wrong: the gate had measured, and I had deleted the result. The last-measured time lives only inside the file, so deleting the file deletes the evidence that would correct the line. A record outside the cache would fix that, and it isn't built.
On the five seconds: it hasn't moved since I set it. The comment beside it says why it isn't zero (written_at is taken just before the write, at one-second resolution), but not why it's five and not fifteen. That's the part someone could tune into silence.
The namespace half I would still argue about, and not about the mechanism.
Your byte test and a namespace answer different questions. The test asks "did this test process write the live path". A namespace answers "can any caller reach it". Those populations are not the same size. A script, a notebook, a half-finished refactor, a REPL session are all callers, and none of them run a test. The defect was a default argument in a test, but the class is that the live path is reachable by name from anything that imports the module, and a test samples one member of that class. That is why prevention is not a better detector. It is a different sentence.
On absence, the pointer has one trap left. It has to advance on the same line as the cache write, not when the gate runs. If it moves on invocation, "last measured at T" becomes "last run at T" and you are back to exit 1 wearing exit 2's name. An append-only line in the gate's log survives better than a pointer file for the same reason you deleted the cache: a file is a thing that can be removed or restored, and a log's past cannot be erased without erasing more than the record.
On five: the floor is the part your comment already has. The missing half is the ceiling. The slack has to be larger than one write and much smaller than the smallest edit worth catching. Five sits inside a wide honest band, and the band is what to write down, because a number with only a floor can be raised forever. Motion is the liar, so log mtime minus written_at on every run: the comment stops being a justification and becomes a distribution.