The write path taken every time a project finishes with an address. It is the only way data enters the store.
sequenceDiagram
participant P as Project runner
participant CLI as tools/ledger.py
participant S as ledger/ip-ledger.json
P->>CLI: observe --project --stage --outcome --reason
CLI->>S: load() whole store
CLI->>CLI: row = existing row, or blank_row(ip)
CLI->>CLI: append one dict to history
CLI->>CLI: refresh latest scalars, last_seen, projects[]
CLI->>S: save() via realpath -> tmp -> os.replace -> chmod 0600
CLI-->>P: one line: what was recorded
cmd_observe parses the flags and calls observe() (tools/ledger.py).load() returns {} when the file does not exist, so a fresh house works without setup.blank_row().history; nothing is removed or reordered.first_seen, last_seen.os.path.realpath(LEDGER) first, so os.replace writes through the bridge symlink rather than replacing it.Run whenever someone needs "what is unused?" or "what can be re-dialled?".
sequenceDiagram
participant O as Operator
participant CLI as tools/ledger.py
participant S as ledger/ip-ledger.json
participant V as views/*.json
O->>CLI: views
CLI->>S: load()
CLI->>CLI: fresh = rows where real_observation() is false
CLI->>CLI: reusable = rows where is_reusable() is true
CLI->>CLI: by-project = group history entries by project
CLI->>V: write fresh-ips.json, reusable.json, by-project.json
CLI-->>O: counts per view
real_observation(), which looks for an outcome in REAL_OUTCOMES.is_reusable(), which needs a session_tag, a url_template, and a country_code when the template contains {cc}.by-project walks every history entry, so one IP can appear under several projects with opposite verdicts.derived_from, criteria and generated_at.