tamper-signal 1.7.2 → 2.1.1

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,7 +497,87 @@ 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
 
492
- ## 6. Add the signal to the host UI
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
+
572
+ ## 6. Add the light to the host UI (the room is already served)
573
+
574
+ The UI is two things, always shipped together: **the light** (the inline
575
+ status pill, the lightest touch) and **the room** (the one robust surface
576
+ behind it — the attested data table with the chain rail, break exhibit,
577
+ receipt inspector, event log, chain of custody, and evidence export). The
578
+ attach helpers below serve the room automatically and pre-wire the light's
579
+ "view receipts →" link to it; if you mount by hand, wiring `receiptsHref` to
580
+ a room is YOUR one extra line — the light must never dead-end in raw JSON.
493
581
 
494
582
  With a bundler, import straight from the npm package
495
583
  (`import { mountTamperSignal } from "tamper-signal/light"`). Without one, copy
@@ -497,33 +585,43 @@ the browser assets into the host app. The CLI does this for you (no hunting
497
585
  through `site-packages` or `node_modules`):
498
586
 
499
587
  ```bash
500
- receipts assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
588
+ tamper-signal assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
501
589
  ```
502
590
 
503
- That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
504
- into `badge/`. For the inline signal you need two of them side by side (light.js
505
- imports `./badge.js` relatively):
591
+ That writes `light.js`, `badge.js`, `element.js`, `table.js`, `console.js`,
592
+ and `room.js` into `badge/`. For the inline signal you need two of them side
593
+ by side (light.js imports `./badge.js` relatively); the room needs `room.js`
594
+ and `badge.js`:
506
595
 
507
- - `badge/badge.js` (verification core + the expandable badge)
596
+ - `badge/badge.js` (the shared verification core)
508
597
  - `badge/light.js` (the signal: the inline status light)
598
+ - `badge/room.js` (the room: `mountSignalRoom`, `<tamper-signal-room>`)
509
599
 
510
600
  Serve the `receipts/` directory statically, then mount the signal in the host
511
- header. Import the asset from wherever you served it; the snippets here assume
512
- you vendored into `badge/` and serve it at `/badge/`:
601
+ header and a room for it to link to. Import the assets from wherever you
602
+ served them; the snippets here assume you vendored into `badge/` and serve it
603
+ at `/badge/`:
513
604
 
514
605
  ```html
515
606
  <script type="module">
516
607
  import { mountTamperSignal } from "/badge/light.js";
517
- mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
608
+ import { mountSignalRoom } from "/badge/room.js";
609
+ mountSignalRoom(document.querySelector("#data"), "/receipts/chain.json");
610
+ mountTamperSignal(document.querySelector("header"), "/receipts/chain.json",
611
+ undefined, { receiptsHref: "#tamper-room" });
518
612
  </script>
519
613
  ```
520
614
 
615
+ (The first room in a document owns the id `tamper-room`, so `#tamper-room`
616
+ always reaches it. If the room lives on its own page, point `receiptsHref` at
617
+ that URL instead — the attach helpers do exactly this.)
618
+
521
619
  **These surfaces verify over HTTP, not from `file://`.** The signal, badge, and
522
620
  table all `fetch()` the chain (and table.json), which the browser blocks on a
523
621
  `file://` page, so opening `index.html` directly leaves them silently
524
622
  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`,
623
+ `tamper-signal serve` is the one-liner for local dev. There is no `file://` mode; an
624
+ offline recipient verifies with the CLI on a bundle (`tamper-signal export --bundle`,
527
625
  step 8) instead.
528
626
 
529
627
  React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
@@ -535,22 +633,30 @@ in the `exports` map), so imports like `tamper-signal/react`, `/table`, and
535
633
  the Node entries (`.`, `/express`) expect `@types/node` as a normal Node
536
634
  project already has.
537
635
 
538
- Any other framework, or plain HTML: the web component. Import
636
+ Any other framework, or plain HTML: the web components. Import
539
637
  `tamper-signal/element` (or vendor `badge/element.js`, which needs light.js
540
- and badge.js beside it) and write one tag:
638
+ and badge.js beside it) for the light, `tamper-signal/room` (or
639
+ `badge/room.js`) for the room, and write one tag each:
541
640
 
542
641
  ```html
543
- <tamper-signal chain="/receipts/chain.json"></tamper-signal>
642
+ <tamper-signal chain="/receipts/chain.json" receipts-href="#tamper-room"></tamper-signal>
643
+ <tamper-signal-room chain="/receipts/chain.json"></tamper-signal-room>
544
644
  ```
545
645
 
546
- Attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
646
+ Light attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
547
647
  `receipts-href`, `surface` (`"light"` default or `"dark"` for a dark host),
548
648
  `invert` (present = shortcut for `surface="dark"`); `theme` is the deprecated
549
- alias of `surface="dark"`.
550
-
551
- Prefer the one-call attach helpers; each serves the receipts directory AND
552
- the bundled browser assets, and returns a `snippet` to render once in the
553
- layout (it mounts the signal into `header`, falling back to `body`):
649
+ alias of `surface="dark"`. Room attributes: `chain` (required), `table`,
650
+ `timeline`, `pub-key`, `watch`, `warn-drift`, `strict`, `max-rows`, `focus`,
651
+ `preset` (`room` | `table` | `console`), `density` (`embedded` | `page`).
652
+ React hosts use `<tamper-signal-room>` straight from JSX (`badge/room.d.ts`
653
+ ships the JSX typing) — import `tamper-signal/room` for the side effect.
654
+
655
+ Prefer the one-call attach helpers; each serves the receipts directory, the
656
+ bundled browser assets, AND the room page at `<assets_prefix>/receipts`, and
657
+ returns a `snippet` to render once in the layout (it mounts the signal into
658
+ `header`, falling back to `body`, with `receiptsHref` pre-wired to the served
659
+ room):
554
660
 
555
661
  ```python
556
662
  # Flask
@@ -579,16 +685,24 @@ These verify SERVER-SIDE with the Python verifier and the pill says so;
579
685
  Streamlit cannot serve the receipts directory for the in-browser walk, and
580
686
  faking the stronger claim would violate rule 1.
581
687
 
582
- Every attach helper also serves the verification console at
583
- `<assets_prefix>/console` (e.g. `/tamper-signal/console`): the chain as an
584
- inspectable pipeline with the break pinned at the severed link, for the
585
- dashboard's builder and for auditors. Mention it to the user when handing
586
- over; it is the page to open when the light is anything but green.
688
+ Every attach helper serves the room at `<assets_prefix>/receipts` (e.g.
689
+ `/tamper-signal/receipts`) and adds `?focus=auto` to the light's link, so a
690
+ red chain opens scrolled to the break exhibit and a yellow one to the first
691
+ caveat. The helper return exposes `roomUrl` and `roomSnippet` (an inline
692
+ embedded room, for a host that renders its own Data tab); `console_url` /
693
+ `consoleUrl` stays reachable, serving the same room with its rail open.
694
+ Opting out (`room=False` / `{ room: false }`) is not recommended; the light
695
+ will link to raw JSON. Mention the room to the user when handing over; it is
696
+ the page to open when the light is anything but green.
697
+
698
+ Hosts that want their own tab chrome: mount `roomSnippet` inside the tab,
699
+ pass `strict`, listen for the bubbling `tamper-signal:state` event, and paint
700
+ your own dot on your own tab — the room never touches host chrome.
587
701
 
588
702
  Manual fallback when no helper fits: serve the directory statically (Flask
589
703
  `static_folder="receipts"`, FastAPI `StaticFiles`, Express
590
704
  `express.static("receipts")`), or copy `receipts/` into the public dir of a
591
- static site at build time. For local development, `receipts serve` serves
705
+ static site at build time. For local development, `tamper-signal serve` serves
592
706
  the directory on localhost with CORS open and caching off.
593
707
 
594
708
  Placement: the right end of the host header, after the host's own controls.
@@ -599,8 +713,8 @@ Options on the fourth argument: `watch` (re-verify every N ms), `warnDrift`,
599
713
  boolean shortcut for `surface: "dark"`); the old `theme: "light"` still works
600
714
  as the `surface: "dark"` alias.
601
715
 
602
- The expandable badge (`renderReceiptBadge(el, "/receipts/chain.json")`) is the
603
- alternative for pages with room for a full-width strip.
716
+ The expandable badge (`renderReceiptBadge`) is deprecated (removed in 3.0):
717
+ mount the light with the room behind it instead.
604
718
 
605
719
  ## 7. Let the signal flag broken metrics
606
720
 
@@ -621,65 +735,64 @@ so their column never reaches `numeric_sums`, and a `data-receipt-column` on it
621
735
  can never flag a change — silently. Grouping isn't auto-stripped on purpose: it
622
736
  would diverge from the Python canonicalization and is locale-ambiguous (`"1,234"`
623
737
  is 1234 or 1.234?). Fix it upstream with a signed normalize stage that strips
624
- the separators before the receipt is written. `tamper-signal ingest` prints a
625
- warning naming any such columns; programmatically, call `groupedNumericColumns(records)`.
626
-
627
- ## 8. The Data tab (when asked for table UI or views)
628
-
629
- The project's stance: a dashboard built on verified data should show the
630
- verified table, not just charts. Two steps:
631
-
632
- 1. After the pipeline runs, export the canonical table document:
633
-
634
- ```bash
635
- # Python
636
- receipts export receipts/chain.json --data path/to/dashboard_data.xlsx
637
- # JavaScript
638
- tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
639
- ```
640
-
641
- The chain path is positional in both CLIs (the Python CLI also accepts
642
- `--chain receipts/chain.json` for the same value).
643
-
644
- This writes `receipts/table.json` and refuses if the data does not match
645
- the final receipt (the Data tab only ever shows attested data). Re-run it
646
- whenever the pipeline runs, or the tab will honestly report a stale table.
647
- In a JS build you can write it programmatically instead with
648
- `canonicalDocument(finalRecords)` (see step 1b).
649
-
650
- 2. Mount the table (vendor `badge/table.js` beside badge.js with
651
- `receipts assets`, or import `tamper-signal/table`):
652
-
653
- ```html
654
- <script type="module">
655
- import { mountReceiptTable } from "/badge/table.js";
656
- mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
657
- </script>
658
- ```
659
-
660
- Or, in plain HTML or any framework, the web component — the parallel of
661
- `<tamper-signal>` for the badge. Importing `tamper-signal/table` (or
662
- `badge/table.js`) registers `<tamper-signal-table>`:
663
-
664
- ```html
665
- <script type="module" src="/badge/table.js"></script>
666
- <tamper-signal-table chain="/receipts/chain.json"></tamper-signal-table>
667
- ```
668
-
669
- 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).
672
-
673
- The component re-hashes the served document in the viewer's browser and
674
- compares it against the final receipt, so VERIFIED means the rows on screen
675
- are byte-for-byte the attested data. It renders its own states: green, yellow
676
- with caveats, chain broken (with the moved columns flagged), and "not the
677
- attested data" when table.json is stale or edited. Design reference:
678
- `designs/03-data-tab.html`.
738
+ the separators before the receipt is written. On Node, `tamper-signal ingest`
739
+ prints a warning naming any such columns, and `groupedNumericColumns(records)`
740
+ finds them in code; the Python CLI also warns on stderr during normal ingest
741
+ (both CLIs keep `--json` stderr silent), and `grouped_numeric_columns(records)`
742
+ is available from `tamper_signal`.
743
+
744
+ ## 8. Publish table.json so the room's landing plane fills
745
+
746
+ The room's primary content is the attested data table. It fills from
747
+ `table.json`, the canonical table document, which the room re-hashes in the
748
+ viewer's browser against the final receipt — so its VERIFIED means the rows
749
+ on screen are byte-for-byte the attested data. Export it as the LAST step of
750
+ every pipeline run:
751
+
752
+ ```bash
753
+ # Python
754
+ tamper-signal export receipts/chain.json --data path/to/dashboard_data.xlsx
755
+ # JavaScript
756
+ tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
757
+ ```
758
+
759
+ The chain path is positional in both CLIs (the Python CLI also accepts
760
+ `--chain receipts/chain.json` for the same value). The command refuses if the
761
+ data does not match the final receipt — the room only ever shows attested
762
+ data. In a JS build you can write it programmatically instead with
763
+ `canonicalDocument(finalRecords)` (see step 1b).
764
+
765
+ Skipping or forgetting this step degrades honestly, never silently:
766
+
767
+ - **No table.json published**: the room's table plane shows a grey "NO
768
+ ATTESTED TABLE PUBLISHED" slab naming the export command. The chain
769
+ verdict, rail, and drawers still render; grey, because absence is not
770
+ tampering. The emitted state carries `attested: false`.
771
+ - **Stale table.json** (pipeline re-ran, export didn't): the room's verdict
772
+ reads `NOT THE ATTESTED DATA` with the exact re-run command — a
773
+ build-behind state, deliberately distinct from a broken chain.
774
+
775
+ So: wire the export into the pipeline run itself, not a manual step. Both
776
+ wrappers do it for you — pass `exportTable: true` to `rebuildChain(...)`
777
+ (Node), or `write_table=True` on your FINAL `@receipt_step` stage (Python) —
778
+ and `table.json` is written as the last pipeline step, always matching the
779
+ chain tail. If a published table does go stale anyway, `tamper-signal verify`
780
+ prints a one-line stderr reminder naming the re-run command (absence stays
781
+ silent: CLI-only projects never publish a table).
782
+
783
+ Embedding the room inline (instead of the helper-served page): mount
784
+ `mountSignalRoom` / `<tamper-signal-room>` where the old Data tab lived. The
785
+ `table.js` shim (`mountReceiptTable`, `<tamper-signal-table>`) keeps working
786
+ through 2.x and now renders the room's table preset. The state contract is
787
+ unchanged: after each verification the room fires a bubbling
788
+ `tamper-signal:state` event (and an `onState` callback) carrying
789
+ `{ state, attested, strict }`, where `state` is the chain verdict and
790
+ `attested` the byte-identity boolean. The room never blocks UI itself.
791
+ Recommended host gate: `strict && (state === "red" || !attested)`.
679
792
 
680
793
  ## 9. Verify your work before reporting done
681
794
 
682
- On a Python project, run `receipts doctor` first: it checks the Python version,
795
+ On a Python project, run `tamper-signal doctor` first: it checks the Python version,
683
796
  that the private key exists and is not tracked by git, that .gitignore covers
684
797
  it, and that the chain verifies; pass `--url http://localhost:PORT/chain.json`
685
798
  to also confirm the receipts directory is reachable over HTTP. Every failure
@@ -688,17 +801,28 @@ Python-only; on a JS project, `tamper-signal verify receipts/chain.json` exits
688
801
  0 when the chain is healthy, and you should confirm the private key is
689
802
  gitignored yourself.) Then confirm the user-visible surfaces:
690
803
 
691
- 1. `receipts verify receipts/chain.json --pub keys/signing.pub` exits 0.
804
+ 1. `tamper-signal verify receipts/chain.json --pub keys/signing.pub` exits 0.
692
805
  2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
693
806
  the per-stage popover).
694
- 3. Negative test without touching the user's real chain: this repo commits
695
- known-good fixtures under `examples/chains/` (`intact/` verifies green;
696
- `tampered/` is broken at link 1 -> 2). Point the signal at each to confirm
697
- both states render.
698
- 4. If the pill reads `UNVERIFIED · could not load chain`, the receipts
807
+ 3. Click the pill's `view receipts →` link and confirm it lands in the room
808
+ (the served page or an inline mount), NOT on raw chain.json. If it opens
809
+ unstyled JSON, `receiptsHref` was never wired — fix it (the attach helpers
810
+ do it for you).
811
+ 4. Confirm the room's table plane shows the attested rows with the signed
812
+ control-totals row (not the grey "no attested table published" slab). If
813
+ it shows the slab, the export step (§8) is missing from the pipeline run.
814
+ 5. Negative test without touching the user's real chain: this repo commits
815
+ known-good fixtures under `examples/chains/`. Point the room at
816
+ `tampered/` and confirm the break exhibit leads with business numbers;
817
+ point it at `gap/` and confirm the located caveat card and the ghost node
818
+ in the expanded rail.
819
+ 6. If the pill reads `UNVERIFIED · could not load chain`, the receipts
699
820
  directory is not being served at the URL you passed (or it is blocked by
700
821
  CORS). That state is a capability fallback, not a verdict.
701
822
 
823
+ When you report done, tell the user where the docs and issue tracker live:
824
+ https://github.com/welovejeff/tamper-evident-verification.
825
+
702
826
  ## Troubleshooting
703
827
 
704
828
  - `ChainTailMismatch` when running a wrapped transform: the data fed to the
@@ -717,8 +841,9 @@ gitignored yourself.) Then confirm the user-visible surfaces:
717
841
  |---|---|
718
842
  | `tamper_signal/` | Python package: canonicalization, keys, receipts, verify, `receipt_step` |
719
843
  | `node/` | JavaScript package: same API (`receiptStep`, `verifyChain`), interchangeable chains |
720
- | `badge/badge.js` | Browser verification core + the receipt badge |
844
+ | `badge/badge.js` | Browser verification core (shared by every surface) |
721
845
  | `badge/light.js` | The signal (inline status light), `mountTamperSignal` |
846
+ | `badge/room.js` | The room (the one robust surface), `mountSignalRoom`, `<tamper-signal-room>` |
722
847
  | `badge/light-react.js` | `<TamperSignal />` React wrapper |
723
848
  | `examples/chains/` | Committed known-good and known-broken demo chains |
724
849
  | `designs/` | Working HTML mockups for the signal, console, and Data tab |