Going deeper
Power user
The capabilities that show up once a chain is part of a real workflow: rotating keys without breaking old receipts, signing in CI with no key on disk, gating a build on the light, witnessing a chain in public, and crossing between stacks and formats freely.
Key rotation
Keys change. When they do, you do not want every receipt signed under the old key to suddenly read as untrusted. Verification accepts more than one trusted key: pass --pub as many times as you need, and any listed key satisfies a receipt.
receipts verify receipts/chain.json --pub keys/new.pub --pub keys/old.pub
Old receipts stay green under the old key while new receipts sign under the new one. In the browser surfaces, the pub-key attribute takes a space-separated list for the same effect.
Signing in CI without a key file
A private key should never be written to a CI runner's disk. Put the PEM contents in a repository secret and expose it as TAMPER_SIGNAL_KEY. The wrapper, ingest, and export all read it, and it wins over any --key path. The semantics are identical in both stacks.
export TAMPER_SIGNAL_KEY="$(cat keys/signing.key)" # from a secret, not the repo
receipts ingest export.csv --origin "nightly" --out receipts/
Gating a build on the light
Because the exit code is the light, a CI job can fail on red and merely warn on yellow. This makes a broken chain a build failure, while a caveat surfaces for a human without blocking the pipeline.
name: tamper-signal
on: [push]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install tamper-signal
- name: Verify the receipt chain
run: |
set +e
receipts verify receipts/chain.json --json | tee verdict.json
code=$?
if [ "$code" = "2" ]; then
echo "::warning::The light is yellow, a human should look."
exit 0
fi
exit $code
Anchoring to a public log
The local key is the day-to-day root of trust, but whoever holds it could in principle re-sign a whole new chain. Anchoring closes that gap for the moments that matter: it records the chain in a Sigstore transparency log, an append-only public ledger, under your identity, witnessed independently of your key. See the notary analogy in concepts.
pip install "tamper-signal[anchor]"
receipts anchor # browser login locally; ambient OIDC in GitHub Actions
receipts verify receipts/chain.json --anchor
With --anchor, verify proves this exact chain existed at the logged time under the recorded identity. A missing anchor is a yellow caveat (exit 2); a chain that changed after it was anchored is red (exit 1). In CI you pin the identity and issuer explicitly:
receipts verify receipts/chain.json --anchor \
--anchor-identity "https://github.com/OWNER/REPO/.github/workflows/anchor.yml@refs/heads/main" \
--anchor-issuer "https://token.actions.githubusercontent.com"
An anchor proves existence at a time, witnessed by someone who is not you. It says nothing about whether the data was right, and nothing about the moments before it was anchored. It strengthens the chain's history; it does not change what the chain claims.
Cross-stack chains
Python and JavaScript are equal citizens. The canonicalization is byte-identical, proven by golden vectors generated from the Python side, so a chain started in one stack verifies in the other.
Why it works
Both implementations weigh against the same reference standard. A receipt does not record "Python produced this" or "Node produced this"; it records the canonical hash, and both stacks compute that hash the same way. A drift between them would fail a golden-vector test long before it ever reached a user as a false tamper verdict.
Common pattern: ingest an xlsx once with Python, then run the rest of the pipeline and verify in Node.
Period-over-period continuity
A report you refresh on a cadence has a second kind of continuity: not just "did this run stay intact," but "do this run's numbers line up with last run's." A nightly export of the same dashboard should look a lot like yesterday's. Tamper Signal can watch that, but only after the producer says out loud how much movement is normal.
You declare the tolerance once, at ingest, and it is signed into the source manifest:
receipts ingest export.csv --origin "nightly" --band 5% --settle 72h --bucket-column day
--band is how far a number may drift run over run; --settle is how long a period stays soft; --bucket-column is the date column the per-period buckets hang off (omit it and a single date-shaped column is found automatically). Because the declaration is signed, you cannot quietly widen the band later to hide a jump: loosening it breaks the signature.
The drying-ink rule
Think of each day's bucket as a page of fresh ink. While the ink is wet, inside the settling window, the figures may still shift a little as late conversions and backfilled attribution land; small movement there is maturation, not tampering, as long as it stays within the band. Once the page is dry, older than the window, it is settled. A settled page that changes at all, by any amount, is worth a human look. Recent data may drift within the band; settled data is frozen.
Green for a refresh: recent buckets moved within the band, settled buckets did not move at all. Yellow: a recent bucket drifted past the band, or a settled bucket moved, or a period that was there last run vanished from this one.
Every non-red verify quietly archives a snapshot of the run under receipts/history/, and the next run judges itself against that memory. Two read-only commands read the history back; both exit 0 whatever they find:
receipts diff # current chain vs the latest differing snapshot
receipts log --granularity week # per-metric trend across runs, oldest first
diff shows what changed between two runs: which stage code moved and the totals delta, including which period buckets shifted. log draws the trend, one row per period, each metric with its value and the change from the previous row, marking the runs that breached.
Two things to disclose. First, declaring a bucket column means the published receipts now carry daily-granularity buckets, so a reader of chain.json sees per-day totals, not just whole-table ones. Second, the run history is CLI-local memory: snapshots sit outside receipt_hashes and outside anchoring, so they are weaker evidence than the chain itself, and receipts serve never exposes the history/ directory. The chain is what you prove; history is what helps you read it.
Control-total drift
Filters and aggregations move totals on purpose, so a moving total is not a tamper by itself. That is why drift warnings are opt-in. Turn them on when you want a human to glance at any movement across links, and leave them off for pipelines that legitimately reshape the data.
receipts verify receipts/chain.json --pub keys/signing.pub --warn-drift
With --warn-drift, control-total movement becomes a yellow caveat rather than passing silently. The same switch exists as the warnDrift option on the browser surfaces.
Format-agnostic hashing
The semantic hash is taken over the meaning of the data, not its packaging, so the same rows hash the same whether they arrive as xlsx, CSV, TSV, JSON, or NDJSON. Two consequences worth leaning on:
- An xlsx ingest verifies against a CSV copy of the same data. You can change storage formats between stages without breaking the chain.
- Row order is not part of integrity (rows are sorted before hashing), and numeric-looking text hashes as the number it parses to, so a re-export that reorders rows or restyles a typeless column does not register as a change.
That same canonicalization is why a thousands-grouped string column cannot be summed or flagged in place. If you need a column watched, normalize it to plain decimals in a signed stage first. See metric flagging.
Before you call it done
- Run
receipts doctor: it checks the Python version, that the private key exists and is untracked, that.gitignorecovers it, and that the chain verifies. Add--urlto confirm the chain is reachable over HTTP. receipts verify receipts/chain.json --pub keys/signing.pubexits0.- The host page pill reads
VERIFIED · chain intact. - Negative test against
examples/chains/tampered/shows red and names the broken link.