The Shape of the Data
Eighty-three tables in thirteen domains, every person’s data wired to one users table — and every rule that can live in Postgres, living there.
One spine, thirteen domains
Cue’s data lives in PostgreSQL 18, and the schema changes only through Flyway: every change is a numbered SQL migration, reviewed like code and never edited once shipped. Hibernate runs in validate mode — it checks that the Kotlin entities match the tables at startup, and never writes DDL of its own.
The 83 tables group into thirteen domains. Every domain that holds people’s data has at least one foreign key into users. The last two belong to no one: Link previews, a cache of public pages every chat shares, and Operations, the backup ledger. Each section below draws one domain as an entity-relationship diagram: the columns that carry its rules, the relations between its tables, and the constraints Postgres enforces on them. A crow’s foot marks the many side of a relation, a bar the one side.
Everything hangs off a person
Every row in Cue traces back to a person. A user appears the first time someone signs in — with Google, Apple, or a six-digit email code — and is never asked to invent a username. Sign-in identities live beside the row rather than inside it, so one person can link several providers and stay a single account.
profile_pictures is keyed by user_id itself, so a person has at most one; each auth_sessions row points at the row that replaced it.widget_credentials: the iOS Chats widget's own credential, one per sign-in- Two names, on purpose.
provider_namefollows the Google or Apple display name and refreshes on every sign-in;nameis an optional name of your own, unique case-insensitively through aLOWER(name)index. The publichandlegets the same treatment. - Heavy bytes live apart. Uploaded avatars sit in
profile_picturesasBYTEA, so the hotusersrow stays small. Apart from Cue Notes’ documents, every other byte lives in R2. - One row per rotation.
auth_sessionsstores only a SHA-256 digest of each refresh token. Every rotation descended from one sign-in shares afamily_id, so a token replayed after its successor was spent revokes the whole chain in a singleUPDATE. - A sign-in takes its devices with it.
device_tokensandwidget_credentialscarry the family that registered them, so a logout, a replayed token or a lapsed session ends that phone’s pushes and its widget’s credential along with the session.
A time that works for everyone
An organizer proposes a few times and invitees answer in plain language. That whole exchange fits in a handful of tables: the event, its candidate slots, one invitation per guest holding their free-text reply, and the notifications that nudge everyone along.
invite_links is unique on event_id, so an event has at most one join link, and asking for it again returns the same token.- One reply per guest. A unique
(event_id, user_id)oninvitationsmakes “exactly one response each” the database’s job, not the application’s. - Invite a stranger.
pending_email_invitesparks an invitation against an email address and attaches it the moment that person signs up. - More than one organizer. A
rolecolumn on the same invitation row lets the creator share the organizing powers with anyone already invited — no extra table, because every guest already has a row. - A nudge points at one thing.
notificationscarries three nullable foreign keys and aCHECKthat exactly one is set: an event, a decision or a trip.
Gemini’s work, written down
Every AI feature re-runs as the conversation changes and saves its output as a fresh row: the suggested time, a persona’s comment, a practical read of the weather. All of it hangs off the event, with token usage and the budget tracked alongside. A cover Cue paints is the exception: one person asks for it, so its session hangs off them.
events is dashed because it belongs to Scheduling. ai_usage_limits and the two price catalogs have no foreign key at all: one row of budget caps, and prices dated by the day they take effect, where an image model’s row adds its own rate for image output.image_sessions: one person painting one image: what Cue read, its ideaimage_takes: each painted take, its bytes in R2 until sweptimage_looks: a person's style, mood and painter for every image- Latest wins, history stays. Each re-run writes a new row. Only the newest is shown; older ones stay for transparency and debugging. Per-slot scores ride in
JSONB, so their shape can change without a migration. Astatusmarks each rowok,rate_limitedorfailed, and afingerprintof the inputs lets an unchanged request skip the call. - Nobody torches the budget. Every Gemini call lands in
ai_usage_logswith its model, token counts and cost — priced once, when it is logged, at its model’s row inai_model_prices— and the caps in the singleai_usage_limitsrow are checked before the next one runs. A usage row is the bill, so it outlives the event, decision or trip it was charged to. - A cover is a session.
image_sessionsholds one person painting one image, a cover or a preview of their look: what Cue read, its idea and the look it froze at the start.image_takesholds each picture, its bytes in R2 under astorage_key. The image prompt stays server-side. A kept take becomes an ordinaryphotosrow; the rest are swept with their session.image_lookskeeps one row per person, and no row means Cue decides. - One name per room. A partial unique index allows one active AI participant per name on each event. A removed participant keeps its row, so its old messages still say who wrote them.
A live room on every event
Switch chat on and an event grows a message stream. People and AI participants post into the same table; read cursors and emoji reactions live in companions of their own.
ai_participants belongs to the Intelligence domain — it is the bridge between the two. A message can reply to another message in the same table.- Human or AI, never both. A
CHECKguarantees each message has exactly one author: a user or an AI participant. - Built to page back. An index on
(event_id, id DESC)lets “load older messages” walk the history in index order, newest first. - A delete never takes your read state with it.
last_read_message_idisON DELETE SET NULL: deleting the message a cursor points at clears the cursor instead of failing, and the row keeps its mute and marked-unread flags.
Two people, one canonical row
A direct-message thread mirrors event chat almost exactly — messages, read cursors, reactions — but it belongs to a pair of people instead of an event. The neat part is the ordering that keeps every pair to one row.
CHECK requires the smaller user id first, so a lookup never tries a pair both ways, and the unique index makes each pair a single thread.- user_a_id < user_b_id, always. Whoever writes first, the pair is stored in the same order, so every two people map to at most one row.
- A quiet decline. A thread carries a
status. Declining silently stops the fan-out — no notification, no leak — and the thread flips back to pending only if the person who declined writes back. - Archived, not deleted.
archived_athides a thread from the inbox until a new message brings it back. Only direct messages have it; other kinds mute or leave.
A room that belongs to no event
Standalone group chats like #oslo and #fotball, with their own membership, roles and three visibility tiers: public, private and secret. Seven tables that borrow every lesson the chat and DM tables learned first.
channel_read_state: cursor, mute, marked unreadchannel_message_reactions: emoji, one of each per person- Never two owners. A partial unique index (
WHERE role = 'owner') makes a second owner impossible — a database invariant, not a code convention. - Removed means blocked. A separate
channel_blockstable keeps removed members off the hot fan-out path, instead of aWHERE removed_at IS NULLsmeared across every query. - Requests keep their history. Decided join requests stay behind for audit and for the 24-hour cooldown after a denial; only a pending one is unique.
One library for photos, video and files
Everyone has one library holding photos, short videos and documents as a single kind of row. Each rendition — every size, every format — is a variant stored in Cloudflare R2, and a usage row records every place an item is shown.
storage_key names its object in R2. target_id has no foreign key, because the row it points at lives in a different table for each usage_type.- One table, three media. A
media_kindcolumn — image, video or document — is all that separates them at the row level. - Upload a photo twice, store it once. A unique
(owner_id, content_hash)deduplicates each person’s photos and documents; uploading a trashed photo again brings the original back — unless an admin took it down, whenremoved_atkeeps the row past the purge as a tombstone that refuses the same bytes. - Read once, kept with the item.
media_ai_textsholds at most one text perkind— a photo’s AI description, or the digest of a shared file — so later prompts carry the text instead of the bytes. It goes with its item, and a photo’s description goes when its uploader opts out. - Thirty days in the trash.
deleted_atsoft-deletes, and a partial index over trashed rows lets the purge job find expired ones without scanning anyone’s library.
A group picks what
Scheduling settles when; Decisions settles what — where to travel, what to name the band. A decision holds candidate options and one free-text preference per person, and Gemini weighs them into a recommended pick with per-option fit scores and a written rationale. It reuses the whole event playbook — invites, an AI commentator, a full chat — so thirteen tables come along.
decision_context_items: background notes and filesdecision_ai_comments: a persona's takedecision_ai_participants: AI personalities in the chatdecision_messages: the decision's chatdecision_message_reactions: emoji, one of each per persondecision_chat_read_state: each reader's cursordecision_invite_links: one shareable join linkdecision_pending_email_invites: invitations to people not on Cue yet- The Events pillar, mirrored.
recommendationsis to a decision whatresolutionsis to an event: the model’s pick, re-run whenever options or preferences change, with fit scores inJSONB. - Grounded, with receipts.
option_insightsstores a cited brief per option, with its sources and search queries — only when the organizer switches insights on, because grounded calls cost money. - Everything else, reused. The AI commentator and participants, the chat trio and the invite pair mirror their event counterparts one for one.
decision_context_items, the background a recommendation reads, is the one table events don’t have.
A container, not a pillar
Events settle when and decisions settle what; a trip settles nothing — it organizes them. A trip is a container with its own roster, chat, notes, documents and album, gathering events and decisions into one timeline. Dates are optional and may be in the past, so the same row is equally a plan for next month and a memory from years ago.
container_links holds one pillar and one container per row: nullable foreign keys to all four, and two CHECK constraints keeping exactly one of each.trip_ai_comments: a persona's taketrip_messages: the trip's chattrip_message_reactions: emoji, one of each per persontrip_chat_read_state: each reader's cursortrip_invite_links: one shareable join linktrip_pending_email_invites: invitations to people not on Cue yet- One table, both container kinds. Trips and channels share
container_links, andUNIQUE NULLS NOT DISTINCTstops a pair from repeating. A new container kind is one more nullable column and a widerCHECK, not a new table. - A link is a tag, never a grant.
ON DELETE CASCADEon all four sides means deleting either end drops only the link. Membership never grants access to a linked event or decision; authorization stays per resource. - Built to outlive its creator.
created_byisON DELETE SET NULL: a shared memory survives its creator’s account, where events and decisions leave with theirs.
How much room a person gets
Three things cost real money as Cue grows: stored bytes, the fan-out every new event, decision or trip drags behind it, and the images Cue paints on demand. So each person has an effective level with an allowance on each. Room grows with tenure on its own, and an admin can pin anyone to any rung — up as a gift, down as a brake on abuse.
users belongs to Identity. granted_level_id is an admin’s pin; left empty, the level is earned from the account’s age.image_take_ledger: the 30-day ledger of painted takes- Rows, not an enum. Levels are catalog rows because the set is open: adding a tier is an
INSERTand retuning an allowance anUPDATE— never a change to the wire contract shipped apps depend on. - Earned is computed, never stored. Each earnable rung carries a threshold in days since
users.created_at, so the earned level is simply the highest rung an account’s age clears. No promotion job, nothing to drift. - A ledger, because live rows are gameable.
user_creationsgains a row per create and never loses one to a delete — otherwise create, fan out invites and AI, delete, repeat would be free.image_take_ledgerdoes the same for every take Cue paints. Only the rolling window returns a slot.
Writing together, never losing a word
A note is markdown several people write at once. Its truth isn’t the markdown column but an Automerge document — a CRDT — so a week offline, a save from a script and an AI rewrite all merge instead of colliding. Postgres keeps that document the way a log-structured store would: every change appended before anyone is told it is safe, folded into a snapshot now and then.
note_docs is keyed by note_id itself, so a note has one snapshot, and note_log holds only the changes since it. notes.markdown is a cache the server rewrites from the document — never an input.note_actors: each CRDT actor, bound to one personnote_clean_up_runs: every Clean up asked for, and any refusalnote_pending_email_invites: invitations to people not on Cue yet- Stored before it’s acknowledged. A change reaches
note_logand commits before the writer hears it is safe. Compaction folds the log intonote_docsstraight from those rows, and itsversioncolumn lets exactly one of two racing replicas win. - Every author, bound.
note_actorsties each CRDT actor to the one person who first wrote with it, and keeps the binding — itsuser_idset null — after they leave, so nobody can later write as a departed author. - History as copies. A
note_versionsrow copies the markdown at a set of heads, with who wrote since the last one and why it was cut — an editing burst, a restore or a Clean up — so scrubbing back reads no document at all. - Images stay the uploader’s.
note_imagespoints at an ordinary photo in its uploader’s library, deliberately not aphoto_usagesrow, so no Cue app’s where-used chips ever name a note.
A page, fetched once for everyone
Post a link in any chat and it gets a card: a title, a thumbnail, the domain it lands on. cue-core fetches the page once, on the server, and keeps what it found as an immutable snapshot every chat can share — so no reader’s device ever contacts the linked site, and a card never changes under its message.
link_snapshot_id carries no ON DELETE action, so Postgres refuses to delete a snapshot a message still points at.- Immutable, newest wins. Every fetch writes a new row, and the newest row for a
canonical_urlis the cache entry, found through an index on(canonical_url, fetched_at DESC). A message keeps the snapshot it posted with when a refresh adds a newer one. - A failure is a row too. A
failedrow has no content and stays the answer for ten minutes, so a dead link isn’t fetched again on every pause in typing. - Decided at post.
link_preview_statetakes one of four values.snapshotpoints at a snapshot;cueis a link to Cue itself, drawn for each viewer at read time and never stored;nonehas no card, though the post’s own fetch may still make it asnapshotwithin seconds;offwas suppressed or removed, and no later edit undoes it.NULLmarks a message from before previews, which never gets one. - Swept, never orphaned. A nightly job deletes snapshots no message points at once they are a week old, with their images in R2. A partial index on each message table’s
link_snapshot_idkeeps that question cheap.
The one table the app never writes
Every other table is written by cue-core. This one is written by the nightly backup job, from another machine, once each run ends — a failed run too, whenever production can still be reached.
database_backups: the ledger belongs to no one. The admin dashboard reads it with plain SQL, so it is the one table without a Kotlin entity.- A failure names its step. A
CHECKties afailed_step— the dump, reading it back, or the restore check — to every failed run, and to nothing else. - Restored, not just saved. Each run restores its dump into a scratch database and records what came back: the tables, their rows, and the newest migration the dump carried.
- Silence is a signal. The ledger lives in the database it describes, so a run that can’t reach production can’t report either. The dashboard reads the gap as a late backup. Rows are kept for good, one a night.
Two shapes, over a quarter of the schema
Thirteen domains, but over a quarter of all tables come in one of two shapes. Every conversation — event chat, direct messages, channels, decision chat, trip chat — is the same trio of tables under its own prefix, and a new chat kind copies it verbatim.
The second shape is the invite pair. Events, decisions, trips and notes — everything a person can be invited to — each carry a reusable join link and a table of invitations to people who aren’t on Cue yet:
invite_linkspending_email_invitesdecision_invite_linksdecision_pending_email_invitestrip_invite_linkstrip_pending_email_invitesnote_invite_linksnote_pending_email_invitesEach has at most one link — a note one per role, editor or viewer — carrying a 10-character token. An email invitation is unique per address and is redeemed when that person signs up.
Rules every table follows
A handful of conventions run through the whole schema. Learn them once and every table reads the same way.
Every change ships as a numbered V<n>__*.sql migration, and a shipped one is never edited — Flyway checksums them. Hibernate only validates.
Enum-shaped columns are VARCHAR holding the lowercase wire value, written by a custom AttributeConverter. Newer tables pin closed sets with a CHECK; open vocabularies deliberately get none.
Per-slot scores, fit scores, cited sources and cover crops live in JSONB, so their structure can evolve without a migration.
Every read cursor is keyed on (user_id, conversation) — a composite primary key with no surrogate id to look up.
Read cursors point at the last message read with ON DELETE SET NULL, so deleting a message clears a cursor instead of failing or taking the read state with it. Every chat kind since was born that way.
No second owner, no second pending request, no two active AIs with one name: invariants over kept history are partial uniques. The trash, stuck transcodes and live session families get partial indexes too.
Photos and notes get a 30-day trash, and removed AI participants keep their rows so old messages still attribute. Everything else hard-deletes.
Thirteen tables carry a generated tsvector column, and twelve of them a trigram index beside it. A generated column can’t drift from the row it summarizes.
Keep exploring
Built by one person and a very capable AI.