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. The ingest command prints a warning naming such columns, and groupedNumericColumns(records) finds 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.