Running a poll under ProofPoll
This guide is for a pollster. It covers what you run, when, and what you hand to readers at the end. The protocol itself is in SPEC.md. To watch the whole thing work first, run node examples/demo.mjs (or proofpoll-demo once installed). It runs one complete poll with a pollster and two witnesses in under a minute.
Two tools, both in sdk/bin/:
proofpoll-- yours. It signs each step of the poll and packs the bundle.proofpoll-witness-- your witnesses run this, not you. It countersigns your steps with the witness's own clock.
Node 20 or later. From sdk/, run npm install once.
The idea in one paragraph#
Every step of the poll becomes a signed record that names the record before it, so the steps form a chain. Each record goes to at least two independent witnesses, who countersign it with their own clocks. Their clocks are the only time a reader trusts. That is how a reader knows your design was fixed before fieldwork, and that each day's responses were committed that day. You cannot rewrite a step later without the witnesses' signatures exposing it. At the end you publish one folder, the bundle, and anyone can check it offline.
Once: set up#
proofpoll init --domain yourpolling.org
This makes a workspace in the current directory (or --dir, or $PROOFPOLL_DIR):
private/pollster.pem-- your signing key. Back it up and never share it. The workspace's.gitignorealready excludes it.public/.well-known/mir.json-- publish this athttps://yourpolling.org/.well-known/mir.json. It is how readers find your public key.
Then add each witness. They send you their mir.json, and you send them yours:
proofpoll witness add witness-a.org witness-a-mir.json
proofpoll witness add witness-b.org witness-b-mir.json
You need at least two witnesses, and they must be independent of you and of each other. Natural candidates are poll archives, university survey centers and newsrooms.
Each poll, in order#
1. Register, before any fieldwork. You commit six documents: questionnaire, population, sampling plan, weighting spec, disclosure policy and sponsor. Any text format works. What is committed is their exact bytes.
proofpoll register city-2027-04 \
--questionnaire questionnaire.md --population population.md \
--sampling-plan sampling-plan.md --weighting-spec weighting-spec.md \
--disclosure-policy disclosure-policy.md --sponsor sponsor.md \
--product sample --field-open 2027-04-02T00:00:00Z --field-close 2027-04-04T00:00:00Z --level L2
--product open is for opt-in polls, where results are framed "of those who participated". --product sample is for recruited samples, where weighted estimates are permitted. --level is how much you will disclose (see below), and the default is L2.
Send the requests to your witnesses now, and wait for both to come back before fieldwork starts. A poll whose registration was witnessed after its first responses reports pre-registered: no, permanently. If you try to commit a batch first, batch warns you.
2. Commit responses at least once a day during fieldwork, excluded responses included:
proofpoll batch city-2027-04 day1.csv --from 2027-04-02T00:00:00Z --to 2027-04-02T23:59:59Z
A batch whose witnesses countersigned it more than 48 hours after its --to is reported as late-committed.
3. Close, analyze, publish:
proofpoll close city-2027-04
proofpoll analyze city-2027-04 --output weighted-results.json
proofpoll publish city-2027-04 --report report.md
close sums the batches. analyze computes the raw tally from your committed responses and records your weighted output file. publish records the report and ties it to that analysis.
If something changes:
proofpoll amend city-2027-04 --field weightingSpec --document weighting-v2.md --reason why.md
proofpoll cancel city-2027-04 --reason field_failure --explanation what-happened.md
proofpoll correct city-2027-04 --output fixed.json --report fixed.md --reason why.md
Amending is always allowed, and readers see exactly when it happened. A change to the weighting after fieldwork began is shown prominently, never hidden. Cancelling is legitimate and visible. What you cannot do is make a registered poll disappear: your registrations form their own chain, and a gap in it is reported.
Witnessing, after every step#
Every command that signs something prints the request files it wrote into requests/:
signed fieldwork.batch, seq 1
send to every witness now:
requests/poll-city-2027-04-000001.json
Send each one to every witness promptly. Each witness runs:
proofpoll-witness sign requests/ # countersigns, in order
A witness:
- checks your signature,
- checks that the step links to the last one it countersigned,
- stamps its own clock,
- and refuses to countersign two different versions of the same step.
Sending the whole folder again is safe, because steps a witness has already signed come back unchanged. The witness returns checkpoint files. Import them:
proofpoll checkpoint witness-a-checkpoints/ witness-b-checkpoints/
proofpoll status city-2027-04 # anything still NEEDS witnesses?
checkpoint refuses anything it cannot match to a step you actually signed. If it says a witness "countersigned a different" step, stop and find out what that witness was shown.
Right now the requests and checkpoints travel however you like (email, a shared folder). A witness service with an API is not built yet.
Publishing the bundle#
proofpoll pack city-2027-04 bundle/
This writes the folder you publish next to the poll, and runs the same verifier a reader will, so you see the reader's report before they do. It exits non-zero if anything fails.
What the bundle holds depends on the level you committed to:
| Level | Adds | A reader can check |
|---|---|---|
| L0 | signed steps and checkpoints | order, timing, counts, gaps |
| L1 | your six documents, results and report | the design you ran is the design you registered |
| L2 | every response, salted | recompute every batch and the raw tally |
L3 (publishing analysis code a reader can rerun) is not supported yet.
Response files#
CSV, with a header row:
rid,status,q1,q2
r-0001,accepted,yes,4
r-0003,excluded:attention,yes,5
r-0008,accepted,no,
ridis an opaque per-response id. Never a name, email address or panel member id that identifies a person. The tool refuses anything that looks like an email address.statusisacceptedorexcluded:{reason}. Reasons use lowercase letters, digits and_, such asattention,speedingorduplicate.- Every other column is a question. Leave a cell blank when the question went unanswered. Plain integers become numbers, and anything else stays text (
05stays text). - JSON Lines also works: one
{"rid", "status", "answers": {...}}object per line.
The tool adds a random salt to every response. That is what stops "yes or no" answers being guessed back from published hashes, so a salt is never supplied by you and never reused. Each rid may appear once per poll.
What a verified bundle does and does not mean#
A verified bundle shows that your record was not altered, that each step was committed when the witnesses say, and that the numbers add up. It does not show that your sample was representative, that respondents were real people, or that you have no unregistered polls. The report says so on every run, in a block that cannot be turned off.