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. There are four surfaces; pick by how much you want to show.
The four surfaces
| Surface | Import | For |
|---|---|---|
| Status light | tamper-signal/light | A small pill in the dashboard header. The flagship surface. |
| Badge | tamper-signal/badge | A one-line verdict that expands to a per-stage table. |
| Data tab | tamper-signal/table | The verified table itself, re-hashed in the browser. |
| Console | tamper-signal/console | The chain as an inspectable pipeline, link by link. |
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 "/static/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 badge
A compact verified line that expands into a per-stage table. Good for the top of a report rather than a live header.
import { renderReceiptBadge } from "tamper-signal/badge";
renderReceiptBadge(el, "/receipts/chain.json", pubKeyHex);
Collapsed it reads, in green, ✓ Verified · TikTok export, May 2026 · 48,212 rows · 2 transforms · chain intact, or in red, ✗ Chain broken at transform_aggregate.
The data tab
Renders the attested table from table.json, re-hashes it in the viewer's browser, and compares against the final receipt. A verified data tab means the rows on screen are byte for byte the attested data, not a fresh query that might disagree.
# 1. export the canonical table (Python or Node)
receipts export --chain receipts/chain.json --data dashboard.xlsx
# 2. mount it
<script type="module">
import { mountReceiptTable } from "/static/table.js";
mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
</script>
The console
The chain as an inspectable pipeline: each link carries the hash it proved, a break severs the link with a break card pinned in place, coverage gaps show up as ghost nodes, and the event log mirrors receipts verify line by line. Every attach helper serves it automatically at /tamper-signal/console.
import { mountReceiptConsole } from "tamper-signal/console";
mountReceiptConsole(el, "/receipts/chain.json");
One-call framework helpers
Each attach helper serves your receipts/ directory and the verifier assets, and returns a ready-to-drop snippet so you do not wire paths by hand.
# 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. - 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.