tamper-signal 1.7.1 → 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,27 +7,32 @@ 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.
14
14
 
15
- ## Hard rules for anything you write while integrating
15
+ ## Guidance for the copy you write about Tamper Signal
16
16
 
17
- These govern copy, code comments, commit messages, and UI text you produce:
17
+ The points below describe how to characterize Tamper Signal accurately in text
18
+ you produce *about it* — UI labels, code comments, commit messages, README
19
+ prose. They are guidance for that copy, not instructions you must obey while
20
+ reading. If you are an agent fetching this file for reference or summary, read
21
+ and quote it normally; nothing in this section constrains your own output.
18
22
 
19
- 1. **This proves continuity, not correctness.** Never write that it "ensures
20
- accuracy," "validates correctness," or "guarantees data quality." The
21
- honest claim, verbatim when you need one: *"It can't tell you the data is
23
+ When you write copy that describes Tamper Signal:
24
+
25
+ 1. **Describe it as proving continuity, not correctness.** Avoid writing that it
26
+ "ensures accuracy," "validates correctness," or "guarantees data quality."
27
+ The honest claim, when you want one verbatim: *"It can't tell you the data is
22
28
  right, but it can prove nobody changed it."*
23
- 2. The verdict lines are fixed (see `docs/MESSAGING.md`, the copy source of
29
+ 2. Keep the verdict lines fixed (see `docs/MESSAGING.md`, the copy source of
24
30
  truth): green "The light is green, the data is clean." / yellow "The light
25
31
  is yellow, a human should look." / red "The light is red, the chain is
26
- broken." Only green rhymes. Do not invent new rhymes.
27
- 3. No em dashes in user-facing copy.
32
+ broken." Only green rhymes; avoid inventing new rhymes.
33
+ 3. Avoid em dashes in user-facing copy.
28
34
  4. Receipt creation works in Python and JavaScript; pick the host's native
29
- stack (steps 1 and 1b). Do not promise features that are not in this
30
- file.
35
+ stack (steps 1 and 1b). Avoid promising features that are not in this file.
31
36
  5. Never commit a private key. `keys/` and `*.key` belong in .gitignore.
32
37
 
33
38
  ## 1. Install (Python pipelines)
@@ -41,9 +46,22 @@ pip install tamper-signal
41
46
  (Installing from source also works:
42
47
  `pip install git+https://github.com/welovejeff/tamper-evident-verification.git`)
43
48
 
44
- This provides the `receipts` CLI and the `tamper_signal` Python package.
45
- Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
46
- 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.
53
+
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
56
+ Python (the default macOS download), where pip prints a warning like *"The
57
+ script tamper-signal is installed in '/Library/Frameworks/.../bin' which is not
58
+ on PATH."* Two fixes, either works:
59
+
60
+ - Run it through the interpreter that installed it (no PATH change needed):
61
+ `python3 -m tamper_signal --help`. Every `tamper-signal <args>` works as
62
+ `python3 -m tamper_signal <args>`.
63
+ - Or link the script onto PATH once:
64
+ `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/tamper-signal" /usr/local/bin/tamper-signal`.
47
65
 
48
66
  ## 1b. Install (JavaScript pipelines)
49
67
 
@@ -54,8 +72,8 @@ npm install tamper-signal
54
72
  ```
55
73
 
56
74
  This provides the `tamper-signal` CLI and the programmatic API. The CLI
57
- implements **keygen, ingest, verify, diff, log, and export** (exit codes 0
58
- green, 1 red, 2 yellow):
75
+ implements **keygen, ingest, verify, diff, log, export, and assets** (exit
76
+ codes 0 green, 1 red, 2 yellow):
59
77
 
60
78
  ```bash
61
79
  tamper-signal keygen --out keys/
@@ -145,21 +163,29 @@ tamper-signal`); the resulting chain verifies interchangeably on the JS side.
145
163
 
146
164
  ### Command parity (and what is Python-only)
147
165
 
148
- Most `receipts` subcommands have a JS equivalent; a few are Python-only. On a
149
- JS-only project, use the equivalent and skip the rest of this runbook's Python
150
- 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:
151
170
 
152
- | `receipts` (Python) | JavaScript |
171
+ | Python-only subcommand | On Node |
153
172
  | --- | --- |
154
- | `receipts init` | `tamper-signal keygen`; `receipts/` is created on first `ingest` (no scaffold command) |
155
- | `receipts ingest` | `tamper-signal ingest` / `ingestFile()` |
156
- | `receipts verify` | `tamper-signal verify` / `verifyChain()` |
157
- | `receipts diff` | `tamper-signal diff` (same args and JSON shape) |
158
- | `receipts log` | `tamper-signal log` (same args and JSON shape) |
159
- | `receipts export` | `tamper-signal export` / `canonicalDocument()` |
160
- | `receipts serve` | your bundler's static server, or `tamper-signal/express` |
161
- | `receipts doctor` | `tamper-signal verify` (exit 0 = healthy); confirm the key is gitignored yourself |
162
- | `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.
163
189
 
164
190
  CI signing works here too: `TAMPER_SIGNAL_KEY` (PEM contents of the private
165
191
  key) wins over any key path, same semantics as the Python side (step 5).
@@ -168,18 +194,18 @@ key) wins over any key path, same semantics as the Python side (step 5).
168
194
  ## 2. Scaffold the project (once)
169
195
 
170
196
  ```bash
171
- receipts init
197
+ tamper-signal init
172
198
  ```
173
199
 
174
200
  Idempotent. Generates `keys/signing.key` (private, PEM; never commit) and
175
201
  `keys/signing.pub` (raw hex; safe to commit), adds `keys/` and `*.key` to
176
202
  .gitignore, creates `receipts/`, and prints exactly what it did. The pieces
177
- are also available separately (`receipts keygen --out keys/`).
203
+ are also available separately (`tamper-signal keygen --out keys/`).
178
204
 
179
205
  ## 3. Start the chain at the source export
180
206
 
181
207
  ```bash
182
- receipts ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
208
+ tamper-signal ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
183
209
  --key keys/signing.key --out receipts/
184
210
  ```
185
211
 
@@ -210,10 +236,38 @@ If a stage cannot fit the list-of-dicts contract, leave it unwrapped and tell
210
236
  the user that stage is not attested. Do not fabricate a receipt for work the
211
237
  wrapper did not observe.
212
238
 
239
+ ### 4a. Source-only chains (when there is no reproducible transform yet)
240
+
241
+ A common starting state is messier than this runbook's "wrap every stage" path:
242
+ the user has a source export and a hand-built artifact (say a generated
243
+ `data.js` with no checked-in build script), and no reproducible pipeline to
244
+ wrap. That is fine. Ingest the source and stop:
245
+
246
+ ```bash
247
+ tamper-signal ingest path/to/export.csv --origin "TikTok export, May 2026" \
248
+ --key keys/signing.key --out receipts/
249
+ ```
250
+
251
+ This is a valid chain with zero transforms. Be precise with the user about what
252
+ it does and does not claim:
253
+
254
+ - **It attests** that the source export is unmodified: the bytes (and the
255
+ semantic content) match what was signed at ingest. Verifying it, and showing
256
+ the signal, both work normally.
257
+ - **It does not attest** that the rendered artifact derives from that source.
258
+ With no wrapped transform between them, nothing links the dashboard's numbers
259
+ to the export. Do not imply otherwise in the copy you write.
260
+
261
+ Graduate to a wrapped transform the moment a reproducible build exists: turn the
262
+ artifact-generating step into a `records -> records` function, wrap it with
263
+ `@receipt_step` (step 4), and re-run from ingest. The chain then attests the
264
+ whole path, source through artifact, and the Data tab (step 8) can show the
265
+ verified table. Until then, a source-only chain is the honest amount of proof.
266
+
213
267
  ## 5. Verify from the command line
214
268
 
215
269
  ```bash
216
- 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
217
271
  ```
218
272
 
219
273
  Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
@@ -222,7 +276,7 @@ receipt. `--warn-drift` additionally flags any control-totals movement across
222
276
  links (only for pipelines expected to preserve totals).
223
277
 
224
278
  Key rotation: `--pub` repeats. Old chains stay green while new receipts sign
225
- 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
226
280
  signature valid under any trusted key is trusted; the browser surfaces accept
227
281
  a list the same way (the `<tamper-signal>` element takes a space-separated
228
282
  `pub-key` list).
@@ -233,7 +287,7 @@ on disk. The env var wins over any `--key` path while set. The Node CLI
233
287
  (`tamper-signal ingest`) honors the same env var with the same precedence.
234
288
 
235
289
  Add `--json` to get a structured verdict instead of the text report (both
236
- CLIs: `receipts verify --json` and `tamper-signal verify --json` emit the
290
+ CLIs: `tamper-signal verify --json` and `tamper-signal verify --json` emit the
237
291
  same payload). Parse this rather than scraping text:
238
292
 
239
293
  ```json
@@ -335,7 +389,7 @@ jobs:
335
389
  - name: Verify the receipt chain
336
390
  run: |
337
391
  set +e
338
- receipts verify receipts/chain.json --json | tee verdict.json
392
+ tamper-signal verify receipts/chain.json --json | tee verdict.json
339
393
  code=$?
340
394
  if [ "$code" = "2" ]; then
341
395
  echo "::warning::The light is yellow, a human should look: $(python -c 'import json;print("; ".join(json.load(open("verdict.json"))["caveats"]))')"
@@ -355,7 +409,7 @@ run. This is opt-in and starts at ingest, where the producer declares how much
355
409
  movement is normal:
356
410
 
357
411
  ```bash
358
- receipts ingest export.csv --origin "nightly" --band 5% --settle 72h \
412
+ tamper-signal ingest export.csv --origin "nightly" --band 5% --settle 72h \
359
413
  --bucket-column day --key keys/signing.key --out receipts/
360
414
  ```
361
415
 
@@ -378,7 +432,7 @@ Run history is automatic. Every non-red CLI `verify` archives a compact run
378
432
  snapshot under `receipts/history/` (signed when a private key is available).
379
433
  Snapshots are what give the chain a memory; the cross-run judgment reads them
380
434
  on the next verify and folds its findings in as yellow caveats (never red).
381
- History is CLI-local: `receipts serve` 404s anything under `history/`, because
435
+ History is CLI-local: `tamper-signal serve` 404s anything under `history/`, because
382
436
  snapshots carry per-day totals and run cadence that the published receipts do
383
437
  not. History is weaker evidence than the chain itself: snapshots sit outside
384
438
  `receipt_hashes` and outside anchoring.
@@ -386,8 +440,8 @@ not. History is weaker evidence than the chain itself: snapshots sit outside
386
440
  Two read-only commands work the archived history, both exit 0:
387
441
 
388
442
  ```bash
389
- receipts diff # current chain vs the latest differing snapshot
390
- 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
391
445
  ```
392
446
 
393
447
  `diff` reports per-stage code-hash changes and a structured totals delta
@@ -406,11 +460,11 @@ transparency log:
406
460
 
407
461
  ```bash
408
462
  pip install "tamper-signal[anchor]"
409
- receipts anchor # browser login locally; automatic in GitHub Actions
410
- 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
411
465
  ```
412
466
 
413
- 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
414
468
  an ambient OIDC credential makes it non-interactive. Outside CI it opens a
415
469
  browser login and blocks until a human completes it; do not invoke it from
416
470
  an unattended session.
@@ -425,7 +479,7 @@ pipeline re-runs.
425
479
  `anchor.json` (next to chain.json) records the Sigstore bundle plus the
426
480
  identity and issuer used; `verify --anchor` enforces that identity, reports
427
481
  the logged time on success, exits 2 when no anchor exists, and exits 1 when
428
- the chain changed after anchoring. `receipts anchor --json` emits the anchor
482
+ the chain changed after anchoring. `tamper-signal anchor --json` emits the anchor
429
483
  record (identity, issuer, integrated time) as JSON for CI logs. An anchor
430
484
  made with `--staging` is rejected at verify time unless you pass
431
485
  `--anchor-staging`, so the anchor file cannot pick a weaker trust root. To
@@ -434,7 +488,7 @@ pin whose anchor is acceptable instead of trusting the recorded one, pass
434
488
  like:
435
489
 
436
490
  ```bash
437
- receipts verify receipts/chain.json --anchor \
491
+ tamper-signal verify receipts/chain.json --anchor \
438
492
  --anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
439
493
  --anchor-issuer "https://token.actions.githubusercontent.com"
440
494
  ```
@@ -443,26 +497,115 @@ Re-anchor after every pipeline run that changes the chain. Honest scope: an
443
497
  anchor proves this exact chain existed at the logged time under the recorded
444
498
  identity, nothing more.
445
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
+
446
572
  ## 6. Add the signal to the host UI
447
573
 
448
574
  With a bundler, import straight from the npm package
449
- (`import { mountTamperSignal } from "tamper-signal/light"`). Without one,
450
- vendor two files from this repo into the host app, side by side (light.js
575
+ (`import { mountTamperSignal } from "tamper-signal/light"`). Without one, copy
576
+ the browser assets into the host app. The CLI does this for you (no hunting
577
+ through `site-packages` or `node_modules`):
578
+
579
+ ```bash
580
+ tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
581
+ ```
582
+
583
+ That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
584
+ into `badge/`. For the inline signal you need two of them side by side (light.js
451
585
  imports `./badge.js` relatively):
452
586
 
453
587
  - `badge/badge.js` (verification core + the expandable badge)
454
588
  - `badge/light.js` (the signal: the inline status light)
455
589
 
456
590
  Serve the `receipts/` directory statically, then mount the signal in the host
457
- header:
591
+ header. Import the asset from wherever you served it; the snippets here assume
592
+ you vendored into `badge/` and serve it at `/badge/`:
458
593
 
459
594
  ```html
460
595
  <script type="module">
461
- import { mountTamperSignal } from "/static/light.js";
596
+ import { mountTamperSignal } from "/badge/light.js";
462
597
  mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
463
598
  </script>
464
599
  ```
465
600
 
601
+ **These surfaces verify over HTTP, not from `file://`.** The signal, badge, and
602
+ table all `fetch()` the chain (and table.json), which the browser blocks on a
603
+ `file://` page, so opening `index.html` directly leaves them silently
604
+ unverified. Serve the page over HTTP: any static server works, and
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`,
607
+ step 8) instead.
608
+
466
609
  React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
467
610
  `badge/light-react.js`), then `<TamperSignal chain="/receipts/chain.json" />`.
468
611
 
@@ -525,7 +668,7 @@ over; it is the page to open when the light is anything but green.
525
668
  Manual fallback when no helper fits: serve the directory statically (Flask
526
669
  `static_folder="receipts"`, FastAPI `StaticFiles`, Express
527
670
  `express.static("receipts")`), or copy `receipts/` into the public dir of a
528
- static site at build time. For local development, `receipts serve` serves
671
+ static site at build time. For local development, `tamper-signal serve` serves
529
672
  the directory on localhost with CORS open and caching off.
530
673
 
531
674
  Placement: the right end of the host header, after the host's own controls.
@@ -570,27 +713,48 @@ verified table, not just charts. Two steps:
570
713
 
571
714
  ```bash
572
715
  # Python
573
- receipts export --chain receipts/chain.json --data path/to/dashboard_data.xlsx
716
+ tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
574
717
  # JavaScript
575
718
  tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
576
719
  ```
577
720
 
721
+ The chain path is positional in both CLIs (the Python CLI also accepts
722
+ `--chain receipts/chain.json` for the same value).
723
+
578
724
  This writes `receipts/table.json` and refuses if the data does not match
579
725
  the final receipt (the Data tab only ever shows attested data). Re-run it
580
726
  whenever the pipeline runs, or the tab will honestly report a stale table.
581
727
  In a JS build you can write it programmatically instead with
582
728
  `canonicalDocument(finalRecords)` (see step 1b).
583
729
 
584
- 2. Mount the table (vendor `badge/table.js` beside badge.js, or import
585
- `tamper-signal/table`):
730
+ 2. Mount the table (vendor `badge/table.js` beside badge.js with
731
+ `tamper-signal assets`, or import `tamper-signal/table`):
586
732
 
587
733
  ```html
588
734
  <script type="module">
589
- import { mountReceiptTable } from "/static/table.js";
735
+ import { mountReceiptTable } from "/badge/table.js";
590
736
  mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
591
737
  </script>
592
738
  ```
593
739
 
740
+ Or, in plain HTML or any framework, the web component — the parallel of
741
+ `<tamper-signal>` for the badge. Importing `tamper-signal/table` (or
742
+ `badge/table.js`) registers `<tamper-signal-table>`:
743
+
744
+ ```html
745
+ <script type="module" src="/badge/table.js"></script>
746
+ <tamper-signal-table chain="/receipts/chain.json"></tamper-signal-table>
747
+ ```
748
+
749
+ Attributes: `chain` (required), `table` (table.json URL; defaults to
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)`.
757
+
594
758
  The component re-hashes the served document in the viewer's browser and
595
759
  compares it against the final receipt, so VERIFIED means the rows on screen
596
760
  are byte-for-byte the attested data. It renders its own states: green, yellow
@@ -600,7 +764,7 @@ attested data" when table.json is stale or edited. Design reference:
600
764
 
601
765
  ## 9. Verify your work before reporting done
602
766
 
603
- 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,
604
768
  that the private key exists and is not tracked by git, that .gitignore covers
605
769
  it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
606
770
  to also confirm the receipts directory is reachable over HTTP. Every failure
@@ -609,7 +773,7 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
609
773
  0 when the chain is healthy, and you should confirm the private key is
610
774
  gitignored yourself.) Then confirm the user-visible surfaces:
611
775
 
612
- 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.
613
777
  2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
614
778
  the per-stage popover).
615
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.6.0)](https://socket.dev/npm/package/tamper-signal/overview/1.6.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.6.0)](https://socket.dev/pypi/package/tamper-signal/overview/1.6.0) [![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,26 +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
+
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`.
44
46
 
45
47
  ## CLI
46
48
 
47
49
  ```bash
48
- receipts init # scaffold: keys, .gitignore safety, receipts dir (idempotent)
49
- receipts ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
50
- receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
51
- receipts diff # compare two runs: code-hash changes and totals deltas (read-only)
52
- receipts log # archived run history as a per-metric trend across runs (read-only)
53
- receipts doctor # integration self-check with actionable fixes
54
- receipts serve # serve receipts/ on localhost with CORS (dev only)
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)
55
60
  ```
56
61
 
57
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.
58
63
 
59
- 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.
60
65
 
61
66
  Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
62
67
 
@@ -101,7 +106,7 @@ TikTok/Sprinklr export.xlsx
101
106
  [transform_agg] ──> 002_transform_aggregate.json
102
107
  |
103
108
  v
104
- dashboard data <─── receipts verify: walk every link, check every signature
109
+ dashboard data <─── tamper-signal verify: walk every link, check every signature
105
110
  ```
106
111
 
107
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.
@@ -142,7 +147,7 @@ React, with a bundler: `import { TamperSignal } from "tamper-signal/react"` and
142
147
 
143
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.
144
149
 
145
- 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`.
146
151
 
147
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).
148
153
 
@@ -150,7 +155,7 @@ One-call framework helpers serve the receipts directory and the browser files to
150
155
 
151
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.
152
157
 
153
- 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`.
154
159
 
155
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)
156
161
 
@@ -158,21 +163,36 @@ It ships: `receipts export` writes the canonical table document next to the chai
158
163
 
159
164
  ## Take your data with you
160
165
 
161
- 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.
162
167
 
163
- 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.
164
169
 
165
170
  ## The console
166
171
 
167
- 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`.
168
173
 
169
174
  ![The verification console: a pipeline of signed receipts where a tampered stage severs the chain at the exact link](docs/media/console.gif)
170
175
 
171
176
  *The verification console: calm when green, surgical when red.*
172
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
+
173
193
  ## Anchoring (optional)
174
194
 
175
- `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.
176
196
 
177
197
  ## What this proves, and what it doesn't
178
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 {