0009 — A liveness signal must fail when the SYSTEM fails, not when its log stops

The problem, paid for in a panic

On 2026-09-01 at 13:06 the Mac mini kernel-panicked after 81 hours of uptime. 88 Claude Code processes held 114.8 GB resident on a 64 GB machine; free memory was 14.9 MB, the file cache had been evicted to 1.8 MB, the compressor held 35.6 GB across 65 swapfiles, and the pageout path asked for 3,090 pages and reclaimed 23. Userspace could not be scheduled, watchdogd missed its check-in for 90 seconds, and the kernel panicked deliberately.

Every rung of ADR-0003's ladder was working throughout. ccswitch healed wedged sessions, rotation spread credentials, nudge woke parked sessions — each rung restarting something inside a machine that no rung could see.

The obvious fix is a host entry in SYSTEMS.md. The trap is how it gets its signal.

The rule

A signal must go red for the failure the system actually has. Concretely, when choosing a signal type, ask: what does a green reading here prove?

file-age proves only that something wrote to a file recently. For a sampler, that is proof the sampler ran — never proof of what it found. Had the host entry used file-age on its snapshot, the Systems tab would have rendered the mini Healthy at 14.9 MB free, because the sampler was faithfully recording the machine's death every five minutes. Green would have meant "the thermometer works."

So a signal over a measuring system buckets on a word that only appears when the measurement is good:

This is the same shape reviewer-tokens uses for cycle done and nudge for NUDGED, and it generalises: match on a word that means success, never on the absence of a word that means failure. Absence is indistinguishable from the log not being written, from the pattern being renamed, and from the check never running.

The corollary: pick the metric that moves first

Thresholds have the same failure mode as signals — a metric can be true, prominent, and still useless as a warning.

Free-memory percentage is the trap. At panic, inactive stood at 12.2 GB: nominally reclaimable, enough to read as ~19% available on a machine seconds from death. The kernel asked for 3,090 pages of it and got 23. Inactive memory is a promise, not a reserve.

The metrics that actually moved first were swap and the compressor, so those carry the thresholds and free memory is the last resort rather than the headline. When choosing what to threshold, prefer the quantity that degrades early and monotonically over the one that is easiest to read.

Consequences


Amendment, 2026-10-13 — the mirror rule: a signal must not fail when the system is FINE

What this adds

The rule above asks what a green reading proves. Two failures live on the other side of it, and both were paid for on this one row.

1. A PROXY read after it stops tracking its PROPERTY is not a weakened signal, it is a FALSE one. spend-alarm carried a post recorded today, a file-age on the dedupe ledger a Slack post is recorded in. It stood in for is the check alive? and it worked only because of a property of the OLD behaviour: every UTC day the check posted at least once, so the ledger was touched daily. agent-smith#957 severs that deliberately — Fede ruled 2026-10-12 for major things only, so a quiet day is MEANT to post nothing and silence becomes the healthy state. The signal would then have reddened every quiet day and sent someone chasing an outage that is the new intended behaviour. The remedy is to retire the proxy, not to widen its staleAfterS: widening keeps a signal whose green means nothing and whose red is noise, and a quiet WEEK reddens it anyway. So: when a change makes silence healthy, every signal that counted output is now false, and the PR that makes the change (or the PR that catches it) retires them.

2. A signal must go red for the failure the system HAS, and green when it has none — the second half had no rule, and the gap cost weeks of red. The same row's check completing is the right shape by this ADR's own prescription: a log-match-age on cost_anomaly done, a word written only when the run COMPLETES (EXIT_INCOMPLETE raises above it). It still could not carry liveness, because the evaluator's window, not the signal, was wrong: evalLogMatchAge tailed a fixed 64 KB of a log the whole Smith worker writes to. Measured 2026-10-13 — 0 matches at 64 KB, 128 KB, 256 KB, 512 KB and 1 MB; one match at 2 MB, 76 minutes old against a 4 h budget. The marker left the window ~5 minutes after each 3-hourly fire, so the row read down for ~175 of every 180 minutes while the check was completing on schedule, and nobody chased it because it failed toward red.

That is the cost of the asymmetry. A false green is loud once someone checks; a false red is silent forever, because the page simply stops being read. On the day this was measured the fleet showed 27 down signals, and this row was one of them.

The rule: a log-match-age window must span the pattern's cadence at the log's write rate. It is now derived rather than declared — the tail doubles from 64 KB until the pattern is found or the window reaches back past that signal's own staleAfterS, so a declared budget is honoured instead of being silently clipped to log volume. A number in a signal that the evaluator cannot actually honour is the same defect class as a green that proves nothing.

And the rule holds only UP TO THE CAP, which is why the cap must not be the thing that decides. LOG_TAIL_MAX_BYTES exists for a log with no parseable stamps (no horizon to find) and for a pathological file. Set too low it reintroduces exactly the false red this amendment removes: at the 8 MB first draft, spending-digest — 26 h budget, last marker measured 12.49 MB from EOF — would still have read down, which review caught. It is 32 MB, chosen so that the largest budget any catalog entry declares (93600 s) at the busiest log's measured rate (~0.8 MB/h, ~21 MB) is inside it, i.e. so the HORIZON decides for every signal in the catalog. A budget that needs a bigger cap is not a cap problem — it is a signal pointed at a firehose, and the fix is its own log (ci-runs-on-canary is the precedent). A down that stopped at the cap now says so in its detail, because a false red must be diagnosable and the fixed tail's no "X" in recent log was not.

The cost is on the relay's only thread, so it is paid in index scans, not allocations. The evaluator reads synchronously and the relay is single-threaded; splitting a 20 MB window into lines to find one of them would allocate ~300k strings on every 10 s catalog pass. So the match, the matched line's bounds and the window's oldest stamp are all found with bounded indexOf / lastIndexOf scans over the same buffer. A signal whose pattern sits in the first 64 KB — which is every signal on a quiet log — pays exactly what it paid before.

Inventory of the old surface (ADR-0004 §2)

Site Was Now
relay/src/systems/catalog.ts evalLogMatchAge one 64 KB tail widens to the signal's staleness horizon, capped at LOG_TAIL_MAX_BYTES
same file, CATALOG_CACHE_TTL_MS docstring "a 64KB log tail per log signal" states the widening
SYSTEMS.md spend-alarm 3 signals incl. a post recorded today 2 signals; _retired_signal_note records the retirement and why
SYSTEMS.md ci-runs-on-canary "whose 64 KB tail window is ~11 minutes" names the fix; keeps its own log for the canary reason
SYSTEMS.md phase-snapshot the measured ~11-minute objection says the window half is fixed at the source
SYSTEMS.md finding-inbox _signals_note "the evaluator tails only the last 64 KB" states the horizon behaviour

Three catalog entries had written workarounds for one instrument bug. That is the tell this belonged in the evaluator, not in a fourth entry's prose.

Consequences


Amendment, 2026-10-14 — the same mirror rule, one layer lower: the PARSER

What this adds

The amendment above fixed the WINDOW: a log-match-age signal could not find its marker because the tail was too short. One layer below the window is the PARSER, and it had the same shape of defect — a signal must be able to read the logs the fleet actually writes, and ten rows proved it could not.

LOG_STAMP accepted exactly one stamp shape: YYYY-MM-DD HH:MM:SS, space-separated, host-local. Measured on the live relay and on disk, 2026-10-14:

~/.claude/jobs/specialists.log 77,400 lines; 0 space-separated; 77,385 …T…Z; 15 …T…+00:00
signals reading unknown | matched line has no timestamp 12, across 10 rows on the DEPLOYED relay; 14 across 11 with #343's window (it adds test-inbox, still down on the deployed build, and review-webhook's third)
…of those, over specialists.log 9 signals across 8 rows (cleaner carries two). ⚠️ The task that produced this amendment was briefed as "nine ROWS"; measured, the nine is a count of SIGNALS and the rows are eight

test-inbox is the row that makes the stacking visible. Its marker sat 533 KB behind EOF, so before #343 it read down — no "test-inbox exit=" in recent log. #343's widened window finds it, and the row then lands on this parser and reads unknown instead. Two bugs in series: fixing the window alone moved the row from one permanently-wrong state to another, and no reading of the page before the deploy would have shown that.

Why this is the mirror rule and not a nitpick

unknown is the HONEST state for "I could not read it". The evaluator was behaving correctly and saying so; the defect is that ten rows were PERMANENTLY stuck in it — eleven once #343 deploys — with no reading of any system able to change them. A page a third of whose rows can never be green is a page nobody reads — and that bill came due the same night: the nightly turnover benches returned === VERDICT: RED === in both logs, "synced":0,"failed":2 and "synced":0,"failed":4, zero of six work orders syncing back, and nobody was told, because that true red sat beside three broken thermometers.

So the rule, stated at the parser's layer: the set of stamp shapes the evaluator accepts is a CONTRACT with every log writer in the fleet, and a writer that does not know it exists will break it silently — the row goes unknown, every test of the writer stays green, and nothing anywhere fails. Three entries in SYSTEMS.md had already written a workaround for this one parser (red-main, fleet-deploy, tracker-refresh each chose file-age and cited the parser as the reason), which is the same tell the window fix left: a workaround written in a third entry's prose means the bug is in the instrument.

The widening, and the one risk in it

Accepted now: YYYY-MM-DD, then T or a space, HH:MM:SS, optional fractional seconds, optional zone (Z, ±HH:MM, ±HHMM).

The zone is HONOURED, not discarded, and that was the whole risk. A regex capturing only HH:MM:SS and dropping a trailing Z hands new Date() a zone-less string, which ES2015+ reads as HOST LOCAL — so on this UTC-5 mini every …T…Z line would be dated five hours in the FUTURE, and a marker six hours stale would read ok. That is strictly worse than the unknown it replaces: a false green over a dead specialist. Measured against that exact mutant: a 6-hour-old …Z marker on a 4-hour budget reads ok, detail "test-inbox exit=" 60m ago. A zone-LESS stamp (the space form, and a bare T) stays host-local, unchanged — 52 signals read ok from space-form logs today, which is EVERY ok log-match-age signal in the catalog (counted off the live /api/systems response, not estimated), and reading those as UTC would redden the page wholesale.

NOT accepted, as a scope call and not an impossibility: a stamp wrapped in brackets ([2026-10-14T05:49:53.753Z] event.in, which ~/.claude/services/review-webhook/listener.log writes). The ^ anchor is what makes the line opens with its own stamp checkable, and relaxing it to skip a leading delimiter is a different decision about what a log line is. review-webhook's three signals stay unknown after this change; that is named on its entry and pinned by a test, so it stays a decision rather than becoming an oversight.

THE SECOND DEFECT THE FIX EXPOSED, and the reason it is in the same PR

Widening the parser turned two rows from a permanent unknown into a permanent false GREEN — this ADR's ORIGINAL failure, not its mirror. specialists.log is one file carrying 24 tools whose names nest, and the evaluator matches SUBSTRINGS:

row pattern was what it dated what it meant to date
finisher finisher dock-finisher, 47,471 lines, seconds old the finisher ENTRY POINT
optimizer optimizer work-optimizer, 209 lines, minutes old the optimizer ENTRY POINT
updater updater either; branch-updater is 3,928 of 13,047 updater, 9,082 lines

While the parser was space-only this was invisible: all three read unknown regardless. updater is leading-space-anchored now ( updater exit=), the form every other signal on this log already used. A latent false green that only a widening can expose belongs in the widening's PR, because the widening is what arms it.

⚠️ AND THE FIRST DIAGNOSIS OF TWO OF THOSE THREE ROWS WAS WRONG — RETRACTED HERE, NOT SOFTENED

For one round of agentflow-relay#349 this section said optimizer and finisher would read down "because nobody has run either tool in a month", and offered a human the choice between status: superseded and a wider budget. Both the reason and both options were wrong, and the correct answer was neither.

~/.local/bin/optimizer and ~/.local/bin/finisher are symlinks — to work-optimizer and dock-finisher, inodes 706783833 and 706783830, created Sep 13 00:40. And _record_run writes os.path.basename(os.path.realpath(__file__)) — realpath, which RESOLVES the link — so an invocation through the alias executes the target AND LOGS AS THE TARGET. Measured by loading each script through its ALIAS path with CLAUDE_JOBS_DIR redirected, so nothing touched the live log: invoked as optimizer it wrote work-optimizer exit=0; as finisher, dock-finisher exit=0; as cleaner, worker-cleaner exit=0. The control that makes that a measurement rather than a tautology: updater, which is NOT a symlink, wrote updater.

The bare optimizer and finisher lines in the log are real — 51 and 213 of them — and they stop at 2026-09-14T05:59:02Z and 2026-09-13T22:31:10Z. They stop because local-bin changed _record_run from abspath to realpath at 2026-09-14 ~01:30 CT, after fleet-grader spent a full cycle reporting work-optimizer: no run in 3h while the aid had just run it twice through that alias. The incident is written up in ~/.local/bin/tests/specialist-log-names-the-canonical-tool.py, which derives the alias list from the checkout and asserts the canonical name is recorded. That fix collapsed the two identities deliberately, so the string those two rows matched can never be written again.

So the widening did not uncover a month of disuse. It converted a signal that was unmatchable-as-unknown into one that is unmatchable-as-down — the same defect wearing the other colour, which is why "red forever is the same disease as unknown forever" was the right instinct attached to the wrong cause.

The fix is the string. Both patterns name what the binary writes now ( work-optimizer exit=, dock-finisher exit=), and both rows read ok — work-optimizer ran 2026-10-14T05:47:19Z, dock-finisher 06:37:49Z. cleaner is the named precedent (§2) and needed no change: it is also an alias entry (~/.local/bin/cleaner -> worker-cleaner) and has always matched worker-cleaner exit=.

Rejected, and why neither was right: status: superseded would assert a retirement that has not happened — both entry points work and are documented; a wider staleAfterS would hide a red this ADR's own amendment refuses to hide. A third option, collapsing each pair into one entry named for the binary, was considered and NOT taken: id is this catalog's machine key and its header says never to rename one casually, deployedAt pointing at the alias is correct because the alias is the documented entry point, and cleaner already establishes the alias-entry-with-canonical-pattern shape. Inventing a second idiom for a solved problem is what §2 forbids. That collapse would be an ADR-0004 decision; this correction is not — it changes no relationship between systems, it makes a pattern name the string the tool already writes.

One marker now carries two rows, and that is stated on both entries rather than left to be noticed. dock-of matches the same dock-finisher exit= string, deliberately and as a PROXY: dock-of writes no specialists.log line of its own (verified — no _record_run, no write site), and every board reader resolves its dock through it, so a fresh dock-finisher run is evidence a board reader resolved a dock. For finisher the same line is DIRECT evidence. Two rows that will always agree, for two different reasons.

The lesson that generalises, which is why this is in the ADR and not only in a PR body: a log-match-age pattern is a claim about the string a binary writes, never about the name anyone invokes it by. An alias makes those two different, and nothing in the catalog can tell them apart — the row looked correct, the tool was healthy, and the signal was dead. When a row is keyed on a tool's name, the check is basename(realpath(<the entry point>)), run against the box.

Is this an ADR-0004 decision? — judged, not assumed

Yes. ADR-0004 asks whether a decision changes how systems RELATE. Widening a parser sounds internal, and if the stamp format were private to the relay it would be. It is not: it is the contract between the catalog reader and every log WRITER on the box — local-bin's specialists, agent-smith's fleet_deploy.py, dotclaude's operator-heartbeat. Those writers were constrained to one stamp shape BY this parser; agent-smith carries a test, test_log_line_stamp_is_what_the_relay_can_parse, that asserts the constraint against the regex itself. That constraint is relaxed now, and a writer in another repo cannot learn it from a merged PR body. (That test still passes: the space form is still accepted. Nothing in another repo has to change — but what is PERMITTED there did.)

The converse was considered and rejected: branch (b) of the fork, making the specialists write the space form. It is more invasive (every writer in three repos), it leaves 77,400 existing lines unreadable, it fixes nothing for hooks-pin, driver-setup or red-main, and it would have to be re-litigated by every future writer. Branch (a) is one regex, one repo, one deploy.

Consequences


Amendment, 2026-10-26 — when the SUCCESS WORD cannot be written, the signal reads the measurement itself

What this adds

This ADR's original rule prescribes a remedy as well as a diagnosis: a signal over a measuring system buckets on a word that only appears when the measurement is good. reviewer-tokens is the first entry where that remedy was not available, and the gap let the catalog's worst false green stand for three hours.

Measured. On 2026-10-26 the code-review bot was unusable across three repos from roughly 00:52Z for about three hours: every account in claude-pool-reviewer answered HTTP 429. Six finished propflowai PRs plus local-bin#666 and agent-harness#1477/#1484 sat with no verdict, each surfacing per-PR as Review infra failure (tool_crash) and All 3 Claude account slots are OUT OF BUDGET (HTTP 429). The row was green throughout. Its five signals are three log-match-age markers, one launchd probe and a file-age on ~/.claude/jobs/reviewer-pool/status.json, and every one of them measures whether the ROTATOR RAN. Off the rotator's own log, five consecutive cycles from 2026-10-25 20:19 to 2026-10-26 04:33 local: each logged reviewer cap … 429 three times, wrote a fresh status document, and closed with cycle done. A rotator that runs perfectly and faithfully records an outage satisfies every freshness signal there is — which is this ADR's original sentence, one layer up from the host sampler it was written for.

Why the prescribed remedy was unavailable, judged rather than assumed

The obvious fix is this ADR's own: make the rotator write a success word and match on it. That is also agentflow-relay#431's freshest precedent — it gave pr-autoqueue a stuck census ok mode=live line and declared a log-match-age on it, needing no new type and no ADR. It was considered first and rejected on three measured grounds:

  1. The nearest existing marker is pointed at the wrong pool. The rotator's terminal line is cycle done: N token(s) banked, F above reserve, and in rotate-reviewer-tokens.sh N is the reviewer pool (real) while F is the FLEET pool (fresh, over ranked ← RECORDS ← ACCOUNTS). Through the outage it read 3 token(s) banked, 14 above reserve, and the sibling ALERT: no account above the … reserve on both axes — whose own comment says it exists because "healthy" counted 200s and "stayed silent through the very outage it exists to announce" — never fired, for the same reason. A guard written against this exact disease missed this exact outage because it measures a different pool. That is a dotclaude defect, reported with this amendment and not fixed by it; what matters here is that no marker on that log can carry this predicate today.
  2. A substring cannot carry an arithmetic predicate. log-match-age asks "does this string appear, and how old is it". "At least one of five accounts is live" is a count over a document. Encoding it as a word means the writer computing it — i.e. a second repo, a cutover, and a vocabulary that then lives in two places.
  3. The measurement is already written down. The per-account state has been recorded and rendered on this very row since 2026-10-22. ADR-0009's remedy exists because a sampler's finding was unavailable to the catalog; here it is sitting in a JSON document the row already declares as its detail.

So the rule this amendment adds: when a system already PUBLISHES its measurement in a document the catalog reads, the signal reads the measurement — it does not ask the system to re-encode it as a word. Matching on a success word stays the remedy of choice wherever the finding is not otherwise published, because it needs no schema contract; pool-live is the narrower case, not a replacement.

The one new type, and its two fail-closed halves

pool-live (relay/src/systems/catalog.ts, evalPoolLive) is green iff at least one entry in the document's accounts array has state ok.

The predicate is an ALLOWLIST. The row asked for "neither capped nor failed", which is the same predicate today and the wrong shape tomorrow: a blocklist reads a newly-invented state as healthy, which is this row's own defect wearing a new word. POOL_LIVE_STATES is {ok} and an unrecognised state is not live.

And the mirror rule decides every non-answer, in both directions:

Reading State Why
≥1 live, fresh ok at least one account can serve a review
0 live, readable (incl. accounts: []) down a successful read saying nothing is live is the PREDICATE failing, not missing evidence
0 live, document stale down staleness never SOFTENS a known-bad reading
≥1 live, document stale stale "one account was live three hours ago" is not evidence it is live now
unreadable / unparseable / no accounts array unknown each stated distinctly; unknown outranks stale in worstState, so none renders healthy
accounts present, none carrying a readable state unknown, not down ccswitch's snapshot.json has an accounts array whose rows have no state field. A bare "none of them is ok" would render a permanent false RED on any row this signal is misaimed at — the exact false red the 2026-10-13 amendment refuses. A misaimed signal says it cannot read the document; it does not announce an outage.

account status written is deliberately kept beside it rather than replaced: a fresh document with no live account is a capped pool, a stale document is a stopped rotator, and one signal covering both could not tell a reader which it was looking at.

Is this an ADR-0004 decision? — judged, not assumed

Yes, and for the same reason the parser amendment was. The state vocabulary — ok | capped | no-token | failed — is minted by the jq in dotclaude's rotate-reviewer-tokens.sh and is now READ as a predicate by agentflow's evaluator. It was a presentation detail of a rendered table; it is load-bearing now, and a writer in another repo cannot learn that from a merged PR body. The authority stays the script: POOL_LIVE_STATES names the one word it turns on, and the catalog entry's _signals_note names the script as the authority while carrying the enumeration only as a dated snapshot — the shape #431's review rounds settled on for exactly this, after its own prose was caught asserting another repo's constants as standing fact.

And this contract already has a SECOND reader, which is a consequence rather than a caveat. propflowai carries its own copy of the signal-type union (src/lib/domain/agentflow/types.ts) and, in src/lib/domain/agentflow/specialty-jobs.ts, splits the types into WORK evidence (file-age, log-match-age) and PRESENCE (launchd, self), then derives the SpecialtyJobsCard's health from the work bucket alone. Measured 2026-10-26: reviewer-tokens declares a launchd signal, so isBackgroundJob selects it, and pool-live — the only signal on the row that reads a measurement at all — would land in presenceSignals and be excluded from that card's verdict. The /agents Systems tab is unaffected (it renders signalResults worst-wins, over every signal). So this amendment's rule has a corollary it cannot enforce from this repo: a new signal type is not finished when the relay serves it — it is finished when every surface that CLASSIFIES types has learned it. That is a propflowai change, named on the entry and handed to the dock as its own row rather than deferred to a PR nobody has planned.

This is also the first signal type whose predicate is the CONTENT of another system's document rather than its freshness, its mtime or a log line. That is the relationship change, and it is bounded on purpose: the type reads one array, counts one field, and makes no call (ADR-0003 — rungs integrate by signals, never calls). It stays read-only, like every evaluator here.

Consequences