--- name: publish-anonymized-voter-profile description: Redact VOTER.md and publish a user-reviewed profile to votethisway.org via proof-of-work for aggregate geographic analysis — use when user wants to share a redacted profile, publish voter data, or contribute to ZIP-level aggregates license: MIT --- # publish-anonymized-voter-profile Publish a user-reviewed, redacted version of the user's `VOTER.md` to votethisway.org. Published profiles contribute to aggregate data showing what people in a given area care about. Removing direct identifiers reduces privacy risk, but free-form text and a ZIP code cannot be guaranteed anonymous. This skill is optional. The user must explicitly ask to publish. --- ## Step 1: Prepare the redacted profile Read `VOTER.md` and produce a stripped version. The goal is to keep the substance — values, priorities, tradeoffs — and remove anything that could identify the person. `VOTER.md` is the only file this publishes. If `BALLOT.md` is also in the directory — the per-item recommendations `research-ballot` writes — it is not part of this and never gets sent. It pairs a jurisdiction with how the voter intends to vote on each item, which makes it more identifying than the profile, not less. Do not read from it, do not merge it in, and do not offer to publish it. **Remove:** - Full name, initials, or any name that appears as a personal identifier - Email addresses, phone numbers - Street address, neighborhood name, or city (ZIP is the only location field accepted) - Employer, job title, or workplace - Names of family members, colleagues, or anyone mentioned personally - Any anecdote or detail specific enough to identify the author **Keep:** - ZIP code (required — used for geographic aggregation) - Issue positions and priorities - Tradeoffs and dealbreakers - Confidence levels and nuance - General local context ("housing costs in this area", not "the apartment building on my block") When in doubt, remove it. The value of publishing is in the positions, not the personal context. **Re-identification risk:** a distinctive combination of positions can itself be identifying, especially in a rural or low-population ZIP, even with direct PII removed. ZIP-level aggregate results are suppressed until five distinct profiles exist, but the submitted profile remains retrievable by its hash. Explain this limitation and let the user decide whether to proceed. Show the user the redacted text and ask them to confirm it looks right before continuing. --- ## Step 2: Solve the proof-of-work challenge Publishing requires solving a computational challenge. This is an anti-spam measure — it costs a small amount of compute, not identity. **2a. Request a challenge** ``` GET https://votethisway.org/v1/pow/challenge ``` Response fields: - `challenge_id` — identifier for this challenge - `nonce_seed` — random seed included in the hash input - `difficulty` — number of leading zero bits required - `expires_at` — solve before this time **2b. 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 3: Submit ``` POST https://votethisway.org/v1/profiles/publish Content-Type: application/json { "challenge_id": "...", "nonce": "...", "zip": "...", "profile_text": "..." } ``` The `profile_text` is hashed server-side after PoW verification. 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. - **400 whose `error` does not begin with `pow verification failed:`** — content validation, not proof-of-work. The ZIP was malformed, or the profile text still matched one of the direct-identifier patterns the server screens for; the message names which. This check runs *before* the challenge is consumed, so the challenge is untouched. Show the user the specific reason, revise the text with them, and resubmit with the **same** `challenge_id` and `nonce`. Never silently strip content just to force it through. - **400 with `pow verification failed: `** — the challenge is unusable and 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 2a. - **503 with `pow verification failed: verification temporarily unavailable`** — the 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 4: Confirm A successful response returns a `profile_hash`. Show it to the user as their receipt — they can use it to retrieve the published profile at: ``` GET https://votethisway.org/v1/profiles/{profile_hash} ``` The response also contains `created`: `true` when this canonical ZIP/profile was first stored, or `false` when an identical profile already existed. Identical submissions count once. Both are successes and both carry the same `profile_hash`; the status code tracks `created`, so a new profile returns `201` and a repeat of one already stored returns `200`. Treat only a `4xx` or `5xx` as a failure — a `200` here does not mean the publish went wrong, it means someone had already published the identical text for that ZIP. --- ## Output requirements - Never publish without explicit user confirmation after showing the redacted text and explaining that anonymity cannot be guaranteed - Never include city, street, address, or any field other than ZIP as location data - Publish the redacted `VOTER.md` and nothing else — never `BALLOT.md` or any other local file - Store the returned `profile_hash` locally if the user wants a record - If the challenge expires before you finish solving, request a new one and start step 2b again - Send `nonce` as a JSON string, never a numeric literal - On a rejected submit, read the error before retrying — a content 400 keeps the challenge, a PoW 400 requires a fresh one, and a 503 means retry the same request unchanged