Skip to content

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.

0001_extensionspgcryptoThe only extension Parley needs, for gen_random_uuid()
0002_identityusers, session_tokens
0003_spacesspaces, members
0004_sessionssessions
0005_pokerstories, votesAlso adds sessions.current_story_id
0006_standupstandup_entriesAlso adds sessions.current_speaker_id and speaker_started_at
0007_story_refstories.refTicket reference, ≤40 chars
0008_space_passcodespaces.passcodeEmpty string means an open space
0009_federated_identityusers.issuer, users.subjectBehind a partial unique index
0010_session_kindssession_kindsReplaces the sessions.kind check constraint with a foreign key
0015_member_rolesmembers.roleowner or member. Backfills the space creator — or, where none was recorded, the most recently active member — as owner
0016_standup_readystandup_entries.readyAdvisory gathering-phase signal. Constant default, so no backfill and no table rewrite
0017_avatarusers.avatar_icon, users.avatar_accessoryChosen 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_linkssession_links, users.link_idSigned links. users.link_id is on delete set null, never cascade — see below
0023_org_custodyspaces.archived_at, org_audit_logOrg custody. Neither audit foreign key cascades, so the record outlives what it names — see below
0024_org_members_user_idxorg_members (user_id) where revoked_at is nullPartial 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_commitmentsstandup_commitmentsNew 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_idempotencystandup_commitments.carried_session_idWhich 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_survivesstandup_commitments.opened_session_idMakes 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_pluginsplugin_installs, plugin_grants, plugin_secrets, plugin_kv, plugin_events, plugin_deliveries, plugin_jobsThe 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_orgplugin_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_orgsession_kinds.org_id, session_kinds.actionsA 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_idxsession_tokens (last_used_at), (created_at), (expires_at) where expires_at is not nullBtree 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_soundsusers.notification_sounds, sessions.poker_round_versionBoth use constant defaults, so existing identities remain muted and existing poker sessions begin at round version 0
0038_standup_schedulesstandup_schedules, standup_schedule_slotsScheduled 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_webhooksstandup_webhooks, standup_webhook_deliveriesStandup webhooks and their delivery outbox. New tables, so no backfill. Pins the minimum image version, as above
0040_ics_feed_tokensics_feed_tokensOne 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_throughstandup_commitments.closed_reason, standup_mentionsclosed_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_awaystandup_away, standup_trend_daysSelf-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_handoffsembed_handoffs, session_tokens.embeddedThe 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

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.

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.

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.

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

(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_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.

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.

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.

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.

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 usersno cascade
current_speaker_id uuid Nullable, references usersno 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.

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.

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 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.

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.

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 holds at most one schedule per space (space_id unique, on delete cascade): weekdays (a smallint[] of 06, 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.

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.

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.

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.

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.

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.

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.

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.

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.