# Lattice Cloud A **Lattice cloud** is a shared Postgres database protected by real Postgres **Row-Level Security (RLS)**. Several people connect to the same database and each sees only their own rows plus the rows others have explicitly shared — and that boundary is enforced by Postgres itself, not by any application code. There is exactly **one** concept here: the cloud. A cloud _is_ the set of people who can connect to it; there is no separate "team" object to create and no "enable sharing" step. We call the people on a cloud its **members**, the person who set it up its **owner**, and the database itself the **cloud**. > **SQLite is single-user and local.** RLS and the cloud model are Postgres-only. > A local SQLite Lattice is just your private store; every cloud installer in the > library is a no-op on SQLite. To go from a local Lattice to a shared one, you > **migrate** it into a cloud Postgres (see below). --- ## What makes it different: no server There is **no Lattice server process**. There is no HTTP API in front of Postgres, no bearer tokens, no replica, and no sync client. A cloud is _only_ a Postgres database with RLS installed. ``` ┌─ alice (owner) ────────┐ ┌─ bob (member) ─────────┐ ┌─ carol (member) ──────┐ │ psql / lattice gui │ │ psql / lattice gui │ │ psql / lattice gui │ │ role: alice │ │ role: lm_bob_a91c │ │ role: lm_carol_3f70 │ │ password: •••••• │ │ password: •••••• │ │ password: •••••• │ └───────────┬────────────┘ └───────────┬────────────┘ └──────────┬────────────┘ │ direct postgres:// connection, each as its OWN scoped role │ └──────────────────────────────┬──────────────────────────────────┘ ▼ ┌─ Cloud Postgres (RLS installed) ────────────┐ │ your user-defined tables │ │ └ ENABLE + FORCE ROW LEVEL SECURITY │ │ __lattice_owners (row → owner role) │ │ __lattice_row_grants (custom grants) │ │ __lattice_changes (append-only feed) │ │ lattice_members (group role) │ │ SECURITY DEFINER fns: lattice_row_visible, │ │ lattice_set_row_visibility, _grant, … │ └─────────────────────────────────────────────┘ ``` **Each member connects directly to Postgres as their own scoped, non-superuser role** — never a shared owner/superuser connection string. Postgres RLS is the security boundary: a member who opens a raw `psql` against their own connection **physically cannot read or write another member's rows**. There is no privileged layer to bypass because there is no layer at all — the database is the boundary. The DBA's entire job is to **set up the Postgres database and create usernames/passwords**. Lattice installs the security model on top using plain SQL: `CREATE ROLE`, `CREATE POLICY`, `FORCE ROW LEVEL SECURITY`, and a handful of `SECURITY DEFINER` functions. ### Identity is the Postgres role A member's identity is simply **which Postgres role they authenticated as**. Policies key on `session_user` / `current_user`, which Postgres resolves from the login — it is reliable even behind a **transaction-mode connection pooler**, where `SET LOCAL`-based identity schemes break because a pooled transaction can land on any backend. Because the login role _is_ the identity, there is nothing to spoof: to act as another member you would need that member's password. --- ## The security model Lattice installs the cloud security model in two parts, both via plain SQL migrations against the cloud Postgres. ### 1. Bootstrap (once per cloud) `installCloudRls(db)` creates the shared machinery: - **`lattice_members`** — a `NOLOGIN` group role. Table and schema privileges are granted to the group, so adding a member or a shared table is a single `GRANT`. The group grants _access_; RLS still filters _visibility_ per individual login role. Membership in the group never lets you see another member's rows. - **`__lattice_owners`** `(table_name, pk, owner_role, visibility, …)` — the out-of-band record of who owns each row and how widely it's shared (`private` | `everyone` | `custom`). It is never injected into your tables and members cannot read or write it directly. - **`__lattice_row_grants`** `(table_name, pk, grantee_role, granted_by, …)` — the explicit grant list backing `custom` visibility. - **`__lattice_changes`** — an append-only change feed (`seq`, `table_name`, `pk`, `op`, `owner_role`, `created_at`). A per-row `AFTER INSERT` trigger fires `pg_notify('lattice_changes', …)` carrying only _metadata_ (table, pk, op) — never row content — so a connected GUI can refetch the affected row _through RLS_. - **`SECURITY DEFINER` functions** that read the bookkeeping a member can't: - `lattice_row_visible(table, pk)` — the visibility predicate the policies call, keyed on `session_user`. A row with no ownership record is visible to nobody. - `lattice_set_row_visibility(table, pk, visibility)` — owner-only; raises if the caller isn't the row's owner. - `lattice_grant_row(table, pk, grantee)` / `lattice_revoke_row(table, pk, grantee)` — owner-only; manage the `custom` grant list. Every cloud `SECURITY DEFINER` function pins `search_path = "", pg_temp` (the cloud schema first, `pg_temp` **last**). Without the pin, a definer function resolves unqualified table names via the _caller's_ `pg_temp` first, so a member could `CREATE TEMP TABLE __lattice_owners(...)` to shadow the bookkeeping and make the visibility check return whatever they like — a full RLS bypass. The pin forces every unqualified name to resolve against the real schema; the installer also revokes schema `CREATE` from `PUBLIC` as defense-in-depth. ### 2. Per-table RLS `enableRlsForTable(db, table, pkCols)` secures one shared table: ```sql ALTER TABLE "items" ENABLE ROW LEVEL SECURITY; ALTER TABLE "items" FORCE ROW LEVEL SECURITY; -- applies even to the table owner GRANT SELECT, INSERT, UPDATE, DELETE ON "items" TO lattice_members; CREATE POLICY "lattice_sel" ON "items" FOR SELECT USING (lattice_row_visible('items', CAST("id" AS TEXT))); CREATE POLICY "lattice_upd" ON "items" FOR UPDATE USING (...) WITH CHECK (...); CREATE POLICY "lattice_del" ON "items" FOR DELETE USING (...); CREATE POLICY "lattice_ins" ON "items" FOR INSERT WITH CHECK (true); ``` plus a per-table `SECURITY DEFINER` trigger that, on every write, stamps the inserting member as the row's owner in `__lattice_owners` and records the change in `__lattice_changes`. Members cannot write the bookkeeping tables directly — only the definer-owned trigger can. `FORCE ROW LEVEL SECURITY` is the critical flag: without it, the table's owner role would bypass its own policies. With it, the policies apply to everyone, so the cloud owner is bound by the same row rules as any member. > **Composite keys.** The `pk` string written to `__lattice_owners.pk` uses > Lattice's canonical serialization: a single-column key is the bare value; a > composite key is its columns joined by a TAB (`chr(9)`). This is the same key the > change feed and the row-visibility functions use. > **Empirically verified.** Two non-superuser roles connecting directly cannot > see, update, or delete each other's private rows; cannot read the bookkeeping > tables; cannot `DISABLE ROW LEVEL SECURITY`; and cannot `SET ROLE` to another > member. --- ## The role & privilege model | Who | Postgres role attributes | Can do | | ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | **Owner** | a normal login role with **`CREATEROLE`** | migrate a local Lattice in, install RLS, provision members, share/unshare their own rows | | **Member** | a login role, **`NOSUPERUSER NOCREATEDB NOCREATEROLE`**, in the `lattice_members` group, with RLS **forced** | read/write only their own + shared rows; cannot escalate, cannot run DDL, cannot read another member's data | A member's credential is a dead end for privilege escalation: the role is `NOSUPERUSER`, can't create roles or databases, isn't granted DDL on the schema, and RLS is forced on every shared table. The DBA may either let an owner provision member roles (the owner connection needs `CREATEROLE`), or pre-create the login roles by hand and skip provisioning entirely — Lattice only needs the roles to exist and to be members of `lattice_members`. --- ### Cloud sharing internals consolidated (v3.4) The internal machinery backing row sharing was refactored in v3.4 with **no behavior change** to the live features: row `private` / `everyone` / custom "specific people" sharing, table `default_row_visibility` and `never_share`, and the `owner` secret-column mask all work identically. The consolidation removed unreachable masking machinery and moved permission checks into single `SECURITY DEFINER` helpers, eliminating the risk of regressions when sharing logic changes. The changes are additive and idempotent — clouds converge safely on an owner's next open. ## Sharing: private by default Every row is **private to its owner** the moment it's written — the per-table trigger stamps `visibility = 'private'`. The owner opts a row into wider visibility: - **`private`** — only the owner sees it (the default). - **`everyone`** — every member of the cloud sees it. - **`custom`** — only the owner plus an explicit grant list (`lattice_grant_row`). Sharing is done through the owner-only `SECURITY DEFINER` function — Postgres raises for anyone who isn't the row's owner: ```sql -- Make one row visible to every member of the cloud: SELECT lattice_set_row_visibility('items', 'item-42', 'everyone'); -- Take it private again: SELECT lattice_set_row_visibility('items', 'item-42', 'private'); -- Or grant just one member (sets visibility = 'custom'): SELECT lattice_grant_row('items', 'item-42', 'lm_bob_a91c'); SELECT lattice_revoke_row('items', 'item-42', 'lm_bob_a91c'); ``` From the library, the same thing through `setRowVisibility` (validates `private` | `everyone` before calling the function): ```ts import { Lattice, setRowVisibility } from 'latticesql'; const db = new Lattice('postgres://alice:secret@cloud.example.com:5432/app'); await db.init(); // 'item-42' is the row's canonical primary-key string (composite keys are // TAB-joined). Only the row's owner may call this; Postgres raises otherwise. await setRowVisibility(db, 'items', 'item-42', 'everyone'); ``` Because sharing lives in `__lattice_owners` (out of band), opting a row in or out never touches your table's columns. ### Per-table defaults & never-share (v3.1) The owner can set a table's policy in `__lattice_table_policy` — **stored and enforced in Postgres**, so a direct `psql` insert obeys it too: - **`default_row_visibility`** (`private` | `everyone`) — the visibility NEW rows in that table are stamped with. The per-table insert trigger reads it; default `private` (unchanged behavior). `setTableDefaultVisibility(db, table, vis)`. - **`never_share`** — a hard exclusion. `lattice_set_row_visibility` / `lattice_grant_row` / `lattice_grant_cell` RAISE for the table, and its new rows are forced private regardless of the default. `setTableNeverShare(db, table, on)`. Turning it **on is retroactive**: any already-shared row is reset to private and every row/cell grant on the table is dropped, so flagging an existing table never-share never leaves previously-shared rows visible. `secrets` is seeded never-share by `secureCloud`. ```ts import { setTableDefaultVisibility, setTableNeverShare } from 'latticesql'; await setTableDefaultVisibility(db, 'tickets', 'everyone'); // team-shared by default await setTableNeverShare(db, 'secrets', true); // can never be shared, ever ``` **"Private mode"** in the GUI chat composer is a per-request override: when on, rows the assistant creates that turn are forced private regardless of the table default. The row is stamped private **atomically at insert** (`insertForcingVisibility` sets a transaction-local GUC the insert trigger reads), so it is never momentarily visible at the table default and the change-feed `NOTIFY` only fires once it is already private — there is no create-then-demote window. **Secret columns (`owner` audience).** Marking a column secret stores an `owner` audience in `__lattice_column_policy`, so Postgres masks it to everyone but the row owner via the generated `_v` view — the DB is the boundary (the assistant-side redaction is just model-context safety). See _Per-column audiences_ below; the spec now lives canonically in the DB and the mask view regenerates from it on change. --- ## Opening the cloud & the converge When any member opens a cloud, Lattice runs a **converge** pass: it reads the current Postgres schema against the workspace's registered entities and reconciles them — granting table/column privileges to the member group, rebuilding masking views, installing any missing RLS machinery. Since v3.4, the converge is **per-table fault-isolated**: if the connecting role cannot `ALTER` or `GRANT` a table (most often because it was created by a different Postgres role), that one table is skipped with an actionable reason instead of failing the whole workspace and degrading all objects to "Failed to fetch". The skip is reported in `GET /api/dbconfig` as `convergeWarnings`, for example: `"owned by role X, but this workspace connects as Y — fix with: `ALTER TABLE … OWNER TO Y`"`. `POST /api/workspaces/reload` re-reads the config and re-registers entities in place without restarting the GUI, so a table added out-of-band surfaces immediately. ### Plaintext database URL heal-on-open If a workspace config stores a raw `postgres://…` connection string (with its password in cleartext) in the `db:` line — typically from an older workspace or a migration — opening it now automatically moves the URL into the encrypted credential store and rewrites the line to a `${LATTICE_DB:
_v` beside the base table: ```yaml person: fields: id: { type: uuid, primaryKey: true } name: { type: text } comp: { type: text, audience: 'subject:subject_role+role:hr' } subject_role: { type: text } ``` The audience is a `+`-joined (OR) set of clauses — `role:`, `subject:`, `source:`, or `everyone`. Each compiles to a `session_user`-keyed `SECURITY DEFINER` predicate (`lattice_has_role` / `lattice_is_subject` / `lattice_source_visible`), so the mask binds to the real member even though the view runs with its owner's rights. Members `SELECT` the view (base `SELECT` is revoked); a masked cell reads as `NULL`. App roles are owner-managed via `lattice_assign_role(member, role)` — members can't self-promote. Generated by `enableAudienceView`, never hand-edited. **2. Per-viewer values — a local fold.** When a value is _derived_ from a source (e.g. an enrichment computed from a file), it is only valid for a member who can see that source. `foldEntity(ground, observations, viewer)` (in `latticesql`) overlays the observations a viewer is allowed to see onto the ground-truth projection — latest visible observation per attribute wins. It is a pure, deterministic, additive fold, so a member who can't reach a source simply never sees its derived value, and **un-sharing the source reverts the value with no residue** (revocation is structural, not a cleanup job). Run it on the member's local replica over already-gated observations, cached with `FoldCache` and re-rendered only when an observation changes — egress is paid once at pull. **Change-log visibility.** The observation substrate (`__lattice_changelog`) is RLS- protected with two cases. A **derived** observation is visible only to a member who can reach every source it was derived from (the source-visibility predicate above) — existence-hiding is structural. A **ground-truth / audit** entry is **owner-only** (`lattice_is_owner`): it carries the full row in cleartext, including columns the `
_v` mask hides from a non-owner, so the row's raw history is an owner artifact — a member who can merely see a shared row reads it through the masked view, never its change-log. **3. Forgetting a source — crypto-shred.** For a legally sensitive source, seal its derived values under a per-source key (`sealUnderSource`) and call `shredSource` to destroy the key — the values become unrecoverable everywhere the ciphertext exists, backups included. **Source sharing is direct (for now).** Source visibility reduces to the source row's own RLS. Transitive source-sharing (folders / teams / inheritance, a ReBAC-style model) is intentionally deferred until that shape is actually needed — the primitives above compose with it when it lands.