Database schema
Verified against v0.10.0 · internal/db/migrations, internal/db/migrate.go, internal/store/presence.go, internal/poker/routes.go, internal/api/custody/store.go, internal/plugin, internal/store/users.go, internal/standup/schedule.go, internal/standup/webhook.go, internal/standup/ics.go, internal/standup/commitments.go, internal/standup/mentions.go, internal/standup/away.go, internal/standup/trend.go, internal/store/standup_trend.go, internal/store/embed.go
Forty-three migrations, embedded in the binary and applied at boot. Postgres only — there is no second driver and no plan for one.
Migrations
Section titled “Migrations”| 0001_extensions | pgcrypto | The only extension Parley needs, for gen_random_uuid() |
|---|---|---|
| 0002_identity | users, session_tokens | |
| 0003_spaces | spaces, members | |
| 0004_sessions | sessions | |
| 0005_poker | stories, votes | Also adds sessions.current_story_id |
| 0006_standup | standup_entries | Also adds sessions.current_speaker_id and speaker_started_at |
| 0007_story_ref | stories.ref | Ticket reference, ≤40 chars |
| 0008_space_passcode | spaces.passcode | Empty string means an open space |
| 0009_federated_identity | users.issuer, users.subject | Behind a partial unique index |
| 0010_session_kinds | session_kinds | Replaces the sessions.kind check constraint with a foreign key |
| 0015_member_roles | members.role | owner or member. Backfills the space creator — or, where none was recorded, the most recently active member — as owner |
| 0016_standup_ready | standup_entries.ready | Advisory gathering-phase signal. Constant default, so no backfill and no table rewrite |
| 0017_avatar | users.avatar_icon, users.avatar_accessory | Chosen avatar. Constant empty default, so no backfill and no table rewrite. avatar_accessory is unwritten and unread since v0.5.1 and awaits a drop |
| 0018_session_links | session_links, users.link_id | Signed links. users.link_id is on delete set null, never cascade — see below |
| 0023_org_custody | spaces.archived_at, org_audit_log | Org custody. Neither audit foreign key cascades, so the record outlives what it names — see below |
| 0024_org_members_user_idx | org_members (user_id) where revoked_at is null | Partial index for "which orgs is this user in". Regular create index — migrate.go wraps each file in a transaction, so concurrently is unavailable |
| 0028_standup_commitments | standup_commitments | New table, so no backfill. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0029_commitment_carry_idempotency | standup_commitments.carried_session_id | Which standup last moved the carry count. Nullable with no backfill — an existing row reads as "not answered in any session yet", so its first answer counts normally. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0030_commitment_origin_survives | standup_commitments.opened_session_id | Makes the column nullable and on delete set null, so deleting the room a commitment was opened in no longer deletes the commitment. Rewrites delete behaviour on rows that already exist — nothing is backfilled, but an existing row that used to vanish with its origin room now survives with a null. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0031_plugins | plugin_installs, plugin_grants, plugin_secrets, plugin_kv, plugin_events, plugin_deliveries, plugin_jobs | The plugin foundations. All new tables, so no backfill and nothing existing is rewritten. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0034_plugin_installs_org | plugin_installs.org_id, unique (org_id, name) | Installs belong to an org. Added nullable, backfilled to the default org, then set not null — and deliberately with no retained DEFAULT, unlike spaces.org_id in 0021: an install is a rare deliberate operator action rather than something users create constantly, so a write that does not name an org must fail rather than land silently in the default org. Replaces the instance-wide unique (name), so two orgs can install the same plugin as two separate installs |
| 0035_session_kinds_org | session_kinds.org_id, session_kinds.actions | A session kind belongs to an org, or to the instance. Nullable, and null means instance-wide: the two core kinds belong to no org and stay available to every one of them, which is the whole backfill. A plugin-provided row carries the installing org, so one org's ceremony is not on offer to the instance and its rooms cannot block another org's uninstall. actions carries the dispatch table a manifest declared, so it survives a restart |
| 0036_session_tokens_sweep_idx | session_tokens (last_used_at), (created_at), (expires_at) where expires_at is not null | Btree indexes for the hourly session-token sweep. Regular create index — migrate.go wraps each file in a transaction, so concurrently is unavailable. last_used_at is rewritten on every session touch, so this index prevents HOT updates of that column; created_at and expires_at are immutable after insert. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0037_notification_sounds | users.notification_sounds, sessions.poker_round_version | Both use constant defaults, so existing identities remain muted and existing poker sessions begin at round version 0 |
| 0038_standup_schedules | standup_schedules, standup_schedule_slots | Scheduled async standups. New tables, so no backfill. Pins the minimum image version: migrations are forward-only, and an older image refuses to boot against a newer schema |
| 0039_standup_webhooks | standup_webhooks, standup_webhook_deliveries | Standup webhooks and their delivery outbox. New tables, so no backfill. Pins the minimum image version, as above |
| 0040_ics_feed_tokens | ics_feed_tokens | One personal calendar token per user. The token is stored as a sha256 digest plus an 8-character lookup prefix. New table, so no backfill. Pins the minimum image version, as above |
| 0041_standup_follow_through | standup_commitments.closed_reason, standup_mentions | closed_reason is landed or dropped. Every row already closed is backfilled to landed, the only close that existed before; open rows stay null. standup_mentions is a new table, so no backfill. Append-only: rolling back means restoring from backup. Pins the minimum image version, as above |
| 0042_standup_away | standup_away, standup_trend_days | Self-set away days for async standups, and each scheduled standup's frozen trend counts. New tables, so no backfill; a past scheduled day is frozen lazily on the first trend read. Pins the minimum image version, as above |
| 0043_embed_handoffs | embed_handoffs, session_tokens.embedded | The meeting-client sign-in handoff and the marker on the session it mints. The new column defaults to false, so every existing session stays an ordinary one. Pins the minimum image version, as above |
How the runner behaves
Section titled “How the runner behaves”Files are read from the embedded filesystem, sorted lexically, and a migration’s version is its position in that list. Each runs in its own transaction together with the row that records it, so a failure leaves no half-applied migration.
If the database records a version higher than the number of files the binary has, the process refuses to start. That is the guard against rolling an image back past a migration it does not know about — it fails loudly instead of running against a schema from the future.
There are no down-migrations, no checksums, and no dry-run. See Upgrading.
Tables
Section titled “Tables”| Column | Type | Notes |
|---|---|---|
id |
uuid | Primary key. Public — broadcast to every participant |
name |
text | 1–64 characters, enforced by check constraint |
issuer |
text | ≤255. Empty for anonymous users |
subject |
text | ≤255. Empty for anonymous users |
avatar_icon |
text | ≤32. Opaque client-side id; empty means none chosen |
avatar_accessory |
text | Retired in v0.5.1. Never written and never read; a later migration drops it |
notification_sounds |
boolean | Per-user preference, false by default. Returned only with the caller’s identity |
link_id |
uuid | Set when the person arrived through a signed link. on delete set null |
created_at |
timestamptz |
(issuer, subject) carries a unique index partial on issuer <> '', so every
anonymous row can share the empty pair while federated identities stay unique.
session_tokens
Section titled “session_tokens”| Column | Type | Notes |
|---|---|---|
token_hash |
bytea | SHA-256 of the bearer token, as raw bytes — it is the primary key. The token itself is never stored |
user_id |
uuid | Cascades on user delete |
created_at, last_used_at |
timestamptz | last_used_at drives the SESSION_IDLE_TTL idle window, refreshed by any write or WebSocket connect and never by a GET; created_at drives the SESSION_MAX_TTL absolute cap, which nothing extends |
expires_at |
timestamptz | Null for ordinary sessions. A redeemed guest link sets it; the token dies at that instant regardless of idle activity |
embedded |
boolean | True for a session minted for a meeting client’s frame (12 hours from minting, participant power only). False for everything else |
The hourly sweep that deletes expired rows is indexed on last_used_at, on
created_at, and partially on expires_at where expires_at is not null. The
user_id index from 0002_identity is unchanged.
spaces
Section titled “spaces”| Column | Type | Notes |
|---|---|---|
id |
uuid | |
slug |
text | Unique, and constrained to a lowercase URL-safe pattern |
name |
text | 1–64 characters |
passcode |
text | ≤12. Plaintext by design — empty means open |
archived_at |
timestamptz | Null unless an org admin has archived it. Hides it from the org directory and nothing more |
members
Section titled “members”(space_id, user_id) composite primary key, plus spectator and last_seen_at.
Both foreign keys cascade. last_seen_at is stamped on join and refreshed by
POST /api/orgs/{org}/spaces/{slug}/seen, which the space page sends when a member opens
it; the space read itself is a GET and never writes. It orders the landing
page’s space list and is never returned to the client.
| Column | Type | Notes |
|---|---|---|
role |
text | owner or member, enforced by a CHECK constraint. Defaults to member |
role is the space’s only permission bit. 0015_member_roles adds it and
backfills every existing space: the recorded spaces.creator_id becomes owner,
and a space with no recorded creator hands ownership to its most recently
active member, so no upgraded space is left with nobody able to manage it.
A partial index on (space_id) where role = 'owner' backs the last-owner
guard, which counts owners under a row lock before any demotion or removal.
org_members
Section titled “org_members”(org_id, user_id) composite primary key — good for “is this user in this
org”. Lookups that ask “which orgs is this user in” (the org switcher and
landing grouping via Orgs.ForUser) use a partial index on (user_id) where revoked_at is null instead. The admin members screen lists by org_id and
includes revoked rows, so it does not need that index.
org_audit_log
Section titled “org_audit_log”The record of what an org admin did with custody of a space. It exists because claiming an abandoned space is the one path by which an admin becomes a member of a space they were not in, and an escalation nobody can review afterwards is not a control.
| Column | Type | Notes |
|---|---|---|
org_id |
uuid | on delete set null — never cascade |
org_slug |
text | Denormalized, so a record still names its org after the org is gone |
space_id |
uuid | on delete set null — never cascade |
space_slug |
text | Denormalized for the same reason. Empty for an org-level action |
actor_id |
uuid | on delete set null: deleting an account does not rewrite history |
action |
text | space.claim, space.delete, space.add_owner, org.purge |
detail |
text | Human-readable context. Never session content |
created_at |
timestamptz |
The non-cascading foreign keys are the whole design. DELETE /api/orgs/{org}
deletes the org’s spaces and then the org itself; if these rows went with them,
the purge would erase the record of what an admin did to a space that no longer
exists. Instead the ids are nulled, the slugs remain, and the log still says
what happened to what.
spaces.archived_at
Section titled “spaces.archived_at”Nullable, and the only thing it does is take a space out of the org directory. Its members, its history, its passcode and its own URL are all untouched, and an admin can restore it by clearing the flag.
session_kinds
Section titled “session_kinds”| Column | Type | Notes |
|---|---|---|
kind |
text | Primary key, 1–64 characters. sessions.kind references it on delete restrict |
provider |
text | core for the kinds Parley ships |
display |
text | Human-readable name |
retired_at |
timestamptz | Null means the kind is offerable |
org_id |
uuid | Nullable, references orgs on delete cascade. Null means instance-wide, which is what the core kinds are; a kind a plugin provides carries the org of the install that provides it (0035) |
actions |
jsonb | The dispatch table a plugin-provided kind declared, so it survives a restart. Empty for a core kind, whose actions are Go code (0035) |
A kind belonging to an org is offered only to that org, and only while the
install that provides it is enabled — a disabled plugin’s ceremony stops being
offered at once, and the row stays because a disable is reversible. A kind is
retired in place by setting retired_at, never deleted — the
restricting foreign key means a row with sessions cannot be removed, and
existing sessions of a retired kind keep loading and running as before.
Retiring only closes the door on new sessions: session creation rejects a
kind whose retired_at is set, with 400 that session kind has been retired,
and the space view’s kinds list — what the create dialog offers — leaves it
out.
sessions
Section titled “sessions”| Column | Type | Notes |
|---|---|---|
kind |
text | Foreign key to session_kinds.kind, on delete restrict. Adding a ceremony is a new row, not a schema change — see Adding a session kind |
title |
text | 1–200 characters |
config |
jsonb | Kind-decoded JSON. Poker may update autoReveal and openVoting after create via PATCH .../actions/config, one key at a time; the deck stays create-time |
phase, revealed, version |
version increments on mutation |
|
poker_round_version |
bigint | Increments when a story is selected or the selected story is reset, including an empty round |
facilitator_id |
uuid | References users — no cascade |
current_speaker_id |
uuid | Nullable, references users — no cascade |
facilitator_seen_at |
timestamptz | Drives the 60-second claim grace |
| Column | Type | Notes |
|---|---|---|
space_id |
uuid | References spaces, on delete cascade. Every lookup is scoped by it, so an id alone never reaches another space’s deck |
name |
text | Unique per space |
cards |
text[] | The deck’s own cards. cards, not values: values is a reserved word in Postgres |
ordinal |
boolean | An ordinal deck (t-shirt sizes) has no arithmetic; a numeric one requires every card to parse |
A deck is a template a space picks from, never a reference a session holds:
creating a session copies the cards into its own config, so editing or
deleting a deck cannot invalidate a vote already cast, and the vote path gains
no join. Nothing points at this table.
There is deliberately no check on cards. The card rules live in Go, in
internal/poker, and the deck routes and the session-create route run the same
function — a second copy in SQL would be a copy that drifts.
| Column | Type | Notes |
|---|---|---|
space_id |
uuid | References spaces, on delete cascade |
from_user_id |
uuid | References users, on delete cascade. The sender, and the only person who can delete the row |
to_user_id |
uuid | References users, on delete cascade. A check forbids it equalling the sender |
text |
text | 1–280 characters |
session_id |
uuid | Nullable, references sessions, on delete set null — deleting the room must not erase the thank-you |
created_at |
timestamptz | Indexed with space_id, descending: the only read is one space’s kudos, newest first |
A note from one member of a space to another. One table serves both surfaces — the space’s kudos wall and a standup’s closing beat — so a kudo given in a room appears on the wall unchanged.
There is no leaderboard, and that is a schema constraint rather than a UI choice. No count column, no aggregate, no per-person total. Storing a count is enough to invite a ranking, and a ranking turns thanks into a scoreboard people play, so the objection is written into the migration header where a count column would have to argue with it.
Membership is not expressible in the schema. A link guest holds a users row
but no members row, so the recipient foreign key would accept one; the
membership check in internal/store/kudos.go is the only defence, and it is
tested as such. Guests neither send nor receive. A kudo survives its recipient
leaving the space.
session_participants
Section titled “session_participants”| Column | Type | Notes |
|---|---|---|
session_id |
uuid | References sessions, on delete cascade |
user_id |
uuid | References users, on delete cascade |
joined_at |
timestamptz | When they first turned up |
Who belongs to a session. One row the first time somebody attaches to the room,
kept afterwards — this is durable, and deliberately not the same question as
session_presence, which is erased on disconnect and swept, and so only ever
says “connected recently”. It is narrower than members (a forty-member space
runs five-person rounds) and wider (a link guest has no members row and still
turns up). Written on WebSocket attach (retried on pong only if that first write
failed), not on every successful heartbeat. Capped at 200 rows per session:
attaching past the cap drops the oldest joiner, but never the current
facilitator, so a long-lived room cannot grow the table — or the open-voting
snapshots that copy it — without bound.
round_voters
Section titled “round_voters”| Column | Type | Notes |
|---|---|---|
story_id |
uuid | References stories, on delete cascade |
user_id |
uuid | References users, on delete cascade |
Who an open round is waiting for, written when the story goes on the table
and only for a session with openVoting on: up to 200 of the
session_participants of that session who can still cast (current
non-spectator members, or live link guests; facilitator preferred, most recent
first). An open round is estimated asynchronously, so it cannot be gated on who
happens to be connected when the last vote lands. Snapshotting is what makes it
finishable — reading the set live would move the goalposts under a round in
progress. A later re-select or reset drops anyone no longer in
session_participants or no longer eligible to cast, so cap churn cannot grow
the pending set past the ceiling.
The completion check asks whether every still-pending recorded voter has cast. Spectators stay in the table so sitting back down restores the wait, and are skipped live. Departed members and revoked guests are removed from the table when membership changes (space remove or org revoke); expired guests are dropped on the next vote. Without that, removing a member — or revoking a link — after the snapshot would leave the round waiting forever on a vote that can never arrive. A closed round never touches this table.
stories, votes
Section titled “stories, votes”stories carries title (≤200), notes (≤2000), ref (≤40), a fractional
position for reordering, and estimate. Either title or ref names a
story, so both are allowed to be empty individually but a check constraint
refuses a row where both are. votes is keyed (story_id, user_id) and
cascades on both.
session_links
Section titled “session_links”A signed link is an opaque capability token bound to exactly one room — never a space. The token is never stored; only its SHA-256 digest is.
| Column | Type | Notes |
|---|---|---|
id |
uuid | Primary key |
session_id |
uuid | Cascades on room delete |
created_by |
uuid | The facilitator who minted it |
token_hash |
bytea | SHA-256 of the token, unique. The token is shown once, at mint, and is never readable again |
expires_at |
timestamptz | 24 hours after minting, a server constant |
revoked_at |
timestamptz | Null while live. Revoking is idempotent |
redemptions |
int | Counts toward a fixed server-side cap |
created_at |
timestamptz |
Link rows are never swept or deleted, and users.link_id is deliberately on delete set null rather than cascade. votes, standup_entries and presence
all cascade from users, so a cascade here would make deleting a room or
revoking a link erase a guest’s votes and updates from a finished meeting — and
from any CSV exported afterwards.
standup_entries
Section titled “standup_entries”Keyed (session_id, user_id), with yesterday, today and blockers each
≤2000 characters, plus skipped and ready.
ready is the advisory “I’ve finished writing” signal shown while everyone
gathers. Nothing reads it but the screen: not the speaking order, not the skip
logic, not the CSV export.
standup_schedules, standup_schedule_slots
Section titled “standup_schedules, standup_schedule_slots”standup_schedules holds at most one schedule per space (space_id unique, on
delete cascade): weekdays (a smallint[] of 0–6, Sunday first),
open_time, timezone (IANA), window_minutes (1–1440), enabled, and
updated_by. updated_by is nullable and references users(id) on delete set null. It records the user who last saved the schedule. Deleting that user
clears the column and leaves the schedule in place. At open time that user
facilitates the slot while they are still an owner of the space. When they are
not, the most recently active current owner does (latest last_seen_at, then
user id). A space with no owner skips the slot.
standup_schedule_slots records each slot that has opened, keyed
(schedule_id, slot_date) where slot_date is the local date in the
schedule’s time zone. That primary key is the whole cross-replica guard: every
replica’s ticker inserts, one wins, and the rest do nothing. session_id is on
delete set null, not cascade, so deleting a slot’s room does not let the same
day open again.
standup_webhooks, standup_webhook_deliveries
Section titled “standup_webhooks, standup_webhook_deliveries”standup_webhooks holds at most one webhook per space (space_id primary key,
on delete cascade): the url, the signing secret as secret_nonce and
secret_ciphertext (AES-GCM under PLUGIN_SECRET_KEY; it must be recoverable to
sign, so it is not a hash), created_at and updated_by (on delete set null).
Only events after created_at are delivered, so configuring a webhook never
replays a space’s history.
standup_webhook_deliveries is the outbox. Its id is the event id a receiver
sees, stable across every retry. unique (session_id, event) is the
cross-replica guard: every replica’s sweep inserts, one row survives. A claim
takes for update skip locked plus a two-minute lease_until; attempts,
next_attempt_at, delivered_at, failed_at and last_error record the
retries. The same sweep deletes a delivered or failed row 30 days after it
reached that state, in one statement, so the outbox does not grow without
bound. A row still waiting to be sent is kept. session_id cascades, so
deleting a room drops its undelivered events.
ics_feed_tokens
Section titled “ics_feed_tokens”One personal standup calendar per user (user_id primary key, on delete cascade). token_hash is the sha256 of the token’s raw bytes, the same shape
as session_tokens, and lookup_prefix is the first eight characters of the
plaintext so a fetch can find the row. A unique index on lookup_prefix covers
only rows whose revoked_at is null. remind_minutes is 0–1440, the single
alarm offset. Revoking stamps revoked_at; it does not delete the row. An org
membership revoke stamps it in the same transaction. Deleting the user deletes
the row.
embed_handoffs
Section titled “embed_handoffs”One meeting-client sign-in attempt, keyed by challenge_hash — the 32-byte
SHA-256 of a verifier only the frame holds. display_code is shown in the
frame and typed back on the sign-in page, which binds only on a match;
provider names the enabled meeting client; client_key is the
requesting address, for the per-client throttle. user_id (cascades on user
delete) and bound_at are set by the one bind; used_at by the one redeem.
expires_at is five minutes after creation, and the hourly session sweep
deletes rows past it.
standup_commitments
Section titled “standup_commitments”What somebody said they would do, carried until they say it is done. Keyed by
its own id and scoped to a (space_id, user_id), with text ≤500 characters,
a nullable opened_session_id, a nullable closed_at, a nullable
closed_session_id, a nullable closed_reason, a carried count and a
nullable carried_session_id.
The open rows for a space and person — those with a null closed_at — are
the carry-over list, read directly by a partial index. Nothing walks previous
sessions to work out what is outstanding.
Answering “done” stamps closed_at with a closed_reason of landed, and the
commitment leaves the list. Dropping it closes it the same way with a
closed_reason of dropped: it leaves the list, carried is untouched, and it
is never read as landed. closed_reason is null on an open row. It is not tied
to closed_at by a constraint, because a replica on the previous binary closes
a commitment without writing a reason; a closed row with a null reason is read
as landed, which is the only close that binary knew.
Answering “not yet” carries it into the next standup and it stays. A commitment
showing carried of 2 or more is stuck, which the server computes and puts
on the wire. carried only moves when somebody answers: missing a standup
entirely changes nothing, so a week away cannot manufacture a stalled
commitment.
carried moves at most once per session. The update compares the answering
session against carried_session_id and only increments when they differ,
stamping the column as it goes; a repeat “not yet” on the same commitment in the
same room is a no-op for the count. Without that, one mis-click and its
correction — No, Change, No — would take a commitment opened minutes ago to
carried of 2 and render it as stuck. A “done” leaves carried_session_id
alone: closing is not a carry. The column is nullable and never backfilled,
because a row with no recorded session is exactly one that has not been answered
in any session yet.
Open-versus-closed lives in closed_at, not in the foreign key. A foreign key
cannot carry that state: on delete cascade would take a finished commitment
away with the room, and on delete set null would put it back on the list.
All three session columns — opened_session_id, closed_session_id and
carried_session_id — are historical linkage only, nullable and on delete set null. They record which room a commitment was opened or answered in, and
deleting that room clears the column without reopening anything, rewriting the
count, or taking the commitment with it. A commitment belongs to its
(space_id, user_id), not to the room it happened to be typed in: an open one
with no origin room left is still on the carry-over list and still answerable,
and it simply renders as carried in, because there is no room left for it to
have been opened in.
standup_mentions
Section titled “standup_mentions”One member asking another for help with a blocker, in one standup:
(session_id, from_user_id, to_user_id) is the primary key, with a
created_at, and a check that the two users differ. Every key cascades — the
row is about one room and two people and means nothing without any of them.
The target is a user id picked from the space’s members. Membership is checked
at write time, both in the handler and again inside the insert itself: the
target and the asker must each hold a members row in the room’s space and have
a null users.link_id, so a link guest can neither ask nor be asked. No foreign
key can express that — a link guest has a users row. Leaving the space later
does not delete the row.
The rows never reach the room’s shared state. They are read one person at a time: the people who asked you, and the people you asked.
standup_away
Section titled “standup_away”A person’s self-set away days: id, user_id (on delete cascade), and an
inclusive starts_on and ends_on date, with a created_at. Checks require
ends_on >= starts_on and a span under 90 days. A row belongs to the user,
not to a space, so one range applies in every space they are in. Only the
owner of a row reads or writes it.
An open async standup lists the space’s members whose range covers its day,
by id, only while that day is today, so the digest does not read them as
owing an answer; an ended or reopened past standup lists nobody, and a link
guest is never sent the list. A range counts toward a frozen trend day only if
its created_at is before that day was over. Nothing reads these rows into a
per-person participation figure, and no table stores one.
standup_trend_days
Section titled “standup_trend_days”One frozen trend day per scheduled async standup: id, session_id (unique,
on delete set null), space_id (on delete cascade), day (the slot’s local
date), eligible, answered and frozen_at. Checks require
0 <= answered <= eligible. Eligible is the space’s members who are not link
guests, not spectators (members.spectator) and not away that day by a range
created before the day was over; answered is those of them with a non-blank
entry.
A row is written once, when the day is over: in the transaction that ends the
standup (the facilitator’s end or the next slot opening), or on the first trend
read after its local date has passed. on conflict (session_id) do nothing
makes the first write the only one, so a reopen, a late answer, a membership
change or a later away range never rewrites it. Only standups linked from
standup_schedule_slots are written. session_id is set null rather than
cascaded so deleting the room does not take its day out of a week already
shown. The team trend sums these rows only, dropping days with fewer than four
eligible, and returns team ratios rounded to one decimal.
The plugin tables
Section titled “The plugin tables”Seven tables, all added by 0031_plugins, and all empty until a plugin host is
installed. Every one of them is keyed on an install and cascades from it, so
uninstalling a plugin takes its grants, secrets, storage, deliveries and jobs
with it.
| Table | What it holds |
|---|---|
plugin_installs |
What is installed in one org: org_id, name, version, whether it is enabled, and its storage quota and counter. unique (org_id, name) |
plugin_grants |
One row per capability an install may use, optionally narrowed by scope |
plugin_secrets |
Encrypted credentials — nonce and ciphertext, and deliberately no plaintext column |
plugin_kv |
Namespaced key-value storage. A plugin has no SQL, so this is the whole of its durable storage |
plugin_events |
The transactional outbox: one row per thing that happened |
plugin_deliveries |
One row per (event, subscriber), with its state, attempts and next-attempt time |
plugin_jobs |
Deferred work. The claim still keys off run_at. A five-field cron on parley_job_enqueue is converted to the next run_at at schedule time; invalid expressions are refused rather than stored |
Two delivery paths, on purpose. Core subscribers are in-process and
synchronous, so the state broadcast a room depends on stays instant. Plugin
subscribers get a plugin_deliveries row written inside the same transaction
as the state change, drained afterwards by a worker. That makes plugin
delivery at-least-once, so every plugin event handler must be idempotent —
handling an event twice must reach the same result as handling it once. The
websocket hub is not a subscriber and stays a dumb fanout.
Claims use for update skip locked, in both the outbox and the job queue,
and a claim also pushes the row’s next-attempt time out by a lease. The lock
alone is not enough: it is released the moment the claiming transaction commits,
and a second worker would then pick up a row still marked pending and deliver it
again. A worker that dies mid-delivery releases its rows when the lease expires.
Running a second replica is therefore configuration rather than a rewrite, even
though one replica needs no leader election today.
A delivery that keeps failing is dead-lettered after a bounded number of
attempts, with exponential backoff in between, rather than retrying forever in
front of live work. last_error keeps the reason.
Retention is in from day one. Events past PLUGIN_EVENT_RETENTION whose
deliveries have all reached a terminal state are pruned, along with their
deliveries; an event still waiting on a delivery is never pruned however old it
is. Adding retention once the table is large would mean a migration under load.
An install belongs to an org (0034). plugin_installs.org_id is not null and cascades from orgs, and a plugin’s name is unique within an org
rather than across the instance — two orgs installing the same plugin get two
installs, with two separate key-value stores and two separate sets of secrets.
The column is what makes “this install is not yours” expressible at all: the
admin gate in front of the operator routes resolves the org slug in the
caller’s own path, so it proves only that they administer an org. Every
lookup the administration surface makes is therefore scoped to the org the
request resolved to, and an install belonging to another org answers 404 —
the same answer an id that was never issued gets, so the surface cannot be used
to learn what another org has installed.
plugin_installs.kv_bytes is a counter, not a view. Every key-value write
adjusts it in the same statement as the write it accounts for, and that same
statement’s where clause is the quota check — so a refused write moves neither
the data nor the counter, and the two cannot disagree. A reconciliation pass
runs alongside retention and resets any counter that has drifted, because a
counter nobody checks is a counter that can quietly grant unbounded storage.
Constraints are the validation layer
Section titled “Constraints are the validation layer”Field lengths are enforced by check constraints in the database as well as in
handlers. If you write to Parley’s database directly, the constraints still hold
— which is the intent.