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.

tamper-signal 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
tamper-signal 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
          tamper-signal 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]"
tamper-signal anchor                              # browser login locally; ambient OIDC in GitHub Actions
tamper-signal 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:

tamper-signal 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:

tamper-signal 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:

tamper-signal diff                          # current chain vs the latest differing snapshot
tamper-signal 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 tamper-signal 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.

tamper-signal 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.

Live-source watcher

When the source is a live feed rather than a file you re-export by hand, the watcher keeps it on the same signed chain. It polls, judges the new data against the band and settling window you declared (see Period-over-period), and auto-appends only a clean change. A retroactive edit to an already-settled period — or a slow drift that cumulatively breaches the band — is never signed unattended: it is withheld as a signed pending event and paused for a human.

pip install "tamper-signal[watch]"
tamper-signal watch --config feed.json --key keys/watch.key --out receipts/

The config is a small JSON file: url, format (json or rss), a stable source_id, an optional field_map or columnar mapping for nested/columnar feeds, and the band/settle/bucket_column tolerance. Change detection is a full-content fingerprint, never a replayable ETag/304. The fetch is SSRF-hardened (public hosts only, redirects off, TLS verified, byte + wall-clock caps; RSS parsed through defusedxml).

Withheld changes are reviewed explicitly — each acceptance signs its own reason and commits the exact reviewed candidate:

tamper-signal review                       # list what is waiting
tamper-signal review accept <hash> --reason "confirmed by the data desk"

The recommended deployment is the stateless tick under a systemd timer or cron, so the signing key is not resident between runs — it is a local file-writer, not a server. See AGENTS.md §5c for a hardened systemd unit (dedicated user, NoNewPrivileges, ProtectSystem=strict, the key delivered via LoadCredential=). The watch and review commands are Python-only; the chains they write are read and verified by the Node stack unchanged. See the live weather demo.

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