tamper-signal 1.7.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ every step is verifiable.
7
7
 
8
8
  What you are installing: signed receipts for a data pipeline. Every pipeline
9
9
  stage signs a receipt (a hash of its input, its code, and its output, plus
10
- human-legible control totals). Receipts link into a chain; `receipts verify`
10
+ human-legible control totals). Receipts link into a chain; `tamper-signal verify`
11
11
  and an in-browser signal re-verify the whole chain and report a traffic light.
12
12
  Green: intact. Yellow: verifies, with caveats a human should look at. Red:
13
13
  broken, at an exact link, with the totals delta.
@@ -46,21 +46,22 @@ pip install tamper-signal
46
46
  (Installing from source also works:
47
47
  `pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
48
48
 
49
- This provides the `receipts` CLI and the `tamper_signal` Python package.
50
- Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
51
- the JS equivalents; the two stacks produce interchangeable chains.
49
+ This provides the `tamper-signal` CLI and the `tamper_signal` Python package.
50
+ Verify: `tamper-signal --help` exits 0. (`receipts` is a deprecated alias kept
51
+ working through the 2.x line — prefer `tamper-signal`.) JavaScript-only project?
52
+ Use step 1b; both stacks share the `tamper-signal` command and interchangeable chains.
52
53
 
53
- If `receipts` comes up "command not found", pip installed the script into a bin
54
- directory that is not on PATH. This is common on the python.org framework
54
+ If `tamper-signal` comes up "command not found", pip installed the script into a
55
+ bin directory that is not on PATH. This is common on the python.org framework
55
56
  Python (the default macOS download), where pip prints a warning like *"The
56
- script receipts is installed in '/Library/Frameworks/.../bin' which is not on
57
- PATH."* Two fixes, either works:
57
+ script tamper-signal is installed in '/Library/Frameworks/.../bin' which is not
58
+ on PATH."* Two fixes, either works:
58
59
 
59
60
  - Run it through the interpreter that installed it (no PATH change needed):
60
- `python3 -m tamper_signal --help`. Every `receipts <args>` works as
61
+ `python3 -m tamper_signal --help`. Every `tamper-signal <args>` works as
61
62
  `python3 -m tamper_signal <args>`.
62
63
  - Or link the script onto PATH once:
63
- `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/receipts" /usr/local/bin/receipts`.
64
+ `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
64
65
 
65
66
  ## 1b. Install (JavaScript pipelines)
66
67
 
@@ -162,22 +163,29 @@ tamper-signal`); the resulting chain verifies interchangeably on the JS side.
162
163
 
163
164
  ### Command parity (and what is Python-only)
164
165
 
165
- Most `receipts` subcommands have a JS equivalent; a few are Python-only. On a
166
- JS-only project, use the equivalent and skip the rest of this runbook's Python
167
- commands:
166
+ As of 2.0 the command is `tamper-signal` on both stacks (on Python, `receipts`
167
+ is a deprecated alias that still works). Every subcommand runs on both installs
168
+ **except** these, which are Python-only for now; on a JS-only project use the
169
+ noted equivalent and skip them:
168
170
 
169
- | `receipts` (Python) | JavaScript |
171
+ | Python-only subcommand | On Node |
170
172
  | --- | --- |
171
- | `receipts init` | `tamper-signal keygen`; `receipts/` is created on first `ingest` (no scaffold command) |
172
- | `receipts ingest` | `tamper-signal ingest` / `ingestFile()` |
173
- | `receipts verify` | `tamper-signal verify` / `verifyChain()` |
174
- | `receipts diff` | `tamper-signal diff` (same args and JSON shape) |
175
- | `receipts log` | `tamper-signal log` (same args and JSON shape) |
176
- | `receipts export` | `tamper-signal export` / `canonicalDocument()` |
177
- | `receipts assets` | `tamper-signal assets` (copy the browser bundle into a project) |
178
- | `receipts serve` | your bundler's static server, or `tamper-signal/express` |
179
- | `receipts doctor` | `tamper-signal verify` (exit 0 = healthy); confirm the key is gitignored yourself |
180
- | `receipts anchor` | Python-only today (transparency-log anchoring) |
173
+ | `tamper-signal doctor` | use `tamper-signal verify` (exit 0 = healthy); confirm the key is gitignored yourself |
174
+ | `tamper-signal anchor` | Python-only today (transparency-log anchoring; Node support planned for 2.1) |
175
+ | `tamper-signal custody` | Python-only today (the CLI-local custody view over history/archive) |
176
+ | `tamper-signal watch` | Python-only today (the live-source watcher; see §5c) — its signed manifests and snapshots stay fully readable/verifiable by the JS stack |
177
+ | `tamper-signal review` | Python-only today (human sign-off for withheld watch changes) |
178
+
179
+ Shared subcommands (`ingest`, `verify`, `diff`, `log`, `export`, `assets`,
180
+ `annotate`, `timeline`, `keygen`, `serve`) behave identically on both, with the
181
+ Node programmatic API alongside (`ingestFile()`, `verifyChain()`,
182
+ `canonicalDocument()`). `serve` on Node is your bundler's static server or
183
+ `tamper-signal/express`.
184
+
185
+ If you run a Node host and want a live source kept under custody, the watcher
186
+ itself runs as a Python sidecar process (`pip install "tamper-signal[watch]"`)
187
+ writing into the same `receipts/` directory your Node app serves — the chains
188
+ stay interchangeable, only the `watch`/`review` *commands* are Python-only.
181
189
 
182
190
  CI signing works here too: `TAMPER_SIGNAL_KEY` (PEM contents of the private
183
191
  key) wins over any key path, same semantics as the Python side (step 5).
@@ -186,18 +194,18 @@ key) wins over any key path, same semantics as the Python side (step 5).
186
194
  ## 2. Scaffold the project (once)
187
195
 
188
196
  ```bash
189
- receipts init
197
+ tamper-signal init
190
198
  ```
191
199
 
192
200
  Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
193
201
  `keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
194
202
  .gitignore, creates `receipts/`, and prints exactly what it did. The pieces
195
- are also available separately (`receipts keygen --out keys/`).
203
+ are also available separately (`tamper-signal keygen --out keys/`).
196
204
 
197
205
  ## 3. Start the chain at the source export
198
206
 
199
207
  ```bash
200
- receipts ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
208
+ tamper-signal ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
201
209
  --key keys/signing.key --out receipts/
202
210
  ```
203
211
 
@@ -236,7 +244,7 @@ the user has a source export and a hand-built artifact (say a generated
236
244
  wrap. That is fine. Ingest the source and stop:
237
245
 
238
246
  ```bash
239
- receipts ingest path/to/export.csv --origin "TikTok export, May 2026" \
247
+ tamper-signal ingest path/to/export.csv --origin "TikTok export, May 2026" \
240
248
  --key keys/signing.key --out receipts/
241
249
  ```
242
250
 
@@ -259,7 +267,7 @@ verified table. Until then, a source-only chain is the honest amount of proof.
259
267
  ## 5. Verify from the command line
260
268
 
261
269
  ```bash
262
- receipts verify receipts/chain.json --pub keys/signing.pub --data path/to/dashboard_data.xlsx
270
+ tamper-signal verify receipts/chain.json --pub keys/signing.pub --data path/to/dashboard_data.xlsx
263
271
  ```
264
272
 
265
273
  Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
@@ -268,7 +276,7 @@ receipt. `--warn-drift` additionally flags any control-totals movement across
268
276
  links (only for pipelines expected to preserve totals).
269
277
 
270
278
  Key rotation: `--pub` repeats. Old chains stay green while new receipts sign
271
- under a new key: `receipts verify chain.json --pub new.pub --pub old.pub`. A
279
+ under a new key: `tamper-signal verify chain.json --pub new.pub --pub old.pub`. A
272
280
  signature valid under any trusted key is trusted; the browser surfaces accept
273
281
  a list the same way (the `<tamper-signal>` element takes a space-separated
274
282
  `pub-key` list).
@@ -279,7 +287,7 @@ on disk. The env var wins over any `--key` path while set. The Node CLI
279
287
  (`tamper-signal ingest`) honors the same env var with the same precedence.
280
288
 
281
289
  Add `--json` to get a structured verdict instead of the text report (both
282
- CLIs: `receipts verify --json` and `tamper-signal verify --json` emit the
290
+ CLIs: `tamper-signal verify --json` and `tamper-signal verify --json` emit the
283
291
  same payload). Parse this rather than scraping text:
284
292
 
285
293
  ```json
@@ -381,7 +389,7 @@ jobs:
381
389
  - name: Verify the receipt chain
382
390
  run: |
383
391
  set +e
384
- receipts verify receipts/chain.json --json | tee verdict.json
392
+ tamper-signal verify receipts/chain.json --json | tee verdict.json
385
393
  code=$?
386
394
  if [ "$code" = "2" ]; then
387
395
  echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
@@ -401,7 +409,7 @@ run. This is opt-in and starts at ingest, where the producer declares how much
401
409
  movement is normal:
402
410
 
403
411
  ```bash
404
- receipts ingest export.csv --origin "nightly" --band 5% --settle 72h \
412
+ tamper-signal ingest export.csv --origin "nightly" --band 5% --settle 72h \
405
413
  --bucket-column day --key keys/signing.key --out receipts/
406
414
  ```
407
415
 
@@ -424,7 +432,7 @@ Run history is automatic. Every non-red CLI `verify` archives a compact run
424
432
  snapshot under `receipts/history/` (signed when a private key is available).
425
433
  Snapshots are what give the chain a memory; the cross-run judgment reads them
426
434
  on the next verify and folds its findings in as yellow caveats (never red).
427
- History is CLI-local: `receipts serve` 404s anything under `history/`, because
435
+ History is CLI-local: `tamper-signal serve` 404s anything under `history/`, because
428
436
  snapshots carry per-day totals and run cadence that the published receipts do
429
437
  not. History is weaker evidence than the chain itself: snapshots sit outside
430
438
  `receipt_hashes` and outside anchoring.
@@ -432,8 +440,8 @@ not. History is weaker evidence than the chain itself: snapshots sit outside
432
440
  Two read-only commands work the archived history, both exit 0:
433
441
 
434
442
  ```bash
435
- receipts diff # current chain vs the latest differing snapshot
436
- receipts log --granularity week # per-metric trend across runs, oldest first
443
+ tamper-signal diff # current chain vs the latest differing snapshot
444
+ tamper-signal log --granularity week # per-metric trend across runs, oldest first
437
445
  ```
438
446
 
439
447
  `diff` reports per-stage code-hash changes and a structured totals delta
@@ -452,11 +460,11 @@ transparency log:
452
460
 
453
461
  ```bash
454
462
  pip install "tamper-signal[anchor]"
455
- receipts anchor # browser login locally; automatic in GitHub Actions
456
- receipts verify receipts/chain.json --anchor
463
+ tamper-signal anchor # browser login locally; automatic in GitHub Actions
464
+ tamper-signal verify receipts/chain.json --anchor
457
465
  ```
458
466
 
459
- Agent note: run `receipts anchor` in CI (GitHub Actions and similar), where
467
+ Agent note: run `tamper-signal anchor` in CI (GitHub Actions and similar), where
460
468
  an ambient OIDC credential makes it non-interactive. Outside CI it opens a
461
469
  browser login and blocks until a human completes it; do not invoke it from
462
470
  an unattended session.
@@ -471,7 +479,7 @@ pipeline re-runs.
471
479
  `anchor.json` (next to chain.json) records the Sigstore bundle plus the
472
480
  identity and issuer used; `verify --anchor` enforces that identity, reports
473
481
  the logged time on success, exits 2 when no anchor exists, and exits 1 when
474
- the chain changed after anchoring. `receipts anchor --json` emits the anchor
482
+ the chain changed after anchoring. `tamper-signal anchor --json` emits the anchor
475
483
  record (identity, issuer, integrated time) as JSON for CI logs. An anchor
476
484
  made with `--staging` is rejected at verify time unless you pass
477
485
  `--anchor-staging`, so the anchor file cannot pick a weaker trust root. To
@@ -480,7 +488,7 @@ pin whose anchor is acceptable instead of trusting the recorded one, pass
480
488
  like:
481
489
 
482
490
  ```bash
483
- receipts verify receipts/chain.json --anchor \
491
+ tamper-signal verify receipts/chain.json --anchor \
484
492
  --anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
485
493
  --anchor-issuer "https://token.actions.githubusercontent.com"
486
494
  ```
@@ -489,6 +497,78 @@ Re-anchor after every pipeline run that changes the chain. Honest scope: an
489
497
  anchor proves this exact chain existed at the logged time under the recorded
490
498
  identity, nothing more.
491
499
 
500
+ ## 5c. Live-source watcher (optional, for feeds you do not re-export by hand)
501
+
502
+ When the source is a live HTTP/JSON-API or RSS feed rather than a file you
503
+ re-export, the watcher keeps it on the same signed chain: it polls, judges the
504
+ new data against the declared band/settle (§5a), and **auto-appends only a
505
+ clean change**. A retroactive change to an already-settled period — or a slow
506
+ drift that cumulatively breaches the band — is **not** signed unattended; it is
507
+ withheld as a signed *pending event* and paused for a human reason.
508
+
509
+ ```bash
510
+ pip install "tamper-signal[watch]"
511
+ # Seed the chain once from any first sample, declaring the tolerance (§5a):
512
+ tamper-signal ingest first.csv --origin "https://feed.example/rates" \
513
+ --band 5% --settle 72h --bucket-column day --key keys/watch.key --out receipts/
514
+
515
+ # One tick (poll once, judge, append-if-clean, else withhold). Config is a
516
+ # small JSON file: {url, format: json|rss, source_id, optional field_map,
517
+ # band/settle/bucket_column, per_tick_cap}.
518
+ tamper-signal watch --config feed.json --key keys/watch.key --out receipts/
519
+ ```
520
+
521
+ - **`source_id`** is a STABLE identity for the feed (a feed has no filename).
522
+ Keep it constant across ticks, or cross-run judgment cannot match history and
523
+ the watcher refuses rather than appending unjudged.
524
+ - Change detection is a **full-content fingerprint**, never the server's
525
+ `ETag`/`304` — a compromised origin cannot replay an old validator to hide a
526
+ mutation.
527
+ - The fetch is **SSRF-hardened**: only public hosts (an affirmative `is_global`
528
+ check), redirects off, TLS verified, bounded by bytes and wall-clock. RSS is
529
+ parsed through `defusedxml` (billion-laughs / XXE rejected).
530
+ - The watcher key must be the chain's trusted signer (else it fails closed).
531
+ Use a **dedicated** key, distinct from any interactive human key, for
532
+ isolation and revocability.
533
+
534
+ Withheld changes are reviewed explicitly — each acceptance signs its own reason:
535
+
536
+ ```bash
537
+ tamper-signal review # list pending changes awaiting sign-off
538
+ tamper-signal review accept <hash> --reason "confirmed by finance" --author dana
539
+ tamper-signal review reject <hash> # discard; the chain is untouched
540
+ ```
541
+
542
+ Accepting commits the exact reviewed candidate and signs a reason linked to it;
543
+ if later ticks advanced the chain in the meantime, acceptance re-surfaces for
544
+ review instead of overwriting newer data. The console shows pending changes in
545
+ a distinct "AWAITING REVIEW" section that never affects the verdict.
546
+
547
+ **Deployment — a local file-writer, not a server.** The recommended shape is
548
+ the **stateless tick under a systemd timer / cron**, so the signing key is not
549
+ resident between runs. A `--daemon --interval <seconds>` loop exists for hosts
550
+ without a scheduler; it only polls and writes files. Harden the unit:
551
+
552
+ ```ini
553
+ # /etc/systemd/system/tamper-watch.service (paired with a .timer)
554
+ [Service]
555
+ Type=oneshot
556
+ User=tamper-watch # dedicated, unprivileged user
557
+ ExecStart=/usr/bin/tamper-signal watch --config /etc/tamper/feed.json \
558
+ --key %d/watch.key --out /var/lib/tamper/receipts
559
+ LoadCredential=watch.key:/etc/tamper/watch.key # key material via $CREDENTIALS_DIRECTORY, not the env
560
+ NoNewPrivileges=true
561
+ ProtectSystem=strict
562
+ ProtectHome=true
563
+ ReadWritePaths=/var/lib/tamper/receipts
564
+ PrivateTmp=true
565
+ ```
566
+
567
+ Deliver the key with `LoadCredential=` (it lands under `%d`/`$CREDENTIALS_DIRECTORY`,
568
+ mode 0400, never in the process environment) — do **not** put the key material
569
+ in `EnvironmentFile`, which would expose it via `/proc/<pid>/environ`. Keep the
570
+ key file `0600`; the watcher fails closed if it is group/world-readable.
571
+
492
572
  ## 6. Add the signal to the host UI
493
573
 
494
574
  With a bundler, import straight from the npm package
@@ -497,7 +577,7 @@ the browser assets into the host app. The CLI does this for you (no hunting
497
577
  through `site-packages` or `node_modules`):
498
578
 
499
579
  ```bash
500
- receipts assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
580
+ tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
501
581
  ```
502
582
 
503
583
  That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
@@ -522,8 +602,8 @@ you vendored into `badge/` and serve it at `/badge/`:
522
602
  table all `fetch()` the chain (and table.json), which the browser blocks on a
523
603
  `file://` page, so opening `index.html` directly leaves them silently
524
604
  unverified. Serve the page over HTTP: any static server works, and
525
- `receipts serve` is the one-liner for local dev. There is no `file://` mode; an
526
- offline recipient verifies with the CLI on a bundle (`receipts export --bundle`,
605
+ `tamper-signal serve` is the one-liner for local dev. There is no `file://` mode; an
606
+ offline recipient verifies with the CLI on a bundle (`tamper-signal export --bundle`,
527
607
  step 8) instead.
528
608
 
529
609
  React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
@@ -588,7 +668,7 @@ over; it is the page to open when the light is anything but green.
588
668
  Manual fallback when no helper fits: serve the directory statically (Flask
589
669
  `static_folder="receipts"`, FastAPI `StaticFiles`, Express
590
670
  `express.static("receipts")`), or copy `receipts/` into the public dir of a
591
- static site at build time. For local development, `receipts serve` serves
671
+ static site at build time. For local development, `tamper-signal serve` serves
592
672
  the directory on localhost with CORS open and caching off.
593
673
 
594
674
  Placement: the right end of the host header, after the host's own controls.
@@ -633,7 +713,7 @@ verified table, not just charts. Two steps:
633
713
 
634
714
  ```bash
635
715
  # Python
636
- receipts export receipts/chain.json --data path/to/dashboard_data.xlsx
716
+ tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
637
717
  # JavaScript
638
718
  tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
639
719
  ```
@@ -648,7 +728,7 @@ verified table, not just charts. Two steps:
648
728
  `canonicalDocument(finalRecords)` (see step 1b).
649
729
 
650
730
  2. Mount the table (vendor `badge/table.js` beside badge.js with
651
- `receipts assets`, or import `tamper-signal/table`):
731
+ `tamper-signal assets`, or import `tamper-signal/table`):
652
732
 
653
733
  ```html
654
734
  <script type="module">
@@ -667,8 +747,13 @@ verified table, not just charts. Two steps:
667
747
  ```
668
748
 
669
749
  Attributes: `chain` (required), `table` (table.json URL; defaults to
670
- table.json beside the chain), and `max-rows` (rows before the "show all"
671
- footer, default 500).
750
+ table.json beside the chain), `max-rows` (rows before the "show all"
751
+ footer, default 500), and `strict` (present = the table emits its verdict
752
+ with `strict: true` so the host can gate other views on a broken chain). The
753
+ table never blocks UI itself; after each verification it fires a bubbling
754
+ `tamper-signal:state` event (and an `onState` callback) carrying
755
+ `{ state, attested, strict }`. Default (no `strict`) stays always-on and
756
+ always-honest. Recommended host gate: `strict && (state === "red" || !attested)`.
672
757
 
673
758
  The component re-hashes the served document in the viewer's browser and
674
759
  compares it against the final receipt, so VERIFIED means the rows on screen
@@ -679,7 +764,7 @@ attested data" when table.json is stale or edited. Design reference:
679
764
 
680
765
  ## 9. Verify your work before reporting done
681
766
 
682
- On a Python project, run `receipts doctor` first: it checks the Python version,
767
+ On a Python project, run `tamper-signal doctor` first: it checks the Python version,
683
768
  that the private key exists and is not tracked by git, that .gitignore covers
684
769
  it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
685
770
  to also confirm the receipts directory is reachable over HTTP. Every failure
@@ -688,7 +773,7 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
688
773
  0 when the chain is healthy, and you should confirm the private key is
689
774
  gitignored yourself.) Then confirm the user-visible surfaces:
690
775
 
691
- 1. `receipts verify receipts/chain.json --pub keys/signing.pub` exits 0.
776
+ 1. `tamper-signal verify receipts/chain.json --pub keys/signing.pub` exits 0.
692
777
  2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
693
778
  the per-stage popover).
694
779
  3. Negative test without touching the user's real chain: this repo commits
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # The light is green, the data is clean.
4
4
 
5
- [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.7.2)](https://socket.dev/npm/package/tamper-signal/overview/1.7.1) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.7.2)](https://socket.dev/pypi/package/tamper-signal/overview/1.7.1) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+ [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/2.0.0)](https://socket.dev/npm/package/tamper-signal/overview/2.0.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/2.0.0)](https://socket.dev/pypi/package/tamper-signal/overview/2.0.0) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
7
  Your social team exports a month of TikTok performance data. Someone vibe-codes a dashboard on top of it with an AI assistant in an afternoon. It looks great. Then a transform silently drops 22 rows, or the model hallucinates an aggregation, and the numbers in front of your boss are wrong. Nothing in that workflow catches it. This is the missing verification layer: every stage of the pipeline signs a receipt for what went in and what came out, and one command (or a badge on the dashboard itself) tells you whether the chain is intact, or exactly where it broke and by how much.
8
8
 
@@ -28,7 +28,7 @@ The badge and the verifier reduce the whole chain to one state:
28
28
 
29
29
  *The inline status light: a small dark instrument in your dashboard's header. When the chain breaks, it reaches into the page and flags the exact metric that no longer descends from the source.*
30
30
 
31
- Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The animations in this README are renders of the design mockups in `designs/`; the interfaces they depict have since shipped (`badge/light.js`, `badge/table.js`, `badge/console.js`). The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
31
+ Honest status: all three verdicts are implemented in `tamper-signal verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The animations in this README are renders of the design mockups in `designs/`; the interfaces they depict have since shipped (`badge/light.js`, `badge/table.js`, `badge/console.js`). The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
32
32
 
33
33
  ## 60-second quickstart
34
34
 
@@ -37,29 +37,31 @@ Python 3.11+. Open source (MIT).
37
37
  ```bash
38
38
  pip install tamper-signal
39
39
  git clone https://github.com/welovejeff/tamper-evident-verification && cd tamper-evident-verification
40
- receipts demo
40
+ tamper-signal demo
41
41
  ```
42
42
 
43
- `receipts demo` runs the whole story end to end: generates a deliberately messy sample export, ingests it, runs two AI-written-style transforms, verifies the chain (PASS), then tampers with one spend value and verifies again (FAIL, pinpointing the broken link and the totals delta). It finishes by serving the badge at `http://localhost:8000/badge/badge.html` so you can see green, yellow, and red side by side.
43
+ `tamper-signal demo` runs the whole story end to end: generates a deliberately messy sample export, ingests it, runs two AI-written-style transforms, verifies the chain (PASS), then tampers with one spend value and verifies again (FAIL, pinpointing the broken link and the totals delta). It finishes by serving the badge at `http://localhost:8000/badge/badge.html` so you can see green, yellow, and red side by side.
44
44
 
45
- > **`receipts: command not found`?** pip installed the script into a bin directory that is not on PATH (common on the python.org framework Python, the default macOS download). Either run it through the same interpreter, `python3 -m tamper_signal verify ...` (works as a drop-in for every `receipts ...` command), or link it onto PATH once: `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/receipts" /usr/local/bin/receipts`.
45
+ > **`tamper-signal: command not found`?** pip installed the script into a bin directory that is not on PATH (common on the python.org framework Python, the default macOS download). Either run it through the same interpreter, `python3 -m tamper_signal verify ...` (works as a drop-in for every `tamper-signal ...` command), or link it onto PATH once: `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
46
46
 
47
47
  ## CLI
48
48
 
49
49
  ```bash
50
- receipts init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
51
- receipts ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
52
- receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
53
- receipts diff # compare two runs: code-hash changes and totals deltas (read-only)
54
- receipts log # archived run history as a per-metric trend across runs (read-only)
55
- receipts doctor # integration self-check with actionable fixes
56
- receipts serve # serve receipts/ on localhost with CORS (dev only)
57
- receipts assets --out badge/ # vendor the browser surfaces (light/badge/element/table/console.js) into a project
50
+ tamper-signal init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
51
+ tamper-signal ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
52
+ tamper-signal verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
53
+ tamper-signal diff # compare two runs: code-hash changes and totals deltas (read-only)
54
+ tamper-signal log # archived run history as a per-metric trend across runs (read-only)
55
+ tamper-signal doctor # integration self-check with actionable fixes
56
+ tamper-signal serve # serve receipts/ on localhost with CORS (dev only)
57
+ tamper-signal assets --out badge/ # vendor the browser surfaces (light/badge/element/table/console.js) into a project
58
+ tamper-signal annotate --reason "backfill approved" --author dana # sign a reason onto a receipt (chain of custody)
59
+ tamper-signal watch --config feed.json --out receipts/ # poll a live feed onto the chain (needs [watch]; see below)
58
60
  ```
59
61
 
60
62
  `--pub` repeats for key rotation (any trusted key verifies), and `TAMPER_SIGNAL_KEY` can carry the PEM private key in CI so no key file touches disk. `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals. `--json` emits a structured verdict (schema in `AGENTS.md`) for CI and coding agents.
61
63
 
62
- For a recurring refresh of the same report, declare a tolerance at ingest with `--band` (default 5%) and `--settle` (default 72h), optionally keyed off a date column with `--bucket-column`. The declaration is signed into the source manifest. Every non-red `verify` then archives a run snapshot under `receipts/history/`, and the next verify judges this run against that memory: recent buckets may drift within the band, settled buckets (older than the window) may not, and any breach is a yellow caveat. `receipts diff` and `receipts log` read that history (both read-only, exit 0) to show what moved between runs and the per-metric trend across them. History is CLI-local and weaker evidence than the chain: it stays out of `receipt_hashes` and anchoring, and `serve` never exposes it.
64
+ For a recurring refresh of the same report, declare a tolerance at ingest with `--band` (default 5%) and `--settle` (default 72h), optionally keyed off a date column with `--bucket-column`. The declaration is signed into the source manifest. Every non-red `verify` then archives a run snapshot under `receipts/history/`, and the next verify judges this run against that memory: recent buckets may drift within the band, settled buckets (older than the window) may not, and any breach is a yellow caveat. `tamper-signal diff` and `tamper-signal log` read that history (both read-only, exit 0) to show what moved between runs and the per-metric trend across them. History is CLI-local and weaker evidence than the chain: it stays out of `receipt_hashes` and anchoring, and `serve` never exposes it.
63
65
 
64
66
  Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
65
67
 
@@ -104,7 +106,7 @@ TikTok/Sprinklr export.xlsx
104
106
  [transform_agg] ──> 002_transform_aggregate.json
105
107
  |
106
108
  v
107
- dashboard data <─── receipts verify: walk every link, check every signature
109
+ dashboard data <─── tamper-signal verify: walk every link, check every signature
108
110
  ```
109
111
 
110
112
  Each receipt contains the SHA-256 of its input, the SHA-256 of the transform's source code, the SHA-256 of its output, and human-legible control totals (row counts, numeric sums, date ranges, null counts). Receipts link because each stage's input hash must equal the prior stage's output hash. Everything is signed with Ed25519; `chain.json` is just an ordered list of receipt files plus the public key.
@@ -145,7 +147,7 @@ React, with a bundler: `import { TamperSignal } from "tamper-signal/react"` and
145
147
 
146
148
  The pill expands to a popover: the per-stage table when green, the caveat list when yellow, the broken link with its totals delta when red. In the red state the light also reaches into the page: give any metric element a `data-receipt-column="spend_usd"` attribute, and if that column moved at the broken link the element gets outlined and tagged `tamper signal: unverified value`. Mark up your metrics once and the light flags the exact number that no longer descends from the source.
147
149
 
148
- Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `surface: "dark"` so the pill inverts to stay the one foreign object on a dark host (`surface` describes your page; `invert: true` is a shortcut for it, and the deprecated `theme: "light"` is the same thing). `receipts demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
150
+ Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `surface: "dark"` so the pill inverts to stay the one foreign object on a dark host (`surface` describes your page; `invert: true` is a shortcut for it, and the deprecated `theme: "light"` is the same thing). `tamper-signal demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
149
151
 
150
152
  One-call framework helpers serve the receipts directory and the browser files together and hand back the mounting snippet: `tamper_signal.flask_ext.attach(app)`, `tamper_signal.fastapi_ext.attach(app)`, and `tamperSignal(app)` from `tamper-signal/express`. Streamlit apps get a server-side-verified pill and table caption via `tamper_signal.streamlit_ext` (labeled as the weaker check it is).
151
153
 
@@ -153,7 +155,7 @@ One-call framework helpers serve the receipts directory and the browser files to
153
155
 
154
156
  We think any dashboard built on verified data should let you see the data. Not a tooltip, not an export-on-request: a Data tab, right next to the charts, showing the raw verified table the pretty numbers came from. If the chain is intact and the light is green, there is no reason to hide the rows, and if you find yourself wanting to hide them, that's worth sitting with. A chart asks you to believe; a table lets you check. Green light, open table: that's the whole standard.
155
157
 
156
- It ships: `receipts export` writes the canonical table document next to the chain (refusing data that does not match the final receipt), and `mountReceiptTable(el, "/receipts/chain.json")` from `badge/table.js` (npm: `tamper-signal/table`) renders it after re-hashing it in the viewer's browser against the final receipt. VERIFIED means the rows on screen are byte-for-byte the attested data; a stale or edited table.json renders dimmed under a "not the attested data" strip, and a broken chain flags the columns that moved at the break. Live demo: `badge/table.html`.
158
+ It ships: `tamper-signal export` writes the canonical table document next to the chain (refusing data that does not match the final receipt), and `mountReceiptTable(el, "/receipts/chain.json")` from `badge/table.js` (npm: `tamper-signal/table`) renders it after re-hashing it in the viewer's browser against the final receipt. VERIFIED means the rows on screen are byte-for-byte the attested data; a stale or edited table.json renders dimmed under a "not the attested data" strip, and a broken chain flags the columns that moved at the break. Live demo: `badge/table.html`.
157
159
 
158
160
  ![The Data tab: the dashboard flips to a dark raw-table view where a broken chain is localized to the views column](docs/media/data-tab.gif)
159
161
 
@@ -161,21 +163,36 @@ It ships: `receipts export` writes the canonical table document next to the chai
161
163
 
162
164
  ## Take your data with you
163
165
 
164
- Verified data should be portable, proof and all. `receipts export --bundle` (or `tamper-signal export --bundle`) writes a verified bundle: a zip of the data file plus `chain.json` and its receipts, kept byte for byte, so whoever you send it to runs `receipts verify chain.json` and gets the same light, offline. In the browser, the Data tab's "Take your data" control exports the attested data client-side as that bundle or as a bare rows-only file (csv/tsv/json/ndjson; xlsx routes through the Python CLI). Because the semantic hash is format-agnostic, a CSV you export here re-verifies as JSON and the light stays green; numeric-looking text canonicalizes to its number, so leading zeros and trailing decimals do not survive the round trip.
166
+ Verified data should be portable, proof and all. `tamper-signal export --bundle` (or `tamper-signal export --bundle`) writes a verified bundle: a zip of the data file plus `chain.json` and its receipts, kept byte for byte, so whoever you send it to runs `tamper-signal verify chain.json` and gets the same light, offline. In the browser, the Data tab's "Take your data" control exports the attested data client-side as that bundle or as a bare rows-only file (csv/tsv/json/ndjson; xlsx routes through the Python CLI). Because the semantic hash is format-agnostic, a CSV you export here re-verifies as JSON and the light stays green; numeric-looking text canonicalizes to its number, so leading zeros and trailing decimals do not survive the round trip.
165
167
 
166
- To bring an updated file back, `receipts ingest --as replace|period`. `replace` (the default) re-signs a fresh chain and archives the prior one under `receipts/archive/`. `period` continues the chain's run history as the next period, judged against prior runs through the prior run's signed tolerance band; it continues only under a trusted signer (`--pub` to trust a key other than the chain's) and refuses an untrusted one rather than appending silently. Re-attestation is never silent: the importer's identity is recorded, and an unrecognized signer stays yellow.
168
+ To bring an updated file back, `tamper-signal ingest --as replace|period`. `replace` (the default) re-signs a fresh chain and archives the prior one under `receipts/archive/`. `period` continues the chain's run history as the next period, judged against prior runs through the prior run's signed tolerance band; it continues only under a trusted signer (`--pub` to trust a key other than the chain's) and refuses an untrusted one rather than appending silently. Re-attestation is never silent: the importer's identity is recorded, and an unrecognized signer stays yellow.
167
169
 
168
170
  ## The console
169
171
 
170
- The light answers "is it fine?"; the console answers "where, exactly, and by how much?" `mountReceiptConsole(el, "/receipts/chain.json")` from `badge/console.js` (npm: `tamper-signal/console`) renders the chain as an inspectable pipeline: links carry the hash they proved, a break severs the link with the break card pinned at it, coverage gaps appear as ghost nodes at their position, and the event log mirrors `receipts verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
172
+ The light answers "is it fine?"; the console answers "where, exactly, and by how much?" `mountReceiptConsole(el, "/receipts/chain.json")` from `badge/console.js` (npm: `tamper-signal/console`) renders the chain as an inspectable pipeline: links carry the hash they proved, a break severs the link with the break card pinned at it, coverage gaps appear as ghost nodes at their position, and the event log mirrors `tamper-signal verify` line for line. Every attach helper also serves it ready-made at `/tamper-signal/console`. Live demo: `badge/console.html`.
171
173
 
172
174
  ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
173
175
 
174
176
  *The verification console: calm when green, surgical when red.*
175
177
 
178
+ Below the pipeline the console renders the **chain of custody**: the imports and changes, each signed reason attached to the receipt it explains (`tamper-signal annotate`), and any changes **awaiting human review**. It is an additive layer over the published `timeline.json` — it never feeds the verdict above.
179
+
180
+ ## Live-source watcher (optional)
181
+
182
+ When the source is a live feed rather than a file you re-export by hand, the watcher keeps it on the same signed chain. `tamper-signal watch` (behind `pip install "tamper-signal[watch]"`) polls an HTTP/JSON-API or RSS/Atom endpoint, judges the new data against the declared band/settle, and **auto-appends only a clean change**. A retroactive edit to an already-settled period — or a slow drift that cumulatively breaches the band — is never signed unattended: it is withheld as a signed *pending event* and paused for a human.
183
+
184
+ ```bash
185
+ pip install "tamper-signal[watch]"
186
+ tamper-signal watch --config feed.json --key keys/watch.key --out receipts/ # one tick
187
+ tamper-signal review # list withheld changes
188
+ tamper-signal review accept <hash> --reason "confirmed by finance" # sign off + commit
189
+ ```
190
+
191
+ The fetch is SSRF-hardened (public hosts only, redirects off, TLS verified, byte + wall-clock caps; RSS parsed through `defusedxml`), change detection uses a full-content fingerprint rather than a trust-me `ETag`, and the unattended commit is crash-safe. The recommended deployment is the stateless tick under a systemd timer or cron, so the signing key is not resident between runs — see `AGENTS.md` §5c for a hardened unit. `watch`/`review` are Python-only today; the chains they write are read and verified by the JavaScript stack unchanged.
192
+
176
193
  ## Anchoring (optional)
177
194
 
178
- `pip install "tamper-signal[anchor]"`, then `receipts anchor` signs the exact bytes of chain.json into the public Sigstore transparency log under your OIDC identity (browser login locally, automatic in GitHub Actions). Because chain.json records the sha256 of every receipt file, the anchor covers the receipts themselves, not just their names. `receipts verify --anchor` then proves this exact chain, receipts included, existed at the logged time, independent of the signing key, closing the "whoever holds the key can quietly re-sign everything" gap for the moments that matter. A missing anchor is a yellow caveat; a chain that changed after anchoring is red.
195
+ `pip install "tamper-signal[anchor]"`, then `tamper-signal anchor` signs the exact bytes of chain.json into the public Sigstore transparency log under your OIDC identity (browser login locally, automatic in GitHub Actions). Because chain.json records the sha256 of every receipt file, the anchor covers the receipts themselves, not just their names. `tamper-signal verify --anchor` then proves this exact chain, receipts included, existed at the logged time, independent of the signing key, closing the "whoever holds the key can quietly re-sign everything" gap for the moments that matter. A missing anchor is a yellow caveat; a chain that changed after anchoring is red.
179
196
 
180
197
  ## What this proves, and what it doesn't
181
198
 
@@ -11,6 +11,12 @@ export interface ReceiptConsoleOptions {
11
11
  warnDrift?: boolean;
12
12
  /** Trusted public key hex, single or rotation list. */
13
13
  pubKey?: string | string[];
14
+ /**
15
+ * URL of the published `timeline.json` for the chain-of-custody layer.
16
+ * Defaults to `timeline.json` beside the chain. The custody layer is
17
+ * additive and never affects the verdict (which comes from `chain.json`).
18
+ */
19
+ timeline?: string;
14
20
  }
15
21
 
16
22
  export interface ReceiptConsoleHandle {