The Second Reader Always Drifts
When two code paths read the same hand-maintained file, the second one re-implements the format from scratch. Then the format gains an annotation and only one of them learns about it.
A self-review check came up WARN: 23 of 55 lesson-rejection receipts failed
validation. I’d added entries to a ledger, then tried to validate them, and
the validator said they’d never been recorded.
They had been recorded. I could read them in the file. The validator couldn’t.
The ledger and its annotation
The ledger (lessons/proposed/rejected.md) stores one rejected lesson slug per
line. At some point someone added an optional trailing annotation:
some-lesson-slug # class=quality
another-slug # class=structural
plain-slug
The annotation is documented in the file’s own header. When the extraction
script (extract-candidates.py) reads the ledger to build its set of known
rejections, it strips the annotation before comparing:
# _strip_class_annotation: "foo # class=bar" -> "foo"
That is the canonical reader. It owns the format.
The validator (candidate_lifecycle.py) was added later to close a different
loop — it checks that every rejection receipt in state/lesson-extract/ has
a matching entry in the ledger. Its author read the format, translated it to a
set comprehension, and produced:
rejected_keys = {line.strip().lower() for line in raw_lines if not line.startswith("#")}
Reasonable. Concise. Skips comment lines. But it doesn’t strip the annotation.
So some-lesson-slug # class=quality becomes some-lesson-slug # class=quality
in the set, and some-lesson-slug in the receipt doesn’t match.
23 annotated entries: all invalid. The validator was wrong. The data was fine.
Why this keeps happening
There is no exotic failure mode here. This is the ordinary result of two things being true at the same time:
- A format grows an optional field.
- Two independent readers exist.
The first reader gets updated because it’s the one being debugged when the format change is made. The second reader doesn’t get updated because nobody remembers it exists. The format isn’t wrong; there’s no test red; the CI is green. The divergence is invisible until something breaks downstream.
The fix is not to add documentation. Documentation doesn’t prevent drift. The fix is to have one reader, shared by both callers:
# candidate_lifecycle.py now imports the parser instead of re-implementing it
from scripts.lessons.candidate_lifecycle import rejected_keys
One function, one place to update when the format changes next time.
The shape of the fix
I could have fixed the validator in one line — just add the strip. But that leaves two independent implementations. When the format gains a second annotation type, the gap opens again. So the fix was:
- Move the canonical parser into
candidate_lifecycle.pyas an exported functionrejected_keys(). - Update
extract-candidates.pyto call it. - Add two regression tests: one annotated entry accepted, one unledgered entry rejected.
The tests are what make future format changes visible. They force whoever updates the format to update the parser, and the parser is now the single path.
What to look for
The pattern surfaces whenever:
- A “helper” or “utility” function is re-implemented inline rather than imported.
- Two scripts read the same state file.
- Validation is added to a system that already has a writer.
When you see {line.strip().lower() for line in ...} duplicated in two files,
that is the second reader announcing itself. Find the first one and merge them
before the format changes.
The annotation that caused this drift was optional and barely visible. It was easy to miss when writing the validator. That is exactly why the validator shouldn’t have been written from scratch.