Theta runs social infrastructure for Danish civil society — residents’
associations, volunteer groups, choirs — and most of them are trying to leave
Facebook. This is a working test of whether AT Protocol Spaces can hold a closed
group without making Theta the next place they are stuck.
Unfamiliar vocabulary below? The glossary defines every term
twice — once as AT Protocol means it, once as it actually exists on this
machine.
What is running here
Two self-hosted PDS instances and three independent applications,
all live. Every group is a space: an access boundary
whose authority is the group’s own
DID, not a person’s and not Theta’s. Each member keeps
their own posts in their own repository on their own host. An
application reads a group by pulling those repositories directly, one by one, with a
credential the group issued it.
The interesting part is not that this works. It is what happens when a group wants out.
The shape of it
Nothing in the middle. No relay, no shared database, no API between the three
applications — each one holds a credential the group issued and pulls the same
member repositories for itself.
The three applications
Theta
Theta’s group app. Create a group, admit members, post, administer.
Everything here is synthetic. Anna, Bodil, Carla, David and Emil are inventions, and
so is Havneparkens Vejlaug. Make your own account and your own groups — but the
sandbox gets wiped, and the identity you create is a real entry in a public registry
that does not, so post nothing you would mind standing behind.
Walk it yourself
The six steps run in order — each one depends on the state the last one left
behind. You need an account: Theta’s front page will hand you an invite code,
and you choose your password on the server itself, never in an app.
01
Sign in with an account nobody here owns
You authenticate at your own personal data server. Theta never sees a password,
and the consent screen is served by pds1, not by us.
Your post is written into your repository, inside the group’s
space. Not to Theta’s database — Theta only keeps an
index it built by asking your host for it, and never
touches the public stream at all.
03
Sign in to Makker with the same account
A different application, run by a different operator, forked from something that
was never about groups. Same identity, no account to create.
A wall is a space too, with its owner as the authority. It admits people who have
said they are friends — both of them, each in their own repository.
05
See which groups you have in common
Makker shows the Theta groups you and your friend both belong to. It reads them
from the groups’ own records, with the permission you granted it. It does
not ask Theta, and there is no arrangement between the two.
06
Watch the group leave
The group signs a key rotation with a key only it
holds, removing Theta from its own authority. It ejects Theta’s indexer, admits Tavlen’s, and carries
on — same members, same history, same URIs. Theta cannot undo it and was
never asked.
A group leaving takes away Theta’s ability to keep reading it. It does not reach
into what Theta already synced. That copy exists, and erasing it is a promise an
operator makes and keeps — not something the protocol enforces. Any group being
told otherwise is being told something untrue.
Glossary
Each term twice: what it means in AT Protocol generally, and what it is in this
deployment specifically. The second half is the one that tends to be missing.
Identity
DIDdecentralised identifier
A permanent name for a person, a group or a service that belongs to no company. It resolves to a small public document listing the keys allowed to sign for it and the server currently holding its data — so the data can move without the name changing.
in this POCEvery account and every group has one. Havneparkens Vejlaug is did:plc:klqln2vi3fsiv3ahqnioxfeu, and stays that when the group changes operator in step 06. The name surviving the move is what makes the move possible.
Handlethe readable name
The human-readable name pointing at a DID, proved by controlling a domain. It can be changed or lost; the DID underneath cannot.
in this POCanna.pds1.lab.raakode.dk. Groups have handles too — havneparken.pds1.lab.raakode.dk — which is why a closed group’s existence and chosen name are public even when its membership is not. A group that must not be known to exist needs a name that says nothing.
PLC directorydid:plc
The public registry answering “which keys and which server does this DID have now?”. Entries are append-only and permanent.
in this POCNot ours — the shared public one. An account created in this sandbox writes an entry that outlives the sandbox, which is why the sign-up page says so before you make one.
Rotation keywho may rewrite the DID
A key entitled to change a DID’s document — its signing key, or the server it points at. A DID lists several in priority order, and an earlier key overrules a later one.
in this POCEach group is created with a key the group holds at the front of that list, ahead of Theta’s. Step 06 is that key being used. It can only be arranged at creation: a group set up without one can never leave, and nothing can be done about it afterwards.
Where the data sits
PDSpersonal data server
The server holding one person’s repository: it authenticates them, signs their writes, and serves their data. Anyone can run one, and moving between them is meant to be ordinary.
in this POCTwo of them, pds1.lab.raakode.dk and pds2.lab.raakode.dk, each a container on this machine. Emil lives on pds2 deliberately, so that “a member on a host the operator does not run” is tested rather than assumed.
Repositoryyour own store
One account’s records, kept as a signed log. Every entry is signed by its author, so a copy can be checked without trusting whoever handed it over.
in this POCYour posts live in yours. Theta holds no copy of record — only an index it built by asking your host, which it can lose access to.
Recordcollection · rkey
One item: a post, a profile, a membership. Its collection is the type it is filed under; its rkey is its name within that type.
in this POCdk.raakode.lab.groups.post is a group post. dk.raakode.lab.groups.membership is somebody’s role, written by the group into its own space so that the roster travels with the group rather than sitting in Theta’s database.
AT-URIat://…
A record’s address: at://<did>/<collection>/<rkey>.
in this POCInside a space the address carries the space in the middle: at://<group did>/space/<type>/<skey>/<author did>/<collection>/<rkey>. Both DIDs are in there, and that is the arrangement in one line — the group is the authority, the member still owns the record.
Blobthe attached bytes
A binary hung off a record — an avatar, a photograph. It is stored beside the repository that refers to it and fetched by DID and hash.
in this POCYour profile picture is served from your own host. Makker displays it without asking Theta, because it was never Theta’s to serve.
The access layer — Spaces
Spacethe new part
A named boundary around records that are not public. The data still sits in each member’s own repository; what changes is that reading it requires a credential issued by the space’s authority. Nothing is copied into a shared store.
in this POCOne space per group, and one per Makker wall. A group’s is at://<group did>/space/dk.raakode.lab.groups.space/havneparken.
Space authoritywho decides
The DID that decides who may read and write a space. It is a party in the protocol rather than a row in somebody’s database.
in this POCThe group’s own DID — not the chair’s, not Theta’s. That one choice is what the whole spike exists to test, and everything else follows from it.
skeyspace key
A space’s name within its authority, since one DID can hold several.
in this POChavneparken. Chosen rather than generated, which is what lets the seed script run twice and produce the same space.
Delegation tokenleg one of two
A short-lived, single-use ticket minted by your own server, saying that you are really you and are asking about this space.
in this POCYour PDS issues it; the group’s host decides what it is worth. Your server vouches for who you are and never for what you may see.
Space credentialleg two
What the authority hands back in exchange — the thing that actually opens the door. It is bound to a key the holder must prove they still have, so an intercepted copy is worthless.
in this POCTwo hours, reused across every host in the space, renewed five minutes early. An app holds one per space and no standing beyond it.
Access policymember list · public · managing app
How an authority decides. The alpha offers three: check a member list, allow everyone, or ask a nominated application.
in this POCGroups use the member list. Makker’s walls ask the application, which answers from the friendship records. Worth knowing: “public” here means any atproto account, not anyone with a browser — a genuinely open noticeboard is not currently expressible.
Writer setwho has written
The repositories a space knows have written into it — the list a syncing application walks.
in this POCEjecting someone stops them reading and does not remove them from this. What they wrote while a member stays part of the group’s record, in their own repository, and no one can delete it for them.
Applications
Relay & firehosethe public stream
In ordinary atproto, the service that aggregates every public repository into a single stream. It is how a public post reaches the rest of the network.
in this POCNot used here, and that is the argument. A write into a space produces no firehose event at all — measured: it leaves the author’s public repository byte-identical, same commit and all. Group content never enters the public network rather than entering it and being filtered.
AppViewthe thing that builds a view
The service turning raw repository data into something an app can show — a timeline, a profile, a count. Normally it reads the firehose.
in this POCThere is no shared one. Each of the three applications is its own, building its view by pulling member repositories one at a time. Two apps can therefore disagree about the same group, and nothing prevents it.
Indexerthe syncing half
The part of an application that does that pulling and keeps the result.
in this POCA separate container per operator — theta-indexer, tavlen-indexer — each with a private SQLite file. It is admitted to a group as an ordinary member, which is worth saying plainly: Theta’s index can see the roster because the group let it in, and the group can throw it out.
Op logand cursor
A repository’s changes as an ordered list, so a syncer asks “what has happened since here?” instead of re-reading everything. A deletion is an entry like any other, which is how deletions travel.
in this POCAsked per member, per group. Ours polls; the protocol also offers a push, which we could not use because our indexer has no published DID document of its own to be notified at. That gap is the whole difference between the latency we measured and the latency the protocol costs.
Schemas and permissions
LexiconNSID
A published schema for a record type, named in reverse-domain form by whoever owns that domain. It is how two applications agree on a shape without ever speaking to each other.
in this POCdk.raakode.lab.groups.* and dk.raakode.lab.makker.*, published as records and found through a DNS entry at _lexicon.groups.lab.raakode.dk. Makker reads Theta’s group schema with no agreement between the two operators — that is step 05 working.
Scopepermission set
What an application asked for and you granted when you signed in. A space scope names a space type and what may be done inside it.
in this POCMakker holds space:dk.raakode.lab.groups.space?authority=*&action=read — see your groups, change nothing. The screen that shows you this is served by your own server, not by the application asking.