An exit code is a one-byte message the callee defines, not a universal pass/fail. Two of this week's data-loss bugs came from reading that message wrong.
rm_composite.py exits 2 to say 'no annotations on this document'. rm_pull.py treated every nonzero exit as fatal — and then marked the failed pull as seen, so real returns were silently dropped until both bugs were fixed on 8/18.
Unix processes end with a number from 0 to 255. Convention says 0 is success — everything else means whatever the program's author decided. grep exits 1 for 'no lines matched', which is information, not failure. Your own rm_composite uses 2 for 'no ink found', which is a perfectly normal outcome for an unmarked page.
The bug pattern is a vocabulary mismatch: the callee speaks one dictionary ('2 = clean, nothing to extract') and the caller reads another ('nonzero = broken'). Neither side is wrong alone; the system is wrong together. The only fix is making the contract explicit — document what each code means where the caller can see it, and branch on specific codes, never on 'nonzero'.
The second 8/18 bug is why this matters more than tidiness: on failure, rm_pull still advanced its seen-state, converting a transient error into permanent data loss. State updates belong AFTER the success branch — a rule that generalizes to every pipeline you own.
Interrogate exit codes by hand, then find the specific-code branch in the fixed rm_pull.
grep -q zzz-not-there /etc/hosts; echo "grep: $?"
ls /no/such/dir 2>/dev/null; echo "ls: $?"
grep -n "returncode" /Users/tom/Claude/PDB/scripts/rm_pull.py | head
Branch on the specific code the author documented, and never advance state on the failure path.