Permissioned Data Shapes: Polls

A poll is a space, a ballot is an address, and a vote record carries almost nothing because the address carries everything. This shape uses read_self as a ballot screen: voters cast and see their own votes, and nobody's client can browse anyone else's.

This is the fifth post in a series applying atproto's permissioned data proposal to the shapes I expect people to actually build, after self-only bookmarks , private events , community-moderated content , and forums . Polls keep coming up in the atmosphere, and the hard part was never the poll record. It's where votes can live. Every public design leaks ballots by construction, because a public vote record is a public vote. Permissioned spaces finally give votes somewhere else to be. Same disclaimer as always: the proposal is a draft, and the details underneath this post will move.

The Shape of a Poll

A poll space is anchored on the poll author's DID, with a TID for its skey:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.polls.collection/3mr3uxyi6de7t

The member list is the electorate: whoever the author adds can vote. Inside the space, the author writes the poll itself into their own permissioned repo:

{
"$type": "community.lexicon.polls.poll",
"title": "West Coast or Best Coast?",
"options": [
{
"$type": "community.lexicon.polls.option",
"value": "Best"
},
{
"$type": "community.lexicon.polls.option",
"value": "East"
}
],
"createdAt": "2026-07-20T14:10:00.000Z",
"closesAt": "2026-07-24T00:00:00.000Z"
}

That record lands at:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.polls.collection/3mr3uxyi6de7t/did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.polls.poll/3mr3uzyje3q4a

One space can hold one poll or a batch of them, since a space is really an electorate, and the same set of people often gets asked more than one question.

Votes Are Addresses

Here's the part of this shape I like most. When @mattie.thegem.city votes, her ballot is a record in her own permissioned repo within the space, and its record key is the poll's record key:

{
"$type": "community.lexicon.polls.vote",
"value": "Best",
"createdAt": "2026-07-20T15:02:00.000Z"
}

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2 space authority (the poll author, me)
/space literal marker
/community.lexicon.polls.collection space type
/3mr3uxyi6de7t skey (the electorate)
/did:plc:nysigjs52ta6f2l7fdnevadv author (@mattie.thegem.city)
/community.lexicon.polls.vote collection
/3mr3uzyje3q4a rkey (the poll's rkey)

The vote record contains a value and a timestamp and nothing else, because the address already says everything the tally needs. The space names the electorate. The author segment names the voter. And the rkey names the poll, because a vote's record key is required to equal the record key of the poll it answers.

That rkey mirroring is doing more than linking. A repo has exactly one record per collection and key, so each identity gets exactly one community.lexicon.polls.vote at 3mr3uzyje3q4a, and one vote per voter per poll is enforced by the keyspace instead of by application logic. Changing your vote is a putRecord to the same address. Retracting it is a deleteRecord. Double voting isn't caught; it's unrepresentable.

Declaring the Types

The vocabulary is a community.lexicon.polls.* strawman in the chartering pattern this series keeps using:

{
"lexicon": 1,
"id": "community.lexicon.polls.collection",
"defs": {
"main": {
"type": "space",
"description": "A collection of polls and their votes",
"key": "any",
"name": "Polls",
"collections": [
"community.lexicon.polls.poll",
"community.lexicon.polls.vote"
]
}
}
}

{
"lexicon": 1,
"id": "community.lexicon.polls.poll",
"defs": {
"main": {
"type": "record",
"description": "A poll, authored by the space authority",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "options", "createdAt"],
"properties": {
"title": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 },
"options": {
"type": "array",
"items": { "type": "ref", "ref": "community.lexicon.polls.option" },
"minLength": 2,
"maxLength": 10
},
"createdAt": { "type": "string", "format": "datetime" },
"closesAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

{
"lexicon": 1,
"id": "community.lexicon.polls.option",
"defs": {
"main": {
"type": "object",
"description": "A single poll option",
"required": ["value"],
"properties": {
"value": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }
}
}
}
}

{
"lexicon": 1,
"id": "community.lexicon.polls.vote",
"defs": {
"main": {
"type": "record",
"description": "A vote on a poll. The record key must equal the poll record's key.",
"key": "any",
"record": {
"type": "object",
"required": ["value", "createdAt"],
"properties": {
"value": { "type": "string", "maxLength": 640, "maxGraphemes": 64 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

The option object gets its own NSID rather than an inline def so that richer poll shapes later, ranked options, images, whatever comes, can reuse it without touching the vote record at all.

The Ballot Screen

The interesting access design in this shape isn't who's in the space. It's what the voters' own clients are allowed to do there. The voting app view asks each voter for exactly this grant:

space:community.lexicon.polls.collection?authority=*&action=create&action=update&action=read_self

Create and update cover casting a ballot and changing it, since writes go through the voter's own PDS with their own session and never need a space credential. And read_self is the whole reading story: the client can read back its user's own votes and nothing else. A read_self grant doesn't include getDelegationToken, so a client holding it cannot even begin the credential flow that space-wide reads require. There is no sequence of calls by which the voting client, acting for @mattie.thegem.city , ends up looking at my ballot. The grant is the ballot screen.

That closes the front door, and the space config closes the side door:

{
"config": {
"policy": "member-list",
"appAccess": {
"$type": "com.atproto.simplespace.defs#allowList",
"allowed": ["https://ballotbox.example/oauth-client-metadata.json"]
}
}
}

A voter is on the member list, so a full-read grant to some other client would let them mint a credential and walk every repo in the space, including everyone's votes. The allowlist takes that path away: only the voting app view's attested client identity can exchange a delegation for a space credential. Between the two, voters see the poll and their own ballot, and that's all any voter-facing surface can show.

Tallying still has to happen, and it happens where the full credential legitimately lives. The app view syncs the space through the author's session, walks the writer set, and counts. Eligibility is reader-enforced one more time: anyone can write a vote-shaped record at the right address on their own PDS, and the tally counts only votes from repos whose owners are on the member list, the same way every shape in this series has ignored records from outside the perimeter. A useful property falls out of the tally being derived rather than declared: anyone else with legitimate full access, which is to say the poll author, can re-run the count. The app view reports results, but it can't quietly invent them, because the ballots it counted are checkable by the one party entitled to check.

The Permission Surface

# the voting app acting for a voter: cast, change, and read only your own ballots
space:community.lexicon.polls.collection?authority=*&action=create&action=update&action=read_self

# the poll author: create poll spaces, write polls, manage the electorate
space:community.lexicon.polls.collection?manage=create&manage=update

# a voter's export tool: your own ballots across every poll you've voted in
space:community.lexicon.polls.collection?authority=*&action=read_self&collection=*

The author's grant is the bare personal package plus the manage verbs, which covers creating spaces, writing poll records, and running addMember as the electorate forms. The voter grants both default authority to any, since the polls you vote in live under other people's authorities. And the export grant means every ballot you've ever cast is yours to walk away with, which by now is the series' standing promise.

Trade-offs

Votes here are private from other voters, and that's all. The poll author can read every ballot, the tallying app view can read every ballot, and each voter's own PDS holds their own.

It is not a cryptographic secret ballot. There's no mixing or blind tallying, and nothing about it resists coercion, so it's the wrong tool for anything where someone might demand you prove how you voted. For deciding where the meetup goes, or taking a community's temperature, it's the right size.

The partitioned model means your PDS stores only your vote, so no single host in the system accumulates the ballot box except the services explicitly entitled to tally it.

Permissioned commits are deniable, so a leaked ballot carries no transferable proof that you cast it, a better failure mode for votes than for almost anything else in this series.

The remaining metadata is the usual kind: the member list is the electorate, the writer set is turnout, and both are visible to anyone holding a full credential.