TAMPER
SIGNAL
← Docs home

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"
Still continuity, not correctness

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.

History is weaker evidence than the chain

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:

The flip side

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