Skip to content

Data model overview

All durable state in Soundverse 2.0 lives in one PostgreSQL instance, and exactly one service is allowed to open a connection string to it: core-database. The schemas are partitioned by domain, but they share a single database on purpose — so every cross-domain reference can be a real foreign key that Postgres enforces, not a dangling id that application code hopes is valid.

This page is the map of that database: the core entities, how the tiers split (Postgres / Redis / Azure Blob), and the one deploy caveat worth knowing. The deep dives — the money path, the queue, pricing, storage — each get their own page; this is the lay of the land.

erDiagram
  USERS ||--o{ WORKSPACES : owns
  WORKSPACES ||--o{ PROJECTS : contains
  PROJECTS ||--o{ MESSAGES : has
  MESSAGES ||--o| TASKS : triggers
  TOOLS ||--o{ TASKS : runs
  TOOLS ||--o{ TOOL_CONFIGS : priced_by
  TOOL_CONFIGS ||--o{ TOOL_CONFIG_LICENSES : per_license
  USERS ||--|| TOKEN_BALANCES : has
  TOKEN_BALANCES ||--o{ TOKEN_LEDGER : audited_by
  TASKS ||--o{ TOKEN_LEDGER : reserves_settles
  USERS ||--o{ TASKS : owns
  USERS ||--o{ FILES : owns
  TASKS ||--o{ FILES : produces
  FILES }o--|| BLOBS : references
  USERS {
    uuid id PK
    string email
    string username
  }
  TASKS {
    uuid id PK
    task_status status
    uuid tool_id FK
    uuid user_id FK
    bigint reserved_token_amount
    text billing_idempotency_key
    timestamptz settled_at
  }
  TOKEN_BALANCES {
    uuid user_id PK
    bigint base_tokens
    bigint extra_tokens
    timestamptz base_expires_at
  }
  TOKEN_LEDGER {
    uuid id PK
    string idempotency_key UK
    bigint amount
    string reason
  }
  BLOBS {
    string hash PK
    text blob_url
    bigint size_bytes
    string mime_type
  }
  FILES {
    uuid id PK
    uuid user_id FK
    string blob_hash FK
  }

The schemas are partitioned by domain — the core ones are identity, billing, generation, storage, and chat (with dna, migration, compat, notifications, collabstudio, and growth on the periphery). Because they live in one database, a task can point at a tool with tool_id UUID REFERENCES generation.tools(id), a ledger entry can point at its user with REFERENCES identity.users(id) ON DELETE CASCADE, and a produced file can point at the task that made it. Referential integrity is a database property, not an application convention.

Nothing else touches Postgres. Gateways, tool workers, and core-mcp all reach data only by calling core-database over Connect/gRPC, authenticated with an Authorization: Bearer header carrying the shared INTERNAL_RPC_SECRET. The payoff: the data layer is declared as proto annotations (sql_query, cache_key, require_internal_auth) and code-generated, so there is exactly one place that owns each query and one place that owns its auth. See The contract: proto as source of truth and How the DB codegen plugin works.

Not everything is Postgres. State is split across three tiers by durability requirement:

Tier What it stores Source of truth?
PostgreSQL Users, balances, ledger, tasks, tools + pricing, file/blob metadata Yes — durable, the only authority
Redis Cache-aside reads, rate-limit windows, task-event streams, task:wake signals No — short TTLs; reconstructable from Postgres
Azure Blob The actual file bytes (audio masters, streaming variants, images) Bytes only — every blob has a row in storage.blobs

Redis is a cache-aside layer: readers consult Redis, fall back to core-database on a miss, and repopulate with a short TTL. Because TTLs are short and Postgres is authoritative, a cold or flushed Redis is a latency event, never a correctness event. Azure Blob follows the same rule — the bytes live in object storage, but the truth about a blob (its hash, size, MIME type, owning files) is a row in storage.blobs. See Storage & media plane for the blob lifecycle.

Users & identity — JIT on (provider, provider_subject)

Section titled “Users & identity — JIT on (provider, provider_subject)”

identity.users holds stable identity only — no passwords. A login from the external IdP (self-hosted Logto OIDC — the platform migrated off Zitadel) is mapped through identity.user_auth_identities, which is UNIQUE (provider, provider_subject). core-identity provisions a user just-in-time on first sight of that pair, converging on a verified email so one human arriving via multiple connectors links onto the existing same-email user instead of forking a duplicate. Email and username are unique-when-present and case-insensitive (via lower() partial indexes), and soft-deleted rows release them for reuse. Details: Identity & auth.

Token balances & the ledger — money as an auditable primitive

Section titled “Token balances & the ledger — money as an auditable primitive”

Money lives in two billing tables: billing.token_balances (the current spendable amount, split into an expiring base_tokens bucket and a never-expiring extra_tokens bucket) and billing.token_ledger (an append-only audit trail of every movement). The ledger is built to be untrustable-by-mistake — two structural guarantees:

billing/tokens/v1/schema.sql — enforced by the DB, not by convention
-- History cannot be rewritten; corrections are compensating entries.
CREATE TRIGGER trg_token_ledger_no_mutation
BEFORE UPDATE OR DELETE ON billing.token_ledger
FOR EACH ROW EXECUTE FUNCTION billing.reject_ledger_mutation();
-- The same idempotency key can be written at most once.
CREATE UNIQUE INDEX uq_token_ledger_idempotency
ON billing.token_ledger (idempotency_key)
WHERE idempotency_key IS NOT NULL;

Together with CHECK constraints and single-row FOR UPDATE locking, these make it impossible to silently lose or double-apply money. The full estimate → reserve → settle lifecycle — and why concurrent settlement can’t double-charge — is The token ledger & the money path.

There is no separate broker. generation.tasks is the work queue, and Postgres row-locking gives it exactly-one-claim semantics. A task flows through a small status enum — queued → dispatched → processing → completed | failed (dispatched is matched only by the stale-task reaper for never-claimed/legacy rows). Each row carries its own billing context: reserved_token_amount, reservation_ledger_entry_id, billing_idempotency_key, reported_usage, and a settled_at that stays NULL until the reconciler sweeps it. Ownership of a claimed task is a time-based lease (claim_expires_at), not a held lock. The claim/lease/heartbeat/reaper mechanics are Task queue on Postgres.

A “tool” is a registry row in generation.tools, unique on (environment, model, operation); its input_schema JSONB column is the Pydantic-generated JSON Schema that also drives the schema-driven AI Tools panel. Its price lives in separate rows: generation.tool_configs (scoped platform / workspace / project / user) → generation.tool_config_licenses (one row per license carrying cost_mode, cost_base, cost_increment). A registered tool with no pricing row is the single most common cause of a NotFound at chat time. See Tool pricing model.

Files & blobs — content-addressed, visibility-scoped

Section titled “Files & blobs — content-addressed, visibility-scoped”

storage.blobs is keyed by a content hash; storage.files is the user-facing object (name, tags, owning workspace/project/task) and references its blob by blob_hash. The dedup key is not the raw file hash — it folds in the storage container so identical bytes dedup within a visibility tier but never collapse across tiers (making a private asset public can’t expose a shared one):

core-storage app/grpc_server/servicers/storage.py
db_blob_hash = hashlib.sha256(
f"{raw_file_hash}:{container_name}".encode()
).hexdigest()

One deduped master can back many storage.files rows and many derived storage.blob_variants. Downloads are served as short-lived Azure SAS URLs minted on demand — the permanent blob_url in the row is never handed to a client. Full pipeline: Storage & media plane. New per-file metadata belongs in storage.attributes, not the metadata JSONB bag — see Add durable per-file metadata.

User-content cascade: what happens when a user is deleted Schema fixed; prod migration not applied

Section titled “User-content cascade: what happens when a user is deleted ”

generation.tasks.user_id and storage.files.user_id were historically VARCHAR(255) with no FK to identity.users, so hard-deleting (or recreating) a user orphaned their content — it stayed in the DB but was unreachable, since ListFiles/ListUserTasks scope on the caller’s current uuid. Recreating your own account mints a new uuid and orphans the old library.

The schema source now fixes this: both columns are UUID NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, mirroring the existing storage.files.task_id → generation.tasks(id) precedent. A fresh init (docker-compose, new environments) gets the FK, because the load order is identity → … → generation.task → … → storage, keeping the cross-schema FK init-safe.

Semantics once applied: a hard delete cascades away the user’s files and tasks (metadata side-tables already cascade off storage.files(id); blobs are ON DELETE RESTRICT and stay, since they are dedup-shared and garbage-collected separately). A soft delete (deleted_at) retains content during a grace window; a retention job hard-deletes long-soft-deleted users to reclaim it.