The system is deliberately small: one JSON store, one writer, one reader, three derived views. Everything interesting about it is a consequence of being shared. Two tools write the same file, a symlink exposes it under a second path, and a project-scoped store next door is intentionally not merged. The design answer to all three is the same: keep one authoritative path, make writes atomic, and never silently rewrite what someone else recorded.
ledger/ip-ledger.json, a dict keyed by IP. Rows carry identity, provenance, the latest verdict, and the full observation list. See modules/ledger.md.tools/ledger.py. Owns the field vocabulary, the observation semantics, the atomic write, and the drift report. See modules/tools.md.ip_ledger.py in the web/ip-ledger skill. Same field names on purpose; it is the reason the vocabulary is mirrored rather than invented.views/*.json, regenerated from the store on demand. See modules/views.md.~/.rime_ip_ledger.json, a symlink to the house copy.flowchart TD
ProjectA[["projects/outlook-creator"]] -.->|"observe"| CLI
ProjectB[["projects/grok_cauldron"]] -.->|"observe"| CLI
CLI["tools/ledger.py"]
Old["ip_ledger.py
(web/ip-ledger skill)"]
CLI --> Store[("ledger/ip-ledger.json")]
Old --> Store
Bridge{{"~/.rime_ip_ledger.json
(symlink bridge)"}} --> Store
CLI --> Views[("views/
fresh · reusable · by-project")]
Store --> Views
Aux[("ledger/firecrawl_ip_ledger.json
ledger/proxy_ledger_paragadis_verified.json")] -.->|"not merged"| Store
ledger.py observe with the project, the stage reached, the outcome and the reason. tools/ledger.pyload() reads the whole store; blank_row() supplies the field set for an address never seen before.history; never removed.last_seen.realpath first, then temp file, then os.replace, then mode 0600.by-project groups them.history exists because outcome cannot. A single scalar cannot hold "succeeded for grok-register, then captcha-failed for outlook-creator".os.replace() onto a symlink replaces the link, silently severing the bridge and leaving two ledgers that disagree. save() therefore calls os.path.realpath() first — this is not a style choice, it is the specific repair of an incident.drift_check() returns field names this tool does not know; it does not delete them. Dropping a field another tool recorded is worse than carrying an unknown one.Both fixed 22 Sep 2026. failed is now in the vocabulary, the three foreign stages (render_confirmed, turnstile_render, inject_test) are legal stages, and the freshness test fails closed: an outcome the tool does not recognise counts as spent instead of fresh, so the next vocabulary leak cannot spend a burned address twice. Measured after the fix: fresh → 0. Drift stays visible through vocabulary_drift().
~/.rime_ip_ledger.json) had silently inverted: it was the real file while the house path was the symlink pointing at it. Removing the "bridge" removed the only copy. Recovery used the 34-row pre-migration snapshot ledger-truth.json plus the three newest rows rebuilt from a measurements log. The history arrays were lost and re-created as one canonical entry per row — a row's scalars are its latest observation — marked source: pemulihan-22sep. Still missing: any earlier entries, the original source values, and the full text of one truncated reason field. The row whose verdict had changed was identified and restored the same night — 209.50.168.77, proved by the project's own test record (grok_cauldron/docs/HASIL-uji-turnstile.md lists it among the five exits recorded as render_confirmed / failed), then re-appended through the tool with `source: pemulihan-22sep`. The store's outcome distribution is now identical to the lost state's: captcha_failed 12 · blocked 8 · promising 7 · transient 4 · failed 4 · success 2.Lesson: resolve a bridge on both sides before touching it, and never rm a ledger path to relink it.