TAMPER
SIGNAL
← all posts

Guilty until proven clean: a chain of custody for AI-built dashboards

A row of wax-sealed receipt discs threaded onto a chain on a dark surface, each seal glowing emerald green, an unbroken chain of custody

A photographer posts a real image and gets accused of using AI. They didn't do anything wrong. The tools just got good enough that "that's AI" became the default suspicion, and now the honest person has to prove a negative.

If you ship data work, that is already your problem. You point an AI assistant at a month of TikTok exports, describe the dashboard you want, and have it running by the afternoon. It is genuinely great work. Then your client asks the only question that matters: "how do I know these numbers are real?" And you realize you cannot actually answer. The pipeline that built the dashboard is a stack of AI-written transforms nobody re-checked against 48,000 rows.

That is the trust gap that will throttle AI-driven workflows. Not whether AI can build the thing, it obviously can, but whether anyone downstream can believe what it built. Every AI-built deliverable is now guilty until proven clean.

Why provenance works here when it fails everywhere else

The whole industry is trying to solve content provenance right now, and on the open internet it is losing. Content Credentials get stripped from images the instant they hit Instagram or X, because the platform re-encodes every upload. Invisible watermarks like SynthID are being defeated by free, open-source tools. The reason is simple: out in the feed, nobody controls the channel the data travels through.

A client deliverable is the mirror image of that problem. You control every hop. The export comes from the platform, lands in a file, passes through transforms you wrote, and renders in a dashboard you deploy. Each step is a place you can seal. Provenance that is hopeless in the wild is completely achievable inside a delivery pipeline, because the channel is yours end to end.

What makes the channel trustable is a chain of custody: a record at every hop of what came in, what code touched it, and what came out, signed so it cannot be quietly edited after the fact. That is exactly what Tamper Signal produces. And to be clear about what it does and does not do: this proves continuity, not correctness. It can't tell you the data is right, but it can prove nobody changed it between the source and the chart.

The SOP: chain of custody for social-media data

Adopt this as a team standard for any deliverable built on downloaded platform data. The point is that custody is sealed at intake and never broken, so the producer can hand a client proof instead of a promise. The commands below use the Python receipts CLI; every step has a tamper-signal equivalent in Node (anchoring is Python-only today), and the chains are interchangeable.

1. Acquire at the source, and only there. Download the export directly from the platform (TikTok Analytics, Meta Business Suite, the X export, and so on). Do not hand-edit the file. The moment someone opens the xlsx and "just fixes one cell," custody is broken before it started.

2. Seal it at intake. Ingest the untouched export immediately. The origin string is the custody label, so write it for a human who will read it during a dispute:

receipts init
receipts ingest tiktok_may2026.xlsx \
  --origin "TikTok Analytics, @brandhandle, 2026-05-01 to 2026-05-31, downloaded 2026-06-01 by J. Rivera" \
  --key keys/signing.key --out receipts/

The source export is now the first link in the chain. Everything downstream has to descend from it.

3. Transform only through wrapped stages. Every cleaning and aggregation step signs a receipt. No off-chain edits, ever. The wrapper verifies the existing chain first and refuses to run on data that does not descend from the previous stage's output, so an out-of-band edit cannot slip in unnoticed. If a step genuinely cannot fit the records-in, records-out contract, leave it unwrapped and label that stage as not attested. Never fabricate a receipt for work the chain did not observe.

4. For recurring reports, declare what "normal movement" means. A weekly refresh legitimately changes the numbers. Declare a tolerance at intake so settled periods are locked while recent ones can still settle:

receipts ingest weekly.csv --origin "weekly refresh" \
  --band 5% --settle 72h --bucket-column day --key keys/signing.key --out receipts/

The declaration is signed into the source, so nobody can quietly loosen the band after the fact.

5. Export the attested table. A dashboard built on verified data should be able to show that data, not just charts on top of it.

receipts export --chain receipts/chain.json --data dashboard_data.xlsx

This writes receipts/table.json and refuses unless the data matches the final receipt, so the Data tab only ever shows attested rows.

6. Verify before anything ships. This is the gate. Green ships, yellow gets human eyes, red does not leave the building.

receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard_data.xlsx

The light is green, the data is clean. Every link verifies, every signature checks. Ship it.

The light is yellow, a human should look. A coverage gap, an unrecognized key, or declared drift. Resolve before delivery.

The light is red, the chain is broken. It names the exact link and the totals delta, for example row_count 48212 -> 48190 (-22). Fix the pipeline, do not ship.

7. Anchor when a client or auditor is on the other end. The local key proves the chain was not edited, but whoever holds the key could re-sign a fresh chain. For client-facing work, anchor the chain in the public Sigstore transparency log so existence-at-a-time is provable in a dispute:

receipts anchor
receipts verify receipts/chain.json --anchor

8. Deliver with the light in the dashboard. Mount the signal so the client's own browser re-verifies the whole chain on page load, with no trust in you required. 9. Keep the keys straight: the private key never leaves the producer's machine or CI secret store and is always gitignored; the public key and the receipts travel with the deliverable. The client verifies against the public key and never needs yours.

Implementing it in a vibe-coded visualization

This is the part that makes the SOP real instead of aspirational, and it is deliberately light. Two phases: wire the four touchpoints in development, then make verification a build gate in deployment. Pick your stack, the chains are interchangeable.

In development

Install, scaffold, and wire the four touchpoints into the pipeline your AI assistant already wrote.

pip install tamper-signal
receipts init                 # keys, gitignore safety, receipts/
npm install tamper-signal
npx tamper-signal keygen --out keys/   # receipts/ is created on first ingest

Note: the Node reader takes .csv / .tsv / .json / .ndjson, not .xlsx. The social export usually arrives as xlsx, so convert it to CSV first, or ingest it once with the Python CLI; the resulting chain verifies interchangeably on the JS side.

Ingest the source to seal custody at intake.

receipts ingest tiktok_may2026.xlsx \
  --origin "TikTok export, @brandhandle, May 2026" \
  --key keys/signing.key --out receipts/
npx tamper-signal ingest tiktok_may2026.csv \
  --origin "TikTok export, @brandhandle, May 2026" --out receipts/

Wrap each transform so every stage signs a receipt. The wrapper verifies the chain tail first and refuses to extend a broken chain.

from tamper_signal import receipt_step

@receipt_step(chain_dir="receipts/", key_path="keys/signing.key")
def drop_blank_campaigns(records):
    return [r for r in records if r.get("campaign_name")]
import { rebuildChain } from "tamper-signal";

const normalize = (records) => records.map(toNumbers);
const dropBlankCampaign = (records) =>
  records.filter((r) => r.campaign_name);

// Re-ingests the source (resetting the chain), then runs the stages in order.
await rebuildChain({
  file: "tiktok_may2026.csv",
  stages: [normalize, dropBlankCampaign],
  chainDir: "receipts/",
  keyPath: "keys/signing.key",
});

Export the attested table so the Data tab shows the verified rows the charts came from.

receipts export --chain receipts/chain.json --data dashboard_data.xlsx
npx tamper-signal export receipts/chain.json --data dashboard_data.csv

Mount the light in the dashboard header. The browser surface is the same package on both stacks; serve the receipts/ directory statically and the badge re-verifies in the viewer's browser, zero config, because the chain embeds its own public key.

<script type="module">
  import { mountTamperSignal } from "/static/light.js";
  mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
</script>

React hosts can use <TamperSignal chain="/receipts/chain.json" /> from tamper-signal/react; any framework or plain HTML can use the <tamper-signal chain="/receipts/chain.json"></tamper-signal> element instead. When you think you are done, prove it:

receipts doctor   # checks Python version, key is gitignored, chain verifies
npx tamper-signal verify receipts/chain.json   # exit 0 = healthy; confirm the key is gitignored yourself

If you are handing this to a coding agent, you do not have to wire it by hand. Point the agent at the repo and say "add tamper signal." The AGENTS.md runbook walks it through install, keygen, ingest, wrapping transforms, mounting the signal, and verifying, in order, every step checkable.

In deployment

Verification becomes a build gate so a broken chain can never reach production. Sign in CI with the private key as a repo secret (TAMPER_SIGNAL_KEY, the PEM contents) so no key file touches disk. Red (exit 1) fails the build; yellow (exit 2) surfaces a warning without blocking.

# .github/workflows/tamper-signal.yml
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
      - run: receipts verify receipts/chain.json --json
# .github/workflows/tamper-signal.yml
name: tamper-signal
on: [push]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npx tamper-signal verify receipts/chain.json --json

Anchoring runs non-interactively in GitHub Actions using its ambient OIDC credential, so every deployed chain can also be publicly timestamped (Python CLI today). That is the whole adoption cost: one wrapper per transform, one verify in CI, one tag in the header.

In return, the deliverable carries its own proof. The client clicks the light and checks for themselves, and the analyst stops having to prove a negative. The accusation that broke the photographer, "how do I know this is real," gets a one-word answer: green.

Trust receipts, not vibes.