Reference
Mounting the signal
The light is most useful where the numbers live: on the dashboard itself. Everything here re-verifies the chain in the viewer's own browser using Web Crypto, with no build step, no framework requirement, and no server state. The UI is two things, always shipped together: the light (a small pill in the header, the lightest touch) and the room (the one robust surface behind it, where the light's "view receipts" link lands).
Every surface fetch()es the chain (and table.json), which the browser blocks on a file:// page. Opening index.html directly leaves the signal silently unverified. Serve the page over HTTP: any static server works, and tamper-signal serve is the one-liner for local dev. For a truly offline recipient, send a verified bundle (tamper-signal export --bundle) and verify with the CLI instead.
One light, one room
| Surface | Import | For |
|---|---|---|
| The light | tamper-signal/light | A small pill in the dashboard header. The whole footprint on your page. |
| The room | tamper-signal/room | Everything behind the light: the attested table, the chain, the break exhibit, the inspector, the log, the custody timeline, the export. |
Compat shims (2.x): tamper-signal/table and tamper-signal/console keep their old signatures and now render the room's table and console presets. tamper-signal/badge's renderReceiptBadge is deprecated (removed in 3.0).
The inline status light
A dark, monospace pill that shows the traffic light and expands to a popover: a per-stage table when green, the caveat list when yellow, the broken link and totals delta when red.
<!-- serve receipts/ statically, then mount in the header -->
<script type="module">
import { mountTamperSignal } from "/badge/light.js";
mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
</script>
With a bundler it is the same call from the package:
import { mountTamperSignal } from "tamper-signal/light";
Options
| Option | Effect |
|---|---|
watch | Re-verify every N milliseconds and pulse the pill on a transition. |
warnDrift | Treat control-total movement as a yellow caveat, matching --warn-drift. |
receiptsHref | Where the popover's "view receipts" link points. |
surface: "dark" | Invert the pill for a dark host. Default is "light". (invert: true is a shortcut.) |
The pill is intentionally dark and monospace so it reads as an instrument, not a host control. Place it at the right end of your header, after your own controls, and do not restyle it to match the page. On a dark host, pass { surface: "dark" } so it inverts cleanly.
React and web component
// React
import { TamperSignal } from "tamper-signal/react";
<TamperSignal chain="/receipts/chain.json" />
// Plain HTML, via tamper-signal/element
<tamper-signal chain="/receipts/chain.json"></tamper-signal>
The room
The one surface behind the light. The attested table is the landing plane (re-hashed in the viewer's browser against the final receipt, so its VERIFIED means the rows on screen are byte for byte the attested data), with the chain as a provenance rail above it, the break exhibit in business numbers when the chain is red, and the receipt inspector, CLI-mirror event log, and chain-of-custody timeline one drawer away. Green earns silence; the layout leads with whatever the verdict demands.
# 1. export the canonical table as the last pipeline step (Python or Node)
tamper-signal export receipts/chain.json --data dashboard.xlsx
# 2. mount it
<script type="module">
import { mountSignalRoom } from "/badge/room.js";
mountSignalRoom(document.querySelector("#data-tab"), "/receipts/chain.json");
</script>
Or the web component (works from React JSX too: importing tamper-signal/room registers it and ships the JSX typing):
<script type="module" src="/badge/room.js"></script>
<tamper-signal-room chain="/receipts/chain.json"></tamper-signal-room>
Attributes: chain (required), table, timeline, pub-key, watch, warn-drift, strict, max-rows, focus, preset (room | table | console), density (embedded | page). After each verification the room fires a bubbling tamper-signal:state event carrying { state, attested, strict }; the documented host gate is strict && (state === "red" || !attested). Skipping the export step degrades honestly: no table.json shows a grey "no attested table published" slab, a stale one reads NOT THE ATTESTED DATA with the re-run command.
Every attach helper serves the room automatically at /tamper-signal/receipts and points the light's "view receipts" link there with ?focus=auto, so a red chain opens scrolled to the break. /tamper-signal/console stays reachable, serving the room with its rail open.
One-call framework helpers
Each attach helper serves three things from one call (your receipts/ directory, the verifier assets, and the room page) and returns a ready-to-drop snippet with the light's receiptsHref pre-wired to the room, so you do not wire paths by hand. You structurally cannot ship the light without a live room behind it. (room=False / { room: false } opts out; not recommended: the light will link to raw JSON.)
# Flask
from tamper_signal.flask_ext import attach
signal = attach(app, receipts_dir="receipts/") # {{ signal.snippet | safe }}
# FastAPI
from tamper_signal.fastapi_ext import attach
signal = attach(app, receipts_dir="receipts/")
// Express
import { tamperSignal } from "tamper-signal/express";
const signal = tamperSignal(app, { receiptsDir: "receipts/" });
// render signal.snippet once in your layout
| Host | How |
|---|---|
| Flask | tamper_signal.flask_ext.attach(app, ...) |
| FastAPI | tamper_signal.fastapi_ext.attach(app, ...) |
| Express | tamperSignal(app, { receiptsDir }) from tamper-signal/express |
| Next.js | Copy receipts/ into public/receipts/ during the pipeline run, then <TamperSignal chain="/receipts/chain.json" /> |
| Streamlit | from tamper_signal.streamlit_ext import signal, verified_dataframe (server-side verified, a weaker check) |
Flag a broken metric in place
The signal can reach into the page and outline the exact numbers that no longer descend from the source. Add data-receipt-column to any element that displays a metric, naming the column it comes from. When the chain breaks at a link where that column moved, the signal outlines the element and tags it tamper signal: unverified value.
<div class="kpi" data-receipt-column="spend_(usd)">
$98,441.02
</div>
Column names are the normalized keys from the receipts' control totals: lowercased, spaces turned to underscores. Read them from numeric_sums and null_counts in the receipt files.
Only plain decimals are summed into numeric_sums. A thousands-grouped string like "289,084" or "1 198 372" is not coerced to a number, so that column never reaches the totals, and data-receipt-column on it cannot flag a change. The fix is upstream: a signed normalize stage that turns the column into plain decimals. On both Node and Python, the ingest command prints a warning naming such columns during normal ingest; --json keeps stderr silent. The helpers groupedNumericColumns(records) and grouped_numeric_columns(records) find them in code.
Verify your wiring
- The pill reads
VERIFIED · chain intact; click it for the per-stage popover. - The pill's "view receipts →" link lands in the room, not on raw
chain.json. If it opens unstyled JSON,receiptsHrefwas never wired (the attach helpers do it for you). - The room's table plane shows the attested rows with the signed control-totals row. If it shows the grey "no attested table published" slab, the export step is missing from the pipeline run.
- Point it at
examples/chains/intact/for green andexamples/chains/tampered/for red, as a negative test. - If it reads
UNVERIFIED · could not load chain, the receipts directory is not being served at that URL (usually CORS). That state is a capability fallback, not a verdict about your data. - If it reads
verification unsupported in this browser, the browser lacks Web Crypto Ed25519; verify from the CLI instead.