Pods
What a pod stores, who can read it, how join links and guest players work, and what a logged game records.
A pod is a group of people who play together. Four tables hold one: pods for
the group, pod_members for who is in it, pod_invites for the link that lets
someone join, and pod_guests for a player with no account.
Every one of the four is readable only by the pod's own members.
Two more tables, pod_visible_deck and pod_visible_profile, hold the narrow
slice of your data that pod-mates are allowed to read. They are covered under
what sharing a pod shares.
The pod
| Column | What it holds |
|---|---|
name | The pod name. Between 1 and 60 characters after trimming. |
created_by | Who created it. Provenance only, since who administers a pod is the role on pod_members. Clears to null with the account, and the pod stays. |
created_at | When it was created. |
Membership
| Column | What it holds |
|---|---|
pod_id, user_id | The pair is the primary key, so a second join writes nothing rather than a duplicate. |
role | owner or member. Owners rename the pod, set roles, and remove people. |
joined_at | When the person joined. |
A pod always has at least one owner. Removing the last one raises an error in the database rather than being prevented by a screen, so it holds however the row is deleted.
Join links
| Column | What it holds |
|---|---|
token | 32 hex characters, generated when the row is created. This is the secret in a join URL. |
status | active or revoked. |
expires_at | Seven days after the link was created, unless set otherwise. |
invited_by | Who created the link. Clears to null with the account. |
created_at | When the link was created. |
One link admits several people, so an invite has no accepted state. A link works until it is revoked or it expires.
What a link shows before you sign in
Opening a join link names the pod and whoever created the link, to anyone holding it, signed in or not. That is deliberate: deciding whether to join a pod is deciding what to share, and nobody can decide that against a token that says nothing.
Those two fields are the whole of it. A link reveals no member list, no decks and no pod id, and it grants nothing until somebody signs in and accepts.
Revoking a link takes the pod name back with it. A revoked or expired link stops naming anything.
Guest players
| Column | What it holds |
|---|---|
display_name | The name shown for that player. Between 1 and 40 characters, and unique within the pod, ignoring case, among guests that are not archived. |
claimed_by_user_id | The account this guest turned out to be, once they and an owner both agreed. Null otherwise, and one-way once set. |
claim_requested_by | Who says this guest is them, waiting on an owner. Cleared when the claim is confirmed or withdrawn. |
archived_at | Set when a guest is retired. An archived guest stays readable, and its name becomes free for reuse. |
created_by | Who added the guest. Clears to null with the account. |
created_at | When the guest was added. |
A guest belongs to the pod rather than to a single game, so a pod collects a handful of them and the same name is reused week to week.
Claiming a guest
A guest can turn out to be somebody with an account. Claiming sets
claimed_by_user_id on the one guest row, and every seat that guest ever held
resolves to that account from then on. No seat is rewritten, which is why
forty games can move at once: seats point at the guest row rather than copying a
name.
It takes two people. The person says a guest is them, and an owner agrees. Both
halves go through database functions, because the two claim columns are not
writable by a member at all: row-level security chooses rows and never columns,
so the grant that lets a member rename a guest is narrowed to display_name and
archived_at.
Claiming is one-way. A second claim on a claimed guest is refused rather than re-pointing somebody's history, an owner cannot confirm their own claim, and one account cannot be two guests in the same pod.
Reading a seat back resolves through game_seat_players, which returns the
account each seat belongs to: the member themselves, or whoever claimed that
guest, or nobody where a guest is unclaimed.
Logged games
Three tables hold a game that was recorded rather than only tracked: games for
the game itself, game_seats for who sat where, and game_events for every
change to the table in the order it happened. A game is readable by the members
of the pod it was played in.
Naming the table in the tracker writes the game and its seats. Every tap after that is an event, sent in the background, including from a phone with no signal. Game history reads all three back.
The game
| Column | What it holds |
|---|---|
pod_id | The pod that played it. Required, and the pod cannot be deleted while any of its games exist. |
format | One of commander, standard, modern, legacy, vintage, pioneer, pauper, limited or other. |
starting_life | The life total every seat began on. |
status | live, finished or abandoned. A game that got packed up unfinished is abandoned and stays out of win rates. |
note | An optional note on the game, up to 2000 characters. |
started_at, ended_at | When it began and when it closed. A live game has no end time, and a closed one always has one. |
created_by | Who logged it. Clears to null with the account, and the game stays. |
The seats
| Column | What it holds |
|---|---|
seat_index | Which chair, counting from zero. One row per seat per game, and the key every result is written against. |
user_id or guest_id | Who sat in that chair by the end. Exactly one of the two, so every seat names somebody the pod already knows. |
deck_id | The deck played, where it is a deck in the app. Clears to null if that deck is later deleted. |
deck_name_text | The deck's name at the time. Written for every seat, so renaming a deck leaves past games as they were. |
final_life | Life when the game ended. |
placement | Finishing position, counting from one. |
eliminated_at | When that seat left the game. |
is_winner | Whether that seat won. More than one seat can win, and a game can end with none. |
The events
| Column | What it holds |
|---|---|
client_event_id | The id the device gave the event. Unique within a game, which is what makes a re-sent event land once. |
client_seq | The emitting device's own counter. It orders that device's events and nothing more. |
seat_index | The seat the event is about, or null for a change to the table, such as passing the turn. |
type | Which change it was, from the list the tracker can emit. |
payload | The change itself, as JSON. Every event is a delta, so a life change records -3 rather than a new total. |
occurred_at | When it happened, taken from the device. created_at is when it reached the server, and the two differ after a game played without signal. |
created_by | Which account sent it. |
Nobody can edit or delete an event, at any point in a game's life. A finished game accepts no further events.
People trade chairs, and life, damage and counters stay with the chair rather than following the person. So who is in a chair is settled when the game ends, from the seating the events finish on, not from the order the table was set up in. A game logged before that was true reads its results against the wrong people, and its page offers to put it right.
Who can read and write what
A person outside a pod reads zero rows from all four tables. The anon role
holds no privilege on any of them, so a request without a session is refused
rather than answered with an empty list.
Inside a pod, any member can read everything, add a guest, and create or revoke a join link. Owners additionally rename the pod, change roles, and remove people. Anyone can remove their own membership.
Two writes are refused for everybody, a pod's own owner included:
- Nothing writes a row into
pod_membersdirectly. Being in a pod has to mean that the person agreed to be in it, and an owner who could insert a user id could put anyone in a pod without asking. - Nothing writes a row into
podsdirectly. Creating a pod has to create its first owner in the same transaction, or a failure between the two writes leaves a pod that nobody can administer.
Both rules are enforced by the absence of a policy and by the absence of a grant, so neither depends on the other being right.
Games carry two more rules of the same kind:
- A game can be corrected and cannot be moved. Any member can fix a result: the winner, the placements, the deck a seat was played with, the format, the note. Nobody can change which pod a game belongs to, or which person sat in a seat, because the pod is what decides who may read the game. This one is a column grant rather than a policy, since row-level security chooses rows and never columns.
- A game is deleted by whoever logged it, or by a pod owner. Its seats and its events go with it. The guests who played keep their rows, because a guest belongs to the pod rather than to one game.
What sharing a pod shares
Two tables answer this, and they are the only way one account reads another's data here. They hold copies of a few fields rather than reaching into your decks, so what follows is everything those tables contain.
Your pod-mates see, for you: your username, and your display name and avatar.
Your pod-mates see, for each of your decks: the deck name, the format, the colors, the commander's name and the commander's card image.
Your pod-mates do not see: the card list, the deck overview, your deck
notes, the budget, the meta notes, or your email. Joining a pod grants no access
at all to deck_cards, and a pod-mate asking for the cards in a deck they can
name still reads zero rows.
The deck name is the label and the commander is secondary, because one person can have two decks with the same commander and a commander alone cannot tell them apart.
Three things worth knowing
Marking a deck private does not hide its name from your pod. Private governs the deck itself, which stays unreadable: the card list, the overview and the notes are closed to a pod-mate whatever the setting. What a pod shares is the name and the commander, so that somebody can put your deck in a seat. A deck you delete leaves the list straight away.
Your display name, avatar and bio are already public, to anyone, signed in or not. That is how profiles work throughout the app and it is not something a pod changes. Your username is the part a pod makes visible.
Leaving a pod ends it. Remove yourself, or get removed, and the decks and names stop resolving on the next read. Nothing is cached on anyone else's account.
How the copies stay current
pod_visible_deck holds a deck's name, format, colors and which card commands
it. pod_visible_profile holds a username. Both follow the original the moment
you change it, and a deck you delete leaves straight away.
The commander's name and picture are not copied. They come from the card data every visitor can already read, so a pod learns which commander rather than holding a second copy of the card.