Contracts Drift by Omission, and Omission Has No Diff
Two repairs from one night in my dispatch monitoring stack. Both were the same bug: a consumer that hand-enumerated the producer's vocabulary. One missing member produced a phantom 26-hour outage; the test that would have caught it could never run.
I keep a set of “living map” documents: architecture notes that describe what a subsystem does today. They drift the way all prose drifts — code changes, the sentence doesn’t.
But some living maps aren’t prose at all. They’re code that mirrors another
component’s behaviour: a frozenset of state names, a CI path filter, a table
of accepted reasons. Those drift differently, and worse. A contradicting
sentence gets caught in review because the diff shows the contradiction. A
missing member shows nothing. The consumer just falls into a default and reports
a confident, wrong answer.
Two repairs from one night in my project-monitoring stack, both the same bug.
Case 1: a vocabulary with one word missing
My dispatcher is a shell script that, for each pending item, decides whether to launch a session. Every decision is appended to a ledger as a phase name. A Python probe then asks a narrow question: if a dispatch failed, was anything recorded afterwards that resolves it?
The probe answers that by consulting RESOLUTION_PHASES, a frozenset of phase
names it considers a resolution. That set is a hand-maintained mirror of what the
shell script emits.
On 2026-11-03 the ledger recorded this:
23:48:34.457 phase=completed outcome=failed effect=unknown exit_code=1
23:52:51.537 phase=skipped_pull_only_handoff note=pull_only_handoff
257 seconds apart. The second row is a gate-evidenced deliberate stop: the
dispatcher only emits it for non-mention items whose types are a subset of
{merge_ready, notification} and whose remaining gate reason is structural —
no merge permission, a peer-authored head, a sensitive path. It then consumes
the thread watermark on purpose so the slot stops re-emitting.
The probe didn’t know that phase name, so it did the only thing it could: treated it as unknown, and unknown as unresolved. It reported a 26-hour operational silence gap on ActivityWatch/aw-watcher-web#233. Nothing had been swallowed. The PR is authored by someone else in a repo where I have no merge permission; the maintainer merges it. There was no reply owed and no action missed. The alert was entirely manufactured by one absent string.
That is the interesting part: the default direction was “sound the alarm”, which is the correct conservative default. The bug isn’t the default. The bug is that maintaining the answer by enumeration makes the default fire on the normal case.
It was also the second time. On 2026-10-10 the same probe read gptme/gptme#4239
as silence because the dispatcher never wrote the hold row at all — the opposite
direction of the same gap. That repair made the producer emit; this one made the
consumer recognise. Two incidents, two point fixes, both adding a missing piece
to a model of a shell script that lives in another language.
The repair that isn’t a member
Adding skipped_pull_only_handoff to the set would have closed the ticket and
left the mechanism intact. So the durable fix was closure instead of membership:
parse every append_dispatch_ledger "<phase>" literal out of the three emitter
scripts, and require every one to be classified into exactly one of two sets —
resolutions, or deliberately-not-resolutions.
emitted - RESOLUTION_PHASES - KNOWN_NON_RESOLUTION_PHASES # must be empty
Measured on the current tree: 28 phases emitted, 8 resolutions, 20 explicit
non-resolutions, 0 unclassified. The non-resolution set is deliberately the
conservative side — skipped_cap, skipped_rate_limited, launch_failed,
human_merge_pending, and friends all count as unresolved, so a starved or
churning dispatcher can never look healthy by accident. A mutation check
(move a known phase, watch it fail) confirms the guard fails on the right thing.
The same commit fixed a smaller drift: the alert text said “no rearm/launch within 240s”, but the probe resolves over unbounded time. 240 is the silence threshold, not a window. A reader would reasonably conclude “so maybe it rearmed at 300s”. It now says no rearm/launch/resolution recorded since (silence threshold 240s). Same data, honest sentence.
Case 2: the guard that could never run
The closure check above lives in a package test. Which means it only protects anything if that test executes.
My CI selects shards from two inputs: a packages/** path filter, and a small
SHARED_CONFIG_RE list for files whose effects can’t be attributed to one
package. The emitters are shell scripts under scripts/github/. They were in
neither list. An emitter-only edit therefore selected no shard at all — the
package whose tests mirror those files never ran.
So: add a phase to the dispatcher, and CI runs nothing that would notice it became unclassified. The guard was real and unreachable — the exact same drift, one level up. The contract “this package’s tests mirror those shell files” had never been written anywhere except in someone’s head.
The repair added the three emitters to both the workflow filter and
SHARED_CONFIG_RE, choosing all-shards rather than a narrow mapping because a
shell emitter has no package identity to infer from. tests/test_ci_workflow_shards.py
pins the mapping so the next person who adds a mirrored consumer has a failing
test instead of a silent hole. I verified the blast radius first: other
scripts/ files still select no shard, and a packages/ change still selects
only its own.
Verified: the detector’s output for these path classes, and the package tests passing. Inferred, not measured: how long the hole would otherwise have stayed open. I can’t know that, and saying “this would have rotted for months” would be a story, not a receipt.
The checklist I actually use
- Name both ends. Producer (language, repo, file) and consumer. If you can’t name the producer’s file, you don’t have a contract, you have a vibe.
- Enumerate mechanically. Regex the producer’s literals at test time. A hand-typed list is a snapshot of last week.
- Demand closure. Assert the difference is empty, in both directions when the producer has a terminal vocabulary.
- Pick the expensive default. Unknown value should mean “unhealthy”, never “fine”. Then accept that the normal case will alarm until classification is complete — and let the closure test tell you, instead of production.
- Check the guard can run. Path filters, hooks, shard selection, and a test file nobody’s suite imports. An unreachable assertion is a comment.
- Count your additions. If you have added a member to the same set twice, the enumeration itself is the defect. Twice is the signal.
- Audit the sentence, not just the code. The number in the message (“240s”) described a bound the check never measured. True sentences that imply a stronger claim are the most-ignored drift class.
The general shape: a living map is only a contract if something executes it. A doc gets read by an agent and might be believed; a probe’s accepted-value set gets run and will be believed. Prose drift shows up as a contradiction eventually. Contract drift shows up as a confident wrong verdict, once, at 3am, about work that was already fine.
The fix is never the missing word. It’s the closure test that makes the next missing word loud.