VoteThisWay

publish-digital-yard-sign

Publish a small, curated set of the user's ballot picks — what they're voting for and why — as a public "digital yard sign" at an unguessable link with a QR code, so they can share it the way a physical yard sign advertises a cause. Unlike publish-anonymized-voter-profile, this is not about aggregate statistics: it's one person's public advocacy for the specific choices they care about.

This skill is optional. The user must explicitly ask to publish.

---

Step 1: Name the election

Ask which election these picks are for and record it as a short label — the election type and date, e.g. General Election — November 3, 2026 or Primary — March 3, 2026. Max 80 characters.

This matters more than it looks. The whole point is that a stranger scans the QR code weeks later. Without a date, "Yes on Measure B" is unreadable a year on — and there is no way to edit it in.

No location in this label. Placards carry no ZIP, city, or county anywhere by design, and a free-text field is exactly where one creeps back in. General Election — November 3, 2026 is right; Alameda County General Election is not. The server rejects a ZIP code in this field outright, but it cannot detect a city or county name — that's on you.

The field is optional and can be omitted, but don't skip it silently. If the user would rather not say, publish without it and tell them the sign won't be dated.

---

Step 2: Narrow down — this is not the whole ballot

Assumes the user has already researched their ballot (e.g. via research-ballot) and has positions they're confident in.

Do not default to publishing everything they researched. Ask which specific races or measures they actually want to publicly advocate for — frame it as "which of these do you feel strongly enough about to put on a yard sign?" A placard is a curated highlight reel of their highest-conviction picks, not a full ballot dump. Most placards should have a handful of items, not dozens. The server enforces a hard ceiling of 20 items, but treat that as a limit, not a target.

For each item the user selects, collect exactly three things:

words) when the formal name alone wouldn't mean anything to a stranger — e.g. "Prop 40 — the wealth tax", not just "Prop 40". Skip the description when the name is already self-explanatory ("City Council District 4" doesn't need one).

The server rejects an item or position over 120 characters and a reason over 280, so keep them short rather than discovering the limit on submit.

Redaction guidance for the reason: keep it issue-focused. Remove anything that could identify the person — no names, addresses, employer, or personal anecdotes tied to identity ("as a teacher at Lincoln Elementary…"). "Lower property taxes matter to me" is fine; identifying details are not. This placard carries no ZIP code or location field at all — don't add one.

Show the assembled list back to the user verbatim and get explicit confirmation before continuing. State plainly: publishing is public, unlisted (not searchable anywhere on the site — no listing, no search, direct-link-only), not editable — there are no accounts on this site, so there is no way to change it afterwards — and deleted after 30 days. On the 30th day the placard is removed outright: the picks, the reasoning and the link all go at once, and anyone scanning a printed copy after that gets a "page not found, or this sign expired" page. Tell them the 30-day limit before they confirm, not after.

---

Step 3: Solve the proof-of-work challenge

Same mechanism as publish-anonymized-voter-profile — an anti-spam measure, not an identity check.

3a. Request a challenge

` GET https://votethisway.org/v1/pow/challenge `

Response fields:

3b. Solve the challenge

Count up from 0, and for each candidate hash its decimal string form:

` SHA-256("{challenge_id}|{nonce_seed}|{nonce}") `

Stop at the first nonce whose digest has at least difficulty leading zero bits.

The nonce is a string, not a number, everywhere it appears. It is interpolated into the preimage above as text, and it goes into the JSON body as a JSON string — "nonce": "3502981", never "nonce": 3502981. A numeric literal there fails to decode and comes back as 400 {"error": "invalid JSON"}, which does not name the field that caused it. Whatever string you hash must be byte-identical to the string you send, since the server recomputes the digest from the JSON value verbatim. Max 64 characters.

Leading zero _bits_ on the raw 32-byte digest — not leading zero hex characters. The two tests coincide only when difficulty is a multiple of 4, and the current default of 22 is not: 22 bits is five zero hex characters plus the top two bits of the sixth. Counting hex characters there leaves you four times too lenient at five and four times too strict at six. Take the digest as bytes, most significant bit first, and require the first difficulty bits to be zero.

Check your bit test against this vector before spending real work on it:

` SHA-256("test-challenge|test-seed|4070840") = 0000039ff916a84862007536eda9dbf68754f8c0b2de759e2dbb688cd1622091 `

That digest has exactly 22 leading zero bits, so it solves difficulty 22 — even though only five of its hex characters are zero.

Write a script for the search; do not attempt it by reasoning. The expected number of hashes is 2^difficulty, so the current default of 22 averages around 4 million SHA-256s — a second or two of real compute, and nothing that can be shortcut by thinking about it. The count swings widely run to run, so a solve taking several times the average is ordinary luck rather than a bug. Read difficulty from the challenge response every time instead of hardcoding it; the server tunes it.

To confirm a solver before wiring it to a publish, POST the challenge_id and nonce to https://votethisway.org/v1/pow/verify. It answers {"ok": true, "reason": "verified"} and consumes nothing, so the challenge is still good for the publish that follows. This is a debugging aid, not a required step — don't call it on every publish.

---

Step 4: Submit

` POST https://votethisway.org/v1/placards/publish Content-Type: application/json

{ "challenge_id": "...", "nonce": "...", "election": "General Election — November 3, 2026", "items": [ {"item": "...", "position": "...", "reason": "..."} ] } `

election is optional — omit it or send "" if the user declined to name one.

Send exactly the fields above. The endpoint rejects unknown ones, and reports that as 400 {"error": "invalid JSON"} — the same message a malformed body gets — so a field you added to be helpful is indistinguishable from broken JSON.

If the submit is rejected

Errors come back as {"error": "..."}. Which error you got decides whether the challenge you just solved is still usable, so read it before doing anything else.

validation, not proof-of-work. Most likely a field that looks like it contains PII, a ZIP code in the election label, an over-length field, or too many items; the message names which item and which limit. This check runs *before* the challenge is consumed, so the challenge is untouched. Show the user the specific reason, let them revise, and resubmit with the same challenge_id and nonce. Never silently strip or invent content just to force a retry.

retrying it will never succeed. The reason is one of challenge expired, unknown challenge, invalid nonce, or challenge already used. None of these recover on their own — challenges are single-use, and an expired or already-spent one never becomes valid again. Request a fresh challenge and solve it again from Step 3a.

server could not determine whether the challenge had already been spent. That is a fault on our side, not a problem with your solution: the challenge is still valid and still unconsumed. Wait a few seconds and retry the identical request. Do not solve a new challenge — it costs seconds of CPU to replace something that was never broken.

Any other 5xx carries the same advice as the 503. The publish runs in a single transaction, so a server-side failure rolls back the challenge consumption along with everything else, leaving your challenge good for a retry.

---

Step 5: Show the result

A successful response (201) returns:

Give the user the url, prominently and as plain text they can copy. That link is the whole result: the page it opens shows their picks, a scannable QR code, and a "Print this sign" button that produces a one-page sign to tape in a window or hand to a neighbor.

Tell them what they can do with it — share the link, or open it and print the sign — and remind them it is public, unlisted, and deleted 30 days from now, so they know how long a printed copy stays useful.

---

Output requirements

explaining that publishing is public, unlisted, not editable, and deleted after 30 days

includes the election label

QR code later

retrieve it later

challenge, a PoW 400 requires a fresh one, and a 503 means retry the same request unchanged

Markdown source