tamper-signal 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +235 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/badge/badge.js +436 -0
- package/badge/element.js +77 -0
- package/badge/light-react.js +26 -0
- package/badge/light.js +430 -0
- package/node/canonical.js +271 -0
- package/node/cli.js +142 -0
- package/node/index.js +48 -0
- package/node/keys.js +74 -0
- package/node/load.js +125 -0
- package/node/receipts.js +335 -0
- package/node/test/canonical.test.js +55 -0
- package/node/test/chain.test.js +97 -0
- package/node/test/interop.test.js +46 -0
- package/node/test/vectors.json +115 -0
- package/node/totals.js +134 -0
- package/node/wrapper.js +84 -0
- package/package.json +54 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Integrating Tamper Signal: a runbook for coding agents
|
|
2
|
+
|
|
3
|
+
You are reading this because a user pointed you at this repo and said something
|
|
4
|
+
like "add tamper signal," "add the signal to my dashboard," or "give my data
|
|
5
|
+
receipts." This file is the complete integration path. Follow it in order;
|
|
6
|
+
every step is verifiable.
|
|
7
|
+
|
|
8
|
+
What you are installing: signed receipts for a data pipeline. Every pipeline
|
|
9
|
+
stage signs a receipt (a hash of its input, its code, and its output, plus
|
|
10
|
+
human-legible control totals). Receipts link into a chain; `receipts verify`
|
|
11
|
+
and an in-browser signal re-verify the whole chain and report a traffic light.
|
|
12
|
+
Green: intact. Yellow: verifies, with caveats a human should look at. Red:
|
|
13
|
+
broken, at an exact link, with the totals delta.
|
|
14
|
+
|
|
15
|
+
## Hard rules for anything you write while integrating
|
|
16
|
+
|
|
17
|
+
These govern copy, code comments, commit messages, and UI text you produce:
|
|
18
|
+
|
|
19
|
+
1. **This proves continuity, not correctness.** Never write that it "ensures
|
|
20
|
+
accuracy," "validates correctness," or "guarantees data quality." The
|
|
21
|
+
honest claim, verbatim when you need one: *"It can't tell you the data is
|
|
22
|
+
right, but it can prove nobody changed it."*
|
|
23
|
+
2. The verdict lines are fixed (see `docs/MESSAGING.md`, the copy source of
|
|
24
|
+
truth): green "The light is green, the data is clean." / yellow "The light
|
|
25
|
+
is yellow, a human should look." / red "The light is red, the chain is
|
|
26
|
+
broken." Only green rhymes. Do not invent new rhymes.
|
|
27
|
+
3. No em dashes in user-facing copy.
|
|
28
|
+
4. Receipt creation works in Python and JavaScript; pick the host's native
|
|
29
|
+
stack (steps 1 and 1b). Do not promise features that are not in this
|
|
30
|
+
file.
|
|
31
|
+
5. Never commit a private key. `keys/` and `*.key` belong in .gitignore.
|
|
32
|
+
|
|
33
|
+
## 1. Install (Python pipelines)
|
|
34
|
+
|
|
35
|
+
Requires Python 3.11+.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/welovejeff/tamper-evident-verification.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This provides the `receipts` CLI and the `tamper_signal` Python package.
|
|
42
|
+
Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
|
|
43
|
+
the JS equivalents; the two stacks produce interchangeable chains.
|
|
44
|
+
|
|
45
|
+
## 1b. Install (JavaScript pipelines)
|
|
46
|
+
|
|
47
|
+
Requires Node 18.17+.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install tamper-signal
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This provides the `tamper-signal` CLI (keygen / ingest / verify, exit codes
|
|
54
|
+
0 green, 1 red, 2 yellow) and the programmatic API:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { receiptStep, loadCsv } from "tamper-signal";
|
|
58
|
+
|
|
59
|
+
const clean = receiptStep(
|
|
60
|
+
(records) => records.filter((r) => r.campaign_name !== null),
|
|
61
|
+
{ chainDir: "receipts/", keyPath: "keys/signing.key", codeFile: "pipeline.js" }
|
|
62
|
+
);
|
|
63
|
+
const output = await clean(loadCsv("export.csv"));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`receiptStep` wraps a sync or async records -> records function with the
|
|
67
|
+
same contract as Python's `receipt_step`: verify the chain tail first,
|
|
68
|
+
refuse foreign input, sign and append a receipt. The browser files are the
|
|
69
|
+
same package: `tamper-signal/light`, `tamper-signal/badge`,
|
|
70
|
+
`tamper-signal/element`, `tamper-signal/react`. JS reads .csv/.tsv/.json/
|
|
71
|
+
.ndjson; only the Python side reads .xlsx.
|
|
72
|
+
|
|
73
|
+
## 2. Generate a signing keypair (once per project)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
receipts keygen --out keys/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Writes `keys/signing.key` (private, PEM; never commit) and `keys/signing.pub`
|
|
80
|
+
(raw 32-byte hex; safe to commit). Add `keys/` and `*.key` to .gitignore now.
|
|
81
|
+
|
|
82
|
+
## 3. Start the chain at the source export
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
receipts ingest path/to/export.xlsx --origin "TikTok export, May 2026" \
|
|
86
|
+
--key keys/signing.key --out receipts/
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`--origin` is free text describing where the file came from; it appears in the
|
|
90
|
+
signal's UI, so write it for humans. Input formats: .xlsx/.xlsm (Python only),
|
|
91
|
+
.csv, .tsv, .json (array of objects), .ndjson/.jsonl. The semantic hash is
|
|
92
|
+
identical across formats, so an xlsx ingest verifies against a CSV or JSON
|
|
93
|
+
copy of the same data. This writes `receipts/000_source.json` and
|
|
94
|
+
`receipts/chain.json`.
|
|
95
|
+
|
|
96
|
+
## 4. Wrap every transform stage
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from tamper_signal import receipt_step
|
|
100
|
+
|
|
101
|
+
@receipt_step(chain_dir="receipts/", key_path="keys/signing.key")
|
|
102
|
+
def transform_clean(records):
|
|
103
|
+
return [r for r in records if r.get("campaign_name")]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Contract: the function takes and returns either a list of dicts or a pandas
|
|
107
|
+
DataFrame (frames are hashed as records and pass through the function
|
|
108
|
+
untouched; NaN becomes the canonical null). The wrapper verifies the existing
|
|
109
|
+
chain first, refuses to run if the input data does not descend from the chain
|
|
110
|
+
tail (`ChainTailMismatch`), then signs and appends a receipt for the stage.
|
|
111
|
+
|
|
112
|
+
If a stage cannot fit the list-of-dicts contract, leave it unwrapped and tell
|
|
113
|
+
the user that stage is not attested. Do not fabricate a receipt for work the
|
|
114
|
+
wrapper did not observe.
|
|
115
|
+
|
|
116
|
+
## 5. Verify from the command line
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
receipts verify receipts/chain.json --pub keys/signing.pub --data path/to/dashboard_data.xlsx
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Exit codes are the traffic light: **0 green, 1 red, 2 yellow**. `--data` is
|
|
123
|
+
optional and checks the file the dashboard actually reads against the final
|
|
124
|
+
receipt. In CI: fail the build on exit 1; surface exit 2 to a human rather
|
|
125
|
+
than failing silently. `--warn-drift` additionally flags any control-totals
|
|
126
|
+
movement across links (only for pipelines expected to preserve totals).
|
|
127
|
+
|
|
128
|
+
## 6. Add the signal to the host UI
|
|
129
|
+
|
|
130
|
+
With a bundler, import straight from the npm package
|
|
131
|
+
(`import { mountTamperSignal } from "tamper-signal/light"`). Without one,
|
|
132
|
+
vendor two files from this repo into the host app, side by side (light.js
|
|
133
|
+
imports `./badge.js` relatively):
|
|
134
|
+
|
|
135
|
+
- `badge/badge.js` (verification core + the expandable badge)
|
|
136
|
+
- `badge/light.js` (the signal: the inline status light)
|
|
137
|
+
|
|
138
|
+
Serve the `receipts/` directory statically, then mount the signal in the host
|
|
139
|
+
header:
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<script type="module">
|
|
143
|
+
import { mountTamperSignal } from "/static/light.js";
|
|
144
|
+
mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
|
|
145
|
+
</script>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
|
|
149
|
+
`badge/light-react.js`), then `<TamperSignal chain="/receipts/chain.json" />`.
|
|
150
|
+
|
|
151
|
+
Any other framework, or plain HTML: the web component. Import
|
|
152
|
+
`tamper-signal/element` (or vendor `badge/element.js`, which needs light.js
|
|
153
|
+
and badge.js beside it) and write one tag:
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<tamper-signal chain="/receipts/chain.json"></tamper-signal>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Attributes mirror the options: `pub-key`, `watch`, `warn-drift`,
|
|
160
|
+
`receipts-href`, `theme`.
|
|
161
|
+
|
|
162
|
+
Static serving examples: Flask
|
|
163
|
+
`app = Flask(__name__, static_folder="receipts", static_url_path="/receipts")`;
|
|
164
|
+
FastAPI `app.mount("/receipts", StaticFiles(directory="receipts"))`; Express
|
|
165
|
+
`app.use("/receipts", express.static("receipts"))`. A purely static site can
|
|
166
|
+
copy `receipts/` into its public directory at build time.
|
|
167
|
+
|
|
168
|
+
Placement: the right end of the host header, after the host's own controls.
|
|
169
|
+
The pill is intentionally dark and mono; do not restyle it to match the host
|
|
170
|
+
theme (on a dark host, pass `{ theme: "light" }` instead). Options on the
|
|
171
|
+
fourth argument: `watch` (re-verify every N ms), `warnDrift`, `receiptsHref`,
|
|
172
|
+
`theme`.
|
|
173
|
+
|
|
174
|
+
The expandable badge (`renderReceiptBadge(el, "/receipts/chain.json")`) is the
|
|
175
|
+
alternative for pages with room for a full-width strip.
|
|
176
|
+
|
|
177
|
+
## 7. Let the signal flag broken metrics
|
|
178
|
+
|
|
179
|
+
Add `data-receipt-column="<column>"` to any metric element whose value comes
|
|
180
|
+
from a chain column. When the chain breaks, the signal outlines the elements
|
|
181
|
+
whose columns moved at the broken link and tags them "tamper signal:
|
|
182
|
+
unverified value."
|
|
183
|
+
|
|
184
|
+
Column names must match the normalized names in the receipts' control totals
|
|
185
|
+
(lowercased, spaces to underscores). Do not guess: read
|
|
186
|
+
`receipts/chain.json`, open the listed receipt files, and use the exact keys
|
|
187
|
+
under `control_totals.numeric_sums` / `null_counts`.
|
|
188
|
+
|
|
189
|
+
## 8. The Data tab (when asked for table UI or views)
|
|
190
|
+
|
|
191
|
+
The project's stance: a dashboard built on verified data should show the
|
|
192
|
+
verified table, not just charts. If the user asks for the table UI, add a
|
|
193
|
+
"Data" tab next to the charts that renders the final stage's output (the same
|
|
194
|
+
file `--data` verifies), labeled with the current verdict. The design
|
|
195
|
+
reference is `designs/03-data-tab.html` with notes in `designs/03-NOTES.md`;
|
|
196
|
+
there is no packaged component yet, so build it in the host's own stack and
|
|
197
|
+
say so honestly.
|
|
198
|
+
|
|
199
|
+
## 9. Verify your work before reporting done
|
|
200
|
+
|
|
201
|
+
1. `receipts verify receipts/chain.json --pub keys/signing.pub` exits 0.
|
|
202
|
+
2. Load the host page: the pill reads `VERIFIED · chain intact` (click it for
|
|
203
|
+
the per-stage popover).
|
|
204
|
+
3. Negative test without touching the user's real chain: this repo commits
|
|
205
|
+
known-good fixtures under `examples/chains/` (`intact/` verifies green;
|
|
206
|
+
`tampered/` is broken at link 1 -> 2). Point the signal at each to confirm
|
|
207
|
+
both states render.
|
|
208
|
+
4. If the pill reads `UNVERIFIED · could not load chain`, the receipts
|
|
209
|
+
directory is not being served at the URL you passed (or it is blocked by
|
|
210
|
+
CORS). That state is a capability fallback, not a verdict.
|
|
211
|
+
|
|
212
|
+
## Troubleshooting
|
|
213
|
+
|
|
214
|
+
- `ChainTailMismatch` when running a wrapped transform: the data fed to the
|
|
215
|
+
stage is not the previous stage's output. Re-run the pipeline from ingest;
|
|
216
|
+
do not bypass the wrapper.
|
|
217
|
+
- Yellow with "unrecognized signing key": the `pubKeyHex` passed to the signal
|
|
218
|
+
differs from the key embedded in chain.json. Pass the right trusted key, or
|
|
219
|
+
omit the argument to trust the embedded key.
|
|
220
|
+
- The pill shows `UNVERIFIED · verification unsupported in this browser`: the
|
|
221
|
+
browser lacks Web Crypto Ed25519. The signal says nothing about the chain in
|
|
222
|
+
this state; verify from the CLI instead.
|
|
223
|
+
|
|
224
|
+
## Repo map
|
|
225
|
+
|
|
226
|
+
| Path | What it is |
|
|
227
|
+
|---|---|
|
|
228
|
+
| `tamper_signal/` | Python package: canonicalization, keys, receipts, verify, `receipt_step` |
|
|
229
|
+
| `node/` | JavaScript package: same API (`receiptStep`, `verifyChain`), interchangeable chains |
|
|
230
|
+
| `badge/badge.js` | Browser verification core + the receipt badge |
|
|
231
|
+
| `badge/light.js` | The signal (inline status light), `mountTamperSignal` |
|
|
232
|
+
| `badge/light-react.js` | `<TamperSignal />` React wrapper |
|
|
233
|
+
| `examples/chains/` | Committed known-good and known-broken demo chains |
|
|
234
|
+
| `designs/` | Working HTML mockups for the signal, console, and Data tab |
|
|
235
|
+
| `docs/MESSAGING.md` | Copy rules; the source of truth for any words you write |
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeff MacDonald
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<sub>tamper-signal</sub>
|
|
2
|
+
|
|
3
|
+
# The light is green, the data is clean.
|
|
4
|
+
|
|
5
|
+
Your social team exports a month of TikTok performance data. Someone vibe-codes a dashboard on top of it with an AI assistant in an afternoon. It looks great. Then a transform silently drops 22 rows, or the model hallucinates an aggregation, and the numbers in front of your boss are wrong. Nothing in that workflow catches it. This is the missing verification layer: every stage of the pipeline signs a receipt for what went in and what came out, and one command (or a badge on the dashboard itself) tells you whether the chain is intact, or exactly where it broke and by how much.
|
|
6
|
+
|
|
7
|
+
**Live demo:** [welovejeff.github.io/tamper-evident-verification](https://welovejeff.github.io/tamper-evident-verification/) re-verifies a real committed receipt chain in your browser: swap in a tampered chain or an untrusted key and watch the light catch it.
|
|
8
|
+
|
|
9
|
+
**Pointing a coding agent at this repo?** `AGENTS.md` is the full integration runbook: install, keygen, ingest, wrap transforms, mount the signal, verify. Tell your agent "add tamper signal" and it will find it.
|
|
10
|
+
|
|
11
|
+
## The problem
|
|
12
|
+
|
|
13
|
+
Vibe-coded pipelines fail silently. AI-generated transform scripts work most of the time, and when they don't, they don't crash. They drop rows. They double-count. They coerce a column wrong and quietly shift every total. The dashboard still renders. The chart still looks plausible. Nobody re-checks 48,000 rows by hand.
|
|
14
|
+
|
|
15
|
+
Traditional answers (warehouse lineage, dbt, data-quality suites) assume infrastructure a small team running xlsx-to-dashboard doesn't have. This is the lightweight version: signed receipts as files on disk, no database, no server, no catalog.
|
|
16
|
+
|
|
17
|
+
## The traffic light
|
|
18
|
+
|
|
19
|
+
The badge and the verifier reduce the whole chain to one state:
|
|
20
|
+
|
|
21
|
+
- 🟢 **Green.** Every link in the receipt chain verifies. Every signature is valid. The data made it from the original export to the dashboard unchanged.
|
|
22
|
+
- 🟡 **Yellow.** Verifiable, but with caveats: gaps in receipt coverage, an unrecognized signing key, or control-total drift that needs a human look.
|
|
23
|
+
- 🔴 **Red.** Chain broken. A hash doesn't match at a specific link. You get the exact stage and the control-totals delta (e.g. `row_count 48212 -> 48190 (-22)`).
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
*The inline status light: a small dark instrument in your dashboard's header. When the chain breaks, it reaches into the page and flags the exact metric that no longer descends from the source.*
|
|
28
|
+
|
|
29
|
+
Honest status: all three verdicts are implemented in `receipts verify` and the browser badge. Yellow today covers two detectable caveats (a coverage gap in the receipt numbering, and signatures that only verify under the chain's embedded key rather than the key you trust) plus opt-in control-total drift via `--warn-drift`. The Data tab and console animations in this README are design previews of later interface tiers, built from the working mockups in `designs/`. The badge also renders a separate amber state ("could not load" or "verification unsupported in this browser"); that is a capability fallback that says nothing about the chain, not the yellow verdict.
|
|
30
|
+
|
|
31
|
+
## 60-second quickstart
|
|
32
|
+
|
|
33
|
+
Python 3.11+. Open source (MIT), `pip`-installable.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone <this repo> && cd tamper-evident-verification
|
|
37
|
+
pip install -e .
|
|
38
|
+
receipts demo
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`receipts demo` runs the whole story end to end: generates a deliberately messy sample export, ingests it, runs two AI-written-style transforms, verifies the chain (PASS), then tampers with one spend value and verifies again (FAIL, pinpointing the broken link and the totals delta). It finishes by serving the badge at `http://localhost:8000/badge/badge.html` so you can see green, yellow, and red side by side.
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
receipts keygen --out keys/
|
|
47
|
+
receipts ingest sample_export.xlsx --origin "TikTok export, May 2026" --key keys/signing.key --out receipts/
|
|
48
|
+
receipts verify receipts/chain.json --pub keys/signing.pub --data dashboard.xlsx
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals.
|
|
52
|
+
|
|
53
|
+
Transforms record their own receipts by wrapping any list-of-dicts to list-of-dicts function:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from tamper_signal import receipt_step
|
|
57
|
+
|
|
58
|
+
@receipt_step(chain_dir="receipts/", key_path="keys/signing.key")
|
|
59
|
+
def transform_clean(records):
|
|
60
|
+
return [r for r in records if r.get("campaign_name")]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The wrapper verifies the chain tail first, refuses to run if the input hash doesn't match it, runs the function, then signs and appends a receipt. Transforms can also take and return pandas DataFrames; frames are hashed as records and pass through untouched.
|
|
64
|
+
|
|
65
|
+
## JavaScript pipelines
|
|
66
|
+
|
|
67
|
+
The same receipts, native to Node (18.17+): `npm install tamper-signal` provides a `tamper-signal` CLI (keygen, ingest, verify, with the same exit codes) and a programmatic API. Chains are interchangeable across the two stacks; the canonicalization is byte-identical, proven by golden vectors generated from the Python side.
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import { receiptStep, loadCsv } from "tamper-signal";
|
|
71
|
+
|
|
72
|
+
const clean = receiptStep(
|
|
73
|
+
(records) => records.filter((r) => r.campaign_name !== null),
|
|
74
|
+
{ chainDir: "receipts/", keyPath: "keys/signing.key" }
|
|
75
|
+
);
|
|
76
|
+
const output = await clean(loadCsv("export.csv"));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
JavaScript reads .csv, .tsv, .json, and .ndjson; spreadsheets go through the Python CLI. The browser surfaces ship in the same package: `tamper-signal/light`, `tamper-signal/badge`, `tamper-signal/element`, `tamper-signal/react`.
|
|
80
|
+
|
|
81
|
+
## How the chain works
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
TikTok/Sprinklr export.xlsx
|
|
85
|
+
|
|
|
86
|
+
v
|
|
87
|
+
[ingest] ──────────> 000_source.json evidence hash + semantic hash + totals, signed
|
|
88
|
+
|
|
|
89
|
+
v
|
|
90
|
+
[transform_clean] ─> 001_transform_clean.json input hash == previous output hash
|
|
91
|
+
|
|
|
92
|
+
v
|
|
93
|
+
[transform_agg] ──> 002_transform_aggregate.json
|
|
94
|
+
|
|
|
95
|
+
v
|
|
96
|
+
dashboard data <─── receipts verify: walk every link, check every signature
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Each receipt contains the SHA-256 of its input, the SHA-256 of the transform's source code, the SHA-256 of its output, and human-legible control totals (row counts, numeric sums, date ranges, null counts). Receipts link because each stage's input hash must equal the prior stage's output hash. Everything is signed with Ed25519; `chain.json` is just an ordered list of receipt files plus the public key.
|
|
100
|
+
|
|
101
|
+
Two hashes exist per artifact. The **evidence hash** anchors the raw file bytes at ingest. The **semantic hash** covers the canonicalized data content, stable across format round-trips (xlsx re-save, xlsx to CSV, xlsx to JSON) so long as the values are unchanged. Row order is not part of integrity: rows are sorted before hashing.
|
|
102
|
+
|
|
103
|
+
When verification fails, you don't get a shrug. You get the link:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
✗ CHAIN BROKEN at link 1 -> 2 (transform_aggregate)
|
|
107
|
+
expected input hash a3f1...9c (output of transform_clean)
|
|
108
|
+
found input hash 77b2...d4
|
|
109
|
+
Control totals delta vs upstream: row_count 48212 -> 48190 (-22), spend_(usd) -98.40
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Hashes say "broken." Totals say "how broken."
|
|
113
|
+
|
|
114
|
+
## The badge
|
|
115
|
+
|
|
116
|
+
`badge/badge.js` exports `renderReceiptBadge(containerEl, chainUrl, pubKeyHex)`. Drop it into any web frontend, point it at your `receipts/chain.json`, and it re-verifies the whole chain client-side with Web Crypto Ed25519: every signature, every hash link. No build step, no framework, no server-side trust. The badge re-checks hash links only; it does not re-canonicalize xlsx in the browser.
|
|
117
|
+
|
|
118
|
+

|
|
119
|
+
|
|
120
|
+
Green collapsed state reads like: `✓ Verified · TikTok export, May 2026 · 48,212 rows · 2 transforms · chain intact`. Expanding shows one row per receipt.
|
|
121
|
+
|
|
122
|
+
## The signal: an inline status light
|
|
123
|
+
|
|
124
|
+
`badge/light.js` is the v1 dashboard UI: a small dark pill that mounts in your header, runs the same in-browser verification as the badge, and shows the verdict as the light. It deliberately refuses to adopt your dashboard's theme; like a tamper sticker, its value comes from being recognizable anywhere. One call:
|
|
125
|
+
|
|
126
|
+
```html
|
|
127
|
+
<script type="module">
|
|
128
|
+
import { mountTamperSignal } from "/badge/light.js";
|
|
129
|
+
mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
|
|
130
|
+
</script>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
React, with a bundler: `import { TamperSignal } from "tamper-signal/react"` and `<TamperSignal chain="/receipts/chain.json" />`. Everything else (Vue, Svelte, plain HTML): import `tamper-signal/element` and write `<tamper-signal chain="/receipts/chain.json"></tamper-signal>`.
|
|
134
|
+
|
|
135
|
+
The pill expands to a popover: the per-stage table when green, the caveat list when yellow, the broken link with its totals delta when red. In the red state the light also reaches into the page: give any metric element a `data-receipt-column="spend_usd"` attribute, and if that column moved at the broken link the element gets outlined and tagged `tamper signal: unverified value`. Mark up your metrics once and the light flags the exact number that no longer descends from the source.
|
|
136
|
+
|
|
137
|
+
Options on the fourth argument: `watch` (re-verify every N ms and pulse on transitions), `warnDrift`, `receiptsHref`, and `theme: "light"` so the pill stays the one foreign object on a dark host. `receipts demo` serves a live three-state example at `http://localhost:8000/badge/light.html`.
|
|
138
|
+
|
|
139
|
+
## Dashboards should show their work
|
|
140
|
+
|
|
141
|
+
We think any dashboard built on verified data should let you see the data. Not a tooltip, not an export-on-request: a Data tab, right next to the charts, showing the raw verified table the pretty numbers came from. If the chain is intact and the light is green, there is no reason to hide the rows, and if you find yourself wanting to hide them, that's worth sitting with. A chart asks you to believe; a table lets you check. Green light, open table: that's the whole standard.
|
|
142
|
+
|
|
143
|
+

|
|
144
|
+
|
|
145
|
+
*Design preview: install the verification layer and your dashboard grows a Data tab. When the chain breaks, the break is localized to the column and total that no longer verify, right in the table.*
|
|
146
|
+
|
|
147
|
+
## What this proves, and what it doesn't
|
|
148
|
+
|
|
149
|
+
This proves **continuity, not correctness**. It can't tell you the data is right, but it can prove nobody changed it. The chain shows the dashboard numbers descend from the ingested export through a known sequence of code, and it locates the exact stage where a number changed unexpectedly. If the source export is itself wrong, the chain faithfully verifies wrong numbers. It is not a data-quality tool.
|
|
150
|
+
|
|
151
|
+
Also worth knowing: the signing key lives on your machine, and today that local Ed25519 keypair is the whole root of trust. Anyone holding the key can sign a fresh, internally consistent chain. External anchoring (below) is what closes that gap.
|
|
152
|
+
|
|
153
|
+
## Roadmap
|
|
154
|
+
|
|
155
|
+
- **Richer yellow taxonomy.** Yellow currently detects coverage gaps, unrecognized signing keys, and opt-in totals drift. Distinct severities and smarter drift heuristics are open questions (see `designs/01-NOTES.md`).
|
|
156
|
+
- **External anchoring.** Sigstore transparency logs or RFC 3161 timestamps, so a chain can't be silently re-signed after the fact. The attachment points are already marked `FUTURE:` in `tamper_signal/keys.py` and `tamper_signal/receipts.py`.
|
|
157
|
+
- **Verification console.** A devtools-for-data window: the receipt chain as an inspectable pipeline, an event log of verify runs, and the break pinned at the severed link.
|
|
158
|
+
|
|
159
|
+

|
|
160
|
+
|
|
161
|
+
*Design preview of the verification console: calm when green, surgical when red.*
|
|
162
|
+
|
|
163
|
+
## Relation to OpenLineage, dbt, and Great Expectations
|
|
164
|
+
|
|
165
|
+
Those tools model lineage and quality at the warehouse and orchestration layer. This is narrower and lighter: a signed, file-based receipt chain you can drop in front of an ad-hoc, vibe-coded xlsx-to-dashboard pipeline without a database, a server, or a metadata catalog. A complement for the gap before those tools are in place, not a replacement.
|
|
166
|
+
|
|
167
|
+
## Contributing
|
|
168
|
+
|
|
169
|
+
Open source under the MIT license (see `LICENSE`), designed to be added to any vibe-coded data project. The Python package is in `tamper_signal/`, tests in `tests/` (run `pytest`), examples in `examples/`, the badge in `badge/`. Issues and PRs welcome. The original Luhn hash demo lives unchanged in `legacy/` and is off the main path.
|