latticesql 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cloud.md ADDED
@@ -0,0 +1,675 @@
1
+ # Lattice Cloud
2
+
3
+ A **Lattice cloud** is a shared Postgres database protected by real Postgres
4
+ **Row-Level Security (RLS)**. Several people connect to the same database and each
5
+ sees only their own rows plus the rows others have explicitly shared — and that
6
+ boundary is enforced by Postgres itself, not by any application code.
7
+
8
+ There is exactly **one** concept here: the cloud. A cloud _is_ the set of people
9
+ who can connect to it; there is no separate "team" object to create and no "enable
10
+ sharing" step. We call the people on a cloud its **members**, the person who set it
11
+ up its **owner**, and the database itself the **cloud**.
12
+
13
+ > **SQLite is single-user and local.** RLS and the cloud model are Postgres-only.
14
+ > A local SQLite Lattice is just your private store; every cloud installer in the
15
+ > library is a no-op on SQLite. To go from a local Lattice to a shared one, you
16
+ > **migrate** it into a cloud Postgres (see below).
17
+
18
+ ---
19
+
20
+ ## What makes it different: no server
21
+
22
+ There is **no Lattice server process**. There is no HTTP API in front of Postgres,
23
+ no bearer tokens, no replica, and no sync client. A cloud is _only_ a Postgres
24
+ database with RLS installed.
25
+
26
+ ```
27
+ ┌─ alice (owner) ────────┐ ┌─ bob (member) ─────────┐ ┌─ carol (member) ──────┐
28
+ │ psql / lattice gui │ │ psql / lattice gui │ │ psql / lattice gui │
29
+ │ role: alice │ │ role: lm_bob_a91c │ │ role: lm_carol_3f70 │
30
+ │ password: •••••• │ │ password: •••••• │ │ password: •••••• │
31
+ └───────────┬────────────┘ └───────────┬────────────┘ └──────────┬────────────┘
32
+ │ direct postgres:// connection, each as its OWN scoped role │
33
+ └──────────────────────────────┬──────────────────────────────────┘
34
+
35
+ ┌─ Cloud Postgres (RLS installed) ────────────┐
36
+ │ your user-defined tables │
37
+ │ └ ENABLE + FORCE ROW LEVEL SECURITY │
38
+ │ __lattice_owners (row → owner role) │
39
+ │ __lattice_row_grants (custom grants) │
40
+ │ __lattice_changes (append-only feed) │
41
+ │ lattice_members (group role) │
42
+ │ SECURITY DEFINER fns: lattice_row_visible, │
43
+ │ lattice_set_row_visibility, _grant, … │
44
+ └─────────────────────────────────────────────┘
45
+ ```
46
+
47
+ **Each member connects directly to Postgres as their own scoped, non-superuser
48
+ role** — never a shared owner/superuser connection string. Postgres RLS is the
49
+ security boundary: a member who opens a raw `psql` against their own connection
50
+ **physically cannot read or write another member's rows**. There is no privileged
51
+ layer to bypass because there is no layer at all — the database is the boundary.
52
+
53
+ The DBA's entire job is to **set up the Postgres database and create
54
+ usernames/passwords**. Lattice installs the security model on top using plain SQL:
55
+ `CREATE ROLE`, `CREATE POLICY`, `FORCE ROW LEVEL SECURITY`, and a handful of
56
+ `SECURITY DEFINER` functions.
57
+
58
+ ### Identity is the Postgres role
59
+
60
+ A member's identity is simply **which Postgres role they authenticated as**.
61
+ Policies key on `session_user` / `current_user`, which Postgres resolves from the
62
+ login — it is reliable even behind a **transaction-mode connection pooler**, where
63
+ `SET LOCAL`-based identity schemes break because a pooled transaction can land on
64
+ any backend. Because the login role _is_ the identity, there is nothing to spoof:
65
+ to act as another member you would need that member's password.
66
+
67
+ ---
68
+
69
+ ## The security model
70
+
71
+ Lattice installs the cloud security model in two parts, both via plain SQL
72
+ migrations against the cloud Postgres.
73
+
74
+ ### 1. Bootstrap (once per cloud)
75
+
76
+ `installCloudRls(db)` creates the shared machinery:
77
+
78
+ - **`lattice_members`** — a `NOLOGIN` group role. Table and schema privileges are
79
+ granted to the group, so adding a member or a shared table is a single `GRANT`.
80
+ The group grants _access_; RLS still filters _visibility_ per individual login
81
+ role. Membership in the group never lets you see another member's rows.
82
+ - **`__lattice_owners`** `(table_name, pk, owner_role, visibility, …)` — the
83
+ out-of-band record of who owns each row and how widely it's shared
84
+ (`private` | `everyone` | `custom`). It is never injected into your tables and
85
+ members cannot read or write it directly.
86
+ - **`__lattice_row_grants`** `(table_name, pk, grantee_role, granted_by, …)` — the
87
+ explicit grant list backing `custom` visibility.
88
+ - **`__lattice_changes`** — an append-only change feed (`seq`, `table_name`, `pk`,
89
+ `op`, `owner_role`, `created_at`). A per-row `AFTER INSERT` trigger fires
90
+ `pg_notify('lattice_changes', …)` carrying only _metadata_ (table, pk, op) — never
91
+ row content — so a connected GUI can refetch the affected row _through RLS_.
92
+ - **`SECURITY DEFINER` functions** that read the bookkeeping a member can't:
93
+ - `lattice_row_visible(table, pk)` — the visibility predicate the policies call,
94
+ keyed on `session_user`. A row with no ownership record is visible to nobody.
95
+ - `lattice_set_row_visibility(table, pk, visibility)` — owner-only; raises if the
96
+ caller isn't the row's owner.
97
+ - `lattice_grant_row(table, pk, grantee)` / `lattice_revoke_row(table, pk, grantee)`
98
+ — owner-only; manage the `custom` grant list.
99
+
100
+ Every cloud `SECURITY DEFINER` function pins `search_path = "<schema>", pg_temp`
101
+ (the cloud schema first, `pg_temp` **last**). Without the pin, a definer function
102
+ resolves unqualified table names via the _caller's_ `pg_temp` first, so a member
103
+ could `CREATE TEMP TABLE __lattice_owners(...)` to shadow the bookkeeping and make
104
+ the visibility check return whatever they like — a full RLS bypass. The pin forces
105
+ every unqualified name to resolve against the real schema; the installer also
106
+ revokes schema `CREATE` from `PUBLIC` as defense-in-depth.
107
+
108
+ ### 2. Per-table RLS
109
+
110
+ `enableRlsForTable(db, table, pkCols)` secures one shared table:
111
+
112
+ ```sql
113
+ ALTER TABLE "items" ENABLE ROW LEVEL SECURITY;
114
+ ALTER TABLE "items" FORCE ROW LEVEL SECURITY; -- applies even to the table owner
115
+ GRANT SELECT, INSERT, UPDATE, DELETE ON "items" TO lattice_members;
116
+
117
+ CREATE POLICY "lattice_sel" ON "items" FOR SELECT USING (lattice_row_visible('items', CAST("id" AS TEXT)));
118
+ CREATE POLICY "lattice_upd" ON "items" FOR UPDATE USING (...) WITH CHECK (...);
119
+ CREATE POLICY "lattice_del" ON "items" FOR DELETE USING (...);
120
+ CREATE POLICY "lattice_ins" ON "items" FOR INSERT WITH CHECK (true);
121
+ ```
122
+
123
+ plus a per-table `SECURITY DEFINER` trigger that, on every write, stamps the
124
+ inserting member as the row's owner in `__lattice_owners` and records the change in
125
+ `__lattice_changes`. Members cannot write the bookkeeping tables directly — only the
126
+ definer-owned trigger can.
127
+
128
+ `FORCE ROW LEVEL SECURITY` is the critical flag: without it, the table's owner role
129
+ would bypass its own policies. With it, the policies apply to everyone, so the cloud
130
+ owner is bound by the same row rules as any member.
131
+
132
+ > **Composite keys.** The `pk` string written to `__lattice_owners.pk` uses
133
+ > Lattice's canonical serialization: a single-column key is the bare value; a
134
+ > composite key is its columns joined by a TAB (`chr(9)`). This is the same key the
135
+ > change feed and the row-visibility functions use.
136
+
137
+ > **Empirically verified.** Two non-superuser roles connecting directly cannot
138
+ > see, update, or delete each other's private rows; cannot read the bookkeeping
139
+ > tables; cannot `DISABLE ROW LEVEL SECURITY`; and cannot `SET ROLE` to another
140
+ > member.
141
+
142
+ ---
143
+
144
+ ## The role & privilege model
145
+
146
+ | Who | Postgres role attributes | Can do |
147
+ | ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
148
+ | **Owner** | a normal login role with **`CREATEROLE`** | migrate a local Lattice in, install RLS, provision members, share/unshare their own rows |
149
+ | **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 |
150
+
151
+ A member's credential is a dead end for privilege escalation: the role is
152
+ `NOSUPERUSER`, can't create roles or databases, isn't granted DDL on the schema, and
153
+ RLS is forced on every shared table.
154
+
155
+ The DBA may either let an owner provision member roles (the owner connection needs
156
+ `CREATEROLE`), or pre-create the login roles by hand and skip provisioning entirely —
157
+ Lattice only needs the roles to exist and to be members of `lattice_members`.
158
+
159
+ ---
160
+
161
+ ## Sharing: private by default
162
+
163
+ Every row is **private to its owner** the moment it's written — the per-table
164
+ trigger stamps `visibility = 'private'`. The owner opts a row into wider visibility:
165
+
166
+ - **`private`** — only the owner sees it (the default).
167
+ - **`everyone`** — every member of the cloud sees it.
168
+ - **`custom`** — only the owner plus an explicit grant list (`lattice_grant_row`).
169
+
170
+ Sharing is done through the owner-only `SECURITY DEFINER` function — Postgres raises
171
+ for anyone who isn't the row's owner:
172
+
173
+ ```sql
174
+ -- Make one row visible to every member of the cloud:
175
+ SELECT lattice_set_row_visibility('items', 'item-42', 'everyone');
176
+
177
+ -- Take it private again:
178
+ SELECT lattice_set_row_visibility('items', 'item-42', 'private');
179
+
180
+ -- Or grant just one member (sets visibility = 'custom'):
181
+ SELECT lattice_grant_row('items', 'item-42', 'lm_bob_a91c');
182
+ SELECT lattice_revoke_row('items', 'item-42', 'lm_bob_a91c');
183
+ ```
184
+
185
+ From the library, the same thing through `setRowVisibility` (validates `private` |
186
+ `everyone` before calling the function):
187
+
188
+ ```ts
189
+ import { Lattice, setRowVisibility } from 'latticesql';
190
+
191
+ const db = new Lattice('postgres://alice:secret@cloud.example.com:5432/app');
192
+ await db.init();
193
+
194
+ // 'item-42' is the row's canonical primary-key string (composite keys are
195
+ // TAB-joined). Only the row's owner may call this; Postgres raises otherwise.
196
+ await setRowVisibility(db, 'items', 'item-42', 'everyone');
197
+ ```
198
+
199
+ Because sharing lives in `__lattice_owners` (out of band), opting a row in or out
200
+ never touches your table's columns.
201
+
202
+ ### Per-table defaults & never-share (v3.1)
203
+
204
+ The owner can set a table's policy in `__lattice_table_policy` — **stored and
205
+ enforced in Postgres**, so a direct `psql` insert obeys it too:
206
+
207
+ - **`default_row_visibility`** (`private` | `everyone`) — the visibility NEW rows in
208
+ that table are stamped with. The per-table insert trigger reads it; default
209
+ `private` (unchanged behavior). `setTableDefaultVisibility(db, table, vis)`.
210
+ - **`never_share`** — a hard exclusion. `lattice_set_row_visibility` /
211
+ `lattice_grant_row` / `lattice_grant_cell` RAISE for the table, and its new rows
212
+ are forced private regardless of the default. `setTableNeverShare(db, table, on)`.
213
+ Turning it **on is retroactive**: any already-shared row is reset to private and
214
+ every row/cell grant on the table is dropped, so flagging an existing table
215
+ never-share never leaves previously-shared rows visible. `secrets` is seeded
216
+ never-share by `secureCloud`.
217
+
218
+ ```ts
219
+ import { setTableDefaultVisibility, setTableNeverShare } from 'latticesql';
220
+ await setTableDefaultVisibility(db, 'tickets', 'everyone'); // team-shared by default
221
+ await setTableNeverShare(db, 'secrets', true); // can never be shared, ever
222
+ ```
223
+
224
+ **"Private mode"** in the GUI chat composer is a per-request override: when on, rows
225
+ the assistant creates that turn are forced private regardless of the table default.
226
+ The row is stamped private **atomically at insert** (`insertForcingVisibility` sets a
227
+ transaction-local GUC the insert trigger reads), so it is never momentarily visible
228
+ at the table default and the change-feed `NOTIFY` only fires once it is already
229
+ private — there is no create-then-demote window.
230
+
231
+ **Secret columns (`owner` audience).** Marking a column secret stores an `owner`
232
+ audience in `__lattice_column_policy`, so Postgres masks it to everyone but the row
233
+ owner via the generated `<table>_v` view — the DB is the boundary (the assistant-side
234
+ redaction is just model-context safety). See _Per-column audiences_ below; the spec
235
+ now lives canonically in the DB and the mask view regenerates from it on change.
236
+
237
+ ---
238
+
239
+ ## The three user flows
240
+
241
+ There are exactly three things you do with a cloud: **migrate** into one, **join**
242
+ an existing one, or **invite** someone to yours.
243
+
244
+ ### 1. Migrate — turn a local Lattice into a cloud
245
+
246
+ You have a local SQLite (or single-user Postgres) Lattice and want to share it. You
247
+ point it at a fresh, empty Postgres database; Lattice copies your data in, installs
248
+ RLS, and stamps **you** as the owner of every migrated row.
249
+
250
+ From the GUI: **Workspace Settings → Migrate to cloud**, which posts to
251
+ `POST /api/dbconfig/migrate-to-cloud` with the target Postgres credentials. The
252
+ handler:
253
+
254
+ 1. **Probes** the target with `probeCloud(url)`. It refuses if the database is
255
+ unreachable (`502`) or is _already_ a Lattice cloud (`409` — migrating into it
256
+ would mix two owners' data; you should **join** it instead).
257
+ 2. Opens a target Lattice matching your schema and **copies every row** (your
258
+ tables plus the native `files` / `secrets`). Encrypted columns round-trip.
259
+ 3. **Installs RLS** (`installCloudRls`) and, for each keyed table,
260
+ **backfills ownership** to your role _before_ forcing RLS (otherwise FORCE would
261
+ hide the not-yet-owned rows from your own backfill `SELECT`), then
262
+ `enableRlsForTable`.
263
+ 4. Archives the local SQLite file, saves the connection string encrypted, and
264
+ rewrites the config's `db:` line to reference it by label.
265
+
266
+ After migration you're connected to the cloud as its owner, your rows are private by
267
+ default, and you can invite members.
268
+
269
+ The same flow from the library:
270
+
271
+ ```ts
272
+ import {
273
+ Lattice,
274
+ openTargetLatticeForMigration,
275
+ migrateLatticeData,
276
+ installCloudRls,
277
+ backfillOwnership,
278
+ enableRlsForTable,
279
+ archiveLocalSqlite,
280
+ } from 'latticesql';
281
+
282
+ const encryptionKey = process.env.LATTICE_ENCRYPTION_KEY;
283
+ const cloudUrl = 'postgres://alice:secret@cloud.example.com:5432/app';
284
+
285
+ const source = new Lattice({ config: './lattice.config.yml' }, { encryptionKey });
286
+ await source.init();
287
+
288
+ const target = await openTargetLatticeForMigration('./lattice.config.yml', cloudUrl, encryptionKey);
289
+ await migrateLatticeData(source, target); // → { tablesCopied, rowsCopied }
290
+
291
+ // Owner-side RLS install. Backfill ownership BEFORE forcing RLS on each table.
292
+ await installCloudRls(target);
293
+ for (const table of target.getRegisteredTableNames()) {
294
+ if (table.startsWith('__lattice_')) continue;
295
+ const pk = target.getPrimaryKey(table);
296
+ if (pk.length === 0) continue; // unkeyable table — no per-row RLS
297
+ await backfillOwnership(target, table, pk);
298
+ await enableRlsForTable(target, table, pk);
299
+ }
300
+ target.close();
301
+
302
+ archiveLocalSqlite('./data/app.db'); // renames to .db.local-bak
303
+ ```
304
+
305
+ ### 2. Join — redeem your email-bound invite token
306
+
307
+ The owner sent you a single **invite token**, bound to your email. From the GUI:
308
+ **+ New workspace… → Join a cloud**, then enter your **email + the token**. The
309
+ token decrypts **locally** — the email is required to derive the key — to the
310
+ scoped connection details the owner minted, and the same connect path runs. The
311
+ member UI never handles a `postgres://` string. It posts to
312
+ `POST /api/cloud/redeem-invite` → the shared join path (`joinCloudAsMember`, the
313
+ same logic as `connect-existing`), which:
314
+
315
+ 1. **Probes** the target as your role. The probe both authenticates the login and
316
+ confirms the database is actually a Lattice cloud (RLS installed). It refuses if
317
+ unreachable (`502`) or if the database isn't a cloud yet (`409` — the owner must
318
+ migrate a local Lattice into it first).
319
+ 2. Saves the credential encrypted, rewrites the config, and opens the cloud. Your
320
+ role can't (and needn't) run DDL — the owner already created the role and RLS
321
+ confines it — so the cloud is opened in introspect-only mode.
322
+
323
+ From there you query the cloud directly and see exactly the rows RLS allows: your
324
+ own, anything shared to `everyone`, and anything granted to you specifically.
325
+
326
+ ```ts
327
+ import { Lattice, probeCloud } from 'latticesql';
328
+
329
+ const url = 'postgres://lm_bob_a91c:the-password@cloud.example.com:5432/app';
330
+
331
+ const probe = await probeCloud(url);
332
+ // → { reachable: true, dialect: 'postgres', isCloud: true }
333
+ if (!probe.reachable) throw new Error(probe.error);
334
+ if (!probe.isCloud) throw new Error('Not a Lattice cloud yet — ask the owner to migrate into it.');
335
+
336
+ const db = new Lattice(url);
337
+ await db.init();
338
+ const visibleItems = await db.query('items'); // RLS-filtered to what you may see
339
+ ```
340
+
341
+ ### 3. Invite — mint an email-bound token for a member
342
+
343
+ You own a cloud and want to add someone. As the owner (your connection holds
344
+ `CREATEROLE`), you provision a scoped member role and hand them a single
345
+ email-bound token that carries its credential.
346
+
347
+ From the GUI: **Workspace Settings → Database Connection → Invite a member**, and
348
+ enter the invitee's **email**. It posts to `POST /api/cloud/invite`, which verifies
349
+ the active database is a cloud and that your role can manage roles (`403`
350
+ otherwise), provisions a fresh scoped role, **asserts it is non-privileged** (never
351
+ a superuser / `CREATEROLE` / `BYPASSRLS` / the owner — refused otherwise), mints the
352
+ token, writes an owner-only audit row (`__lattice_member_invites`; email hashed, no
353
+ password stored), and returns just the token:
354
+
355
+ ```jsonc
356
+ { "ok": true, "token": "AaR…base64url…", "role": "lm_bob_a91c", "email": "bob@example.com" }
357
+ ```
358
+
359
+ Send that token to the invitee (privately); they redeem it with their email in
360
+ flow 2. It expires in ~7 days.
361
+
362
+ **Token threat model (bearer / magic-link grade).** The token _is_ the secret. It
363
+ embeds a random secret plus the scoped credential, encrypted with AES-256-GCM under
364
+ a key derived via `HKDF(token secret)` salted by `scrypt(email)`, with the email as
365
+ GCM AAD — so it decrypts **only with the matching email**. The email _binds_ the
366
+ token (you cannot redeem it without knowing the email) but is not strong
367
+ confidentiality against an attacker holding _both_ the token and the email. The real
368
+ protections are: **private delivery**, **short expiry**, and the embedded credential
369
+ being a **scoped, RLS-confined, revocable** `lm_*` role — so a leaked token grants
370
+ only what that member could already see, and the owner can revoke it.
371
+
372
+ The same thing from the library:
373
+
374
+ ```ts
375
+ import { Lattice, memberRoleName, generateMemberPassword, provisionMemberRole } from 'latticesql';
376
+
377
+ // owner connection — must hold CREATEROLE
378
+ const db = new Lattice('postgres://alice:secret@cloud.example.com:5432/app');
379
+ await db.init();
380
+
381
+ const role = memberRoleName('bob'); // e.g. 'lm_bob_a91c' — collision-safe, ≤63 bytes
382
+ const password = generateMemberPassword(); // 48 hex chars
383
+ await provisionMemberRole(db, role, password);
384
+ // Member is created NOSUPERUSER NOCREATEDB NOCREATEROLE and added to lattice_members.
385
+
386
+ // Hand off: host / port / dbname + user=role + password
387
+ ```
388
+
389
+ Removing a member is the inverse — `revokeMemberRole(db, role)` drops the role.
390
+ (Rows the departed member owned stay in their tables but become unreachable until
391
+ you deliberately reassign or purge them — revoking access is not the same as
392
+ purging their data.)
393
+
394
+ ```ts
395
+ import { Lattice, revokeMemberRole } from 'latticesql';
396
+ await revokeMemberRole(db, 'lm_bob_a91c');
397
+ ```
398
+
399
+ ---
400
+
401
+ ## How a member opens the cloud
402
+
403
+ A member opens the cloud by **connecting directly as their scoped role** — there is
404
+ no separate sign-in. The connection string the owner handed over (host / port /
405
+ database / username / password) is the whole credential. Lattice opens the cloud in
406
+ introspect-only mode for a member: the role is `NOSUPERUSER` without DDL on the
407
+ schema, so it never tries to create or alter tables — it reads the existing schema
408
+ and works against the rows RLS lets it see. Every query, insert, update, and delete
409
+ runs as that role, so RLS scopes the member to their own rows plus whatever has been
410
+ shared with them. Nothing about being a member requires elevated privileges or a
411
+ side channel; the database does all the gatekeeping.
412
+
413
+ ---
414
+
415
+ ## GUI cloud endpoints
416
+
417
+ `lattice gui` drives all three flows from the browser. The relevant endpoints (all
418
+ localhost-only, same model as the rest of the GUI):
419
+
420
+ | Method | Route | Does |
421
+ | ------ | -------------------------------- | -------------------------------------------------------------------- |
422
+ | POST | `/api/dbconfig/migrate-to-cloud` | Migrate the active local Lattice into a fresh cloud (you = owner) |
423
+ | POST | `/api/dbconfig/connect-existing` | Join a cloud directly with scoped credentials (the invite) |
424
+ | POST | `/api/cloud/invite` | Owner provisions a scoped member role; returns the connection blob |
425
+ | POST | `/api/cloud/share` | Owner sets a row's visibility (`private` \| `everyone`) |
426
+ | GET | `/api/cloud/s3-config` | Read this member's S3 config (secret redacted) |
427
+ | POST | `/api/cloud/s3-config` | Owner sets S3 for the cloud (see **S3-backed file bytes** below) |
428
+ | GET | `/api/cloud/system-prompt` | Owner reads the chat system prompt (members get no text) |
429
+ | POST | `/api/cloud/system-prompt` | Owner sets the chat system prompt (see **Chat system prompt** below) |
430
+
431
+ `POST /api/cloud/share` body is `{ table, pk, visibility }` and calls
432
+ `setRowVisibility` under the hood; Postgres raises if you aren't the row's owner.
433
+
434
+ The probe used throughout is `probeCloud(url)`, returning
435
+ `{ reachable, dialect, isCloud }` — `isCloud` is `true` when the target Postgres
436
+ already has the RLS machinery installed.
437
+
438
+ ---
439
+
440
+ ## Offline editing
441
+
442
+ Offline editing is preserved as a **client-side local edit queue**: edits you make
443
+ while disconnected are held locally and replayed when you reconnect. This is a
444
+ client behavior only — it is **not** tied to any replica or sync server (there is
445
+ no server). When you reconnect, the queued writes go to the cloud as your role and
446
+ land under the same RLS rules as any other write.
447
+
448
+ ---
449
+
450
+ ## Chat system prompt (owner-set, per workspace)
451
+
452
+ A cloud **owner** can set a **chat system prompt** that's bundled into every
453
+ member's assistant chat for that workspace — house style, domain facts, a fiscal
454
+ calendar, whatever the team should always have in context. The owner edits it in
455
+ **workspace settings** ("Edit chat system prompt"); members never see the control.
456
+ It's stored in the shared DB (`__lattice_cloud_settings`, key `chat_system_prompt`)
457
+ and injected into the system message of each member's turn, alongside the live
458
+ schema.
459
+
460
+ **Owner-only to view and edit, app-mediated.** Two `SECURITY DEFINER` helpers back
461
+ it (`src/cloud/settings.ts`): `lattice_set_cloud_setting` raises unless the caller
462
+ can create roles (the owner gate), and `lattice_get_cloud_setting` is the read path.
463
+ The member group has **no grant on the table**, so a member's `SELECT` is denied —
464
+ the prompt **value** is unreadable to them through the data API (the System view may
465
+ still list the table's existence + column names from the catalog, like every other
466
+ `__lattice_*` table, but never its contents). And `GET /api/cloud/system-prompt`
467
+ returns the text **only to an owner** (a member gets `canEdit: false` and no text).
468
+
469
+ > **Ceiling — same shape as the S3 feature.** This is **app-mediated** secrecy, not
470
+ > cryptographic. Each member's chat call is assembled in their OWN local process
471
+ > over their OWN scoped connection, so the prompt must be readable by that
472
+ > connection to be injected — which means `lattice_get_cloud_setting` is callable by
473
+ > a member, and a member who goes looking (calling the function in `psql`, or
474
+ > inspecting their app's outbound request to the model) **can** read the prompt.
475
+ > What this guarantees is that the prompt is hidden from the product surface (the
476
+ > UI and every API response), not that it is secret from a member's own SQL session.
477
+ > True secrecy would require a server-side chat relay members can't reach, which the
478
+ > no-server v3 model deliberately doesn't have.
479
+
480
+ On a local SQLite workspace there are no members and nothing to keep secret, so the
481
+ editor is hidden (`GET` reports `supported: false`).
482
+
483
+ ---
484
+
485
+ ## S3-backed file bytes (opt-in)
486
+
487
+ A `files` row is shared by RLS like any other row, but a file's **bytes** are
488
+ written to the uploader's local disk (`<root>/data/blobs/<sha256>`). On a cloud
489
+ that means another member can SELECT the row yet can't fetch the bytes — they live
490
+ on someone else's machine. Enabling S3 closes that gap: uploaded bytes also go to
491
+ an S3 bucket, and any member who can see the row pulls them down in the viewer.
492
+
493
+ **It adds no new access machinery — it rides the same `files`-row RLS.** The serve
494
+ route does `db.get('files', id)` as the member's own scoped role; a row RLS won't
495
+ let them SELECT returns NULL → 404 _before S3 is ever touched_. The object key is
496
+ content-addressed (`<prefix>/<sha256>`) and appears **only** inside that
497
+ RLS-gated row. The bucket credential grants `GetObject`+`PutObject` and nothing
498
+ else — **no `ListBucket`, no `Delete`** — so the key is the only handle to an
499
+ object and there's no way to enumerate the bucket. A member learns a key exactly
500
+ when, and only when, RLS lets them read the row that holds it.
501
+
502
+ ### Enabling it
503
+
504
+ S3 config is **per member, machine-local, and encrypted** — it sits in the same
505
+ AES-GCM store as your scoped DB credential (`db-credentials.enc`), never in the
506
+ shared database. Set it from the GUI (owner-only `POST /api/cloud/s3-config`) or
507
+ via environment variables for headless/CI use:
508
+
509
+ | Variable | Meaning |
510
+ | --------------------------------------------- | ----------------------------------------------------- |
511
+ | `LATTICE_S3_BUCKET` | Bucket name (enables S3 when set) |
512
+ | `LATTICE_S3_REGION` | Region (falls back to `AWS_REGION`) |
513
+ | `LATTICE_S3_PREFIX` | Key prefix; defaults to `blobs` |
514
+ | `LATTICE_S3_ENDPOINT` | Custom endpoint for S3-compatible stores (R2 / MinIO) |
515
+ | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Credentials (or the standard AWS chain) |
516
+
517
+ `@aws-sdk/client-s3` is an **optional dependency**, lazy-imported only when S3 is
518
+ enabled. If it isn't installed, uploads fall back to local-only and the serve route
519
+ reports that the bytes aren't reachable here — it never 500s. Setting
520
+ `LATTICE_S3_ENDPOINT` switches the client to path-style addressing, which is what
521
+ R2, MinIO, and LocalStack expect.
522
+
523
+ When enabled, an upload keeps the **local blob too** (hybrid): the uploader gets
524
+ instant local preview while every other member streams from S3. The row records
525
+ `ref_kind='cloud_ref'`, `ref_provider='s3'`, `ref_uri='s3://<bucket>/<key>'`, and
526
+ `source_json={bucket,key,region,size_bytes}` — no schema migration; these fields
527
+ already exist.
528
+
529
+ The fallback is **never silent**: if S3 is enabled but the upload's PUT fails
530
+ (rotated credential, outage, region mismatch, SDK absent), the upload still
531
+ succeeds locally — but the response carries `s3: { status: 'failed', error }` so
532
+ the uploader is told the bytes did **not** reach the shared bucket. Other members
533
+ fetch from S3, so a file shared after a failed PUT would 404 for everyone but the
534
+ uploader; surfacing the failure lets the GUI prompt a re-upload instead of implying
535
+ a clean share. A clean upload returns `s3: { status: 'stored', key }`.
536
+
537
+ The serve route (`GET /api/files/:id/blob`) sends bytes inline with
538
+ `X-Content-Type-Options: nosniff` and a no-allowances `Content-Security-Policy:
539
+ sandbox`. Both the bytes and the row's `mime` are member-writable (PutObject to the
540
+ shared bucket + the generic row CRUD), so without these headers a member could stage
541
+ `text/html` and have it execute in another member's GUI origin. Image/PDF previews
542
+ still render (they load `/blob` as a subresource); a hostile HTML blob opened
543
+ directly is inert.
544
+
545
+ ### Least-privilege IAM policy
546
+
547
+ Grant the workspace credential exactly this — `GetObject` + `PutObject` scoped to
548
+ the prefix, and nothing that can list or delete:
549
+
550
+ ```json
551
+ {
552
+ "Version": "2012-10-17",
553
+ "Statement": [
554
+ {
555
+ "Sid": "LatticeCloudBlobsReadWrite",
556
+ "Effect": "Allow",
557
+ "Action": ["s3:GetObject", "s3:PutObject"],
558
+ "Resource": "arn:aws:s3:::YOUR_BUCKET/blobs/*"
559
+ }
560
+ ]
561
+ }
562
+ ```
563
+
564
+ Do **not** add `s3:ListBucket` or `s3:DeleteObject`. ListBucket would let a holder
565
+ enumerate every key (defeating the "the key only appears in an RLS row" boundary);
566
+ Delete isn't needed by the app and widens the blast radius of a leaked credential.
567
+ Enable default bucket encryption (SSE-S3 or SSE-KMS) and block public access.
568
+
569
+ ### Security model & caveats (read before enabling)
570
+
571
+ This is **app-mediated** access control, which is the honest ceiling for a model
572
+ with no Lattice server in front of the bytes. Understand what it does and does not
573
+ guarantee:
574
+
575
+ 1. **Byte-access is app-mediated, not S3-enforced.** The boundary is "the
576
+ RLS-gated `files` row is the only place the object key appears." S3 itself can't
577
+ tell one member from another — both present the same shared credential.
578
+ 2. **Revocation doesn't retract bytes.** Un-sharing a row (`visibility → private`)
579
+ stops future DB reads, but a member who already fetched the object (or just
580
+ learned its key) keeps access to the content-addressed object. Rotate/relocate
581
+ the object if you need true retraction.
582
+ 3. **The shared workspace credential is a single point of compromise.** Treat it
583
+ exactly like the owner's Postgres URL — machine-local, `0600`, encrypted. Anyone
584
+ with the credential _and_ a key can read that object.
585
+ 4. **No per-member S3 audit.** CloudTrail sees one shared principal, so S3 access
586
+ logs can't attribute a fetch to a person. DB-layer provenance still attributes
587
+ the row itself.
588
+ 5. **Security depends on never granting `ListBucket` and always using the
589
+ unguessable sha256 keys.** Both are required; relaxing either collapses the
590
+ boundary.
591
+
592
+ For teams that need true per-member byte enforcement and per-member audit, the
593
+ documented upgrade path is an online, RLS-checking presign edge (it reintroduces a
594
+ small server that signs per-member, time-boxed URLs only after re-checking RLS) —
595
+ out of scope here. Crypto-shred of S3 objects on row delete is likewise an
596
+ owner-run admin follow-up.
597
+
598
+ ---
599
+
600
+ ## Limits & notes
601
+
602
+ - **Cloud is Postgres-only.** SQLite has no roles or RLS; a local SQLite Lattice is
603
+ your private single-user store. The bridge between them is **migrate**.
604
+ - **Identity is the Postgres login.** There is no separate account system. A member
605
+ _is_ their role; to act as someone else you'd need their password. Provision
606
+ one role per person so you can revoke individually.
607
+ - **Owner needs `CREATEROLE`** to provision members from Lattice. Alternatively the
608
+ DBA pre-creates the login roles by hand and adds them to `lattice_members`.
609
+ - **Departed members' rows persist.** `revokeMemberRole` drops the role but leaves
610
+ the rows it owned (now unreachable). Reassign or purge them deliberately.
611
+ - **Postgres SSL** is governed by your connection string — pass `?sslmode=require`
612
+ (or your provider's equivalent). Lattice doesn't override it.
613
+ - **Connection strings are secrets.** A member's scoped string is safe to hand to
614
+ that member (RLS confines it), but the **owner's** string is a superuser-adjacent
615
+ credential to the whole cloud — keep it encrypted and never share it.
616
+
617
+ ---
618
+
619
+ ## Per-column audiences & per-viewer values (experimental)
620
+
621
+ RLS is whole-row. Two layered primitives take it to the **cell** and to
622
+ **per-viewer values**, so different members can legitimately see different
623
+ versions of the same entity. Both are off by default — a column with no
624
+ `audience` behaves exactly as before.
625
+
626
+ **1. Per-column masking — a generated view.** Declare an `audience` on a column
627
+ and Lattice generates a cell-masking view `<table>_v` beside the base table:
628
+
629
+ ```yaml
630
+ person:
631
+ fields:
632
+ id: { type: uuid, primaryKey: true }
633
+ name: { type: text }
634
+ comp: { type: text, audience: 'subject:subject_role+role:hr' }
635
+ subject_role: { type: text }
636
+ ```
637
+
638
+ The audience is a `+`-joined (OR) set of clauses — `role:<name>`,
639
+ `subject:<col>`, `source:<col>`, or `everyone`. Each compiles to a
640
+ `session_user`-keyed `SECURITY DEFINER` predicate (`lattice_has_role` /
641
+ `lattice_is_subject` / `lattice_source_visible`), so the mask binds to the real
642
+ member even though the view runs with its owner's rights. Members `SELECT` the
643
+ view (base `SELECT` is revoked); a masked cell reads as `NULL`. App roles are
644
+ owner-managed via `lattice_assign_role(member, role)` — members can't
645
+ self-promote. Generated by `enableAudienceView`, never hand-edited.
646
+
647
+ **2. Per-viewer values — a local fold.** When a value is _derived_ from a source
648
+ (e.g. an enrichment computed from a file), it is only valid for a member who can
649
+ see that source. `foldEntity(ground, observations, viewer)` (in `latticesql`)
650
+ overlays the observations a viewer is allowed to see onto the ground-truth
651
+ projection — latest visible observation per attribute wins. It is a pure,
652
+ deterministic, additive fold, so a member who can't reach a source simply never
653
+ sees its derived value, and **un-sharing the source reverts the value with no
654
+ residue** (revocation is structural, not a cleanup job). Run it on the member's
655
+ local replica over already-gated observations, cached with `FoldCache` and
656
+ re-rendered only when an observation changes — egress is paid once at pull.
657
+
658
+ **Change-log visibility.** The observation substrate (`__lattice_changelog`) is RLS-
659
+ protected with two cases. A **derived** observation is visible only to a member who
660
+ can reach every source it was derived from (the source-visibility predicate above) —
661
+ existence-hiding is structural. A **ground-truth / audit** entry is **owner-only**
662
+ (`lattice_is_owner`): it carries the full row in cleartext, including columns the
663
+ `<table>_v` mask hides from a non-owner, so the row's raw history is an owner artifact
664
+ — a member who can merely see a shared row reads it through the masked view, never its
665
+ change-log.
666
+
667
+ **3. Forgetting a source — crypto-shred.** For a legally sensitive source, seal
668
+ its derived values under a per-source key (`sealUnderSource`) and call
669
+ `shredSource` to destroy the key — the values become unrecoverable everywhere
670
+ the ciphertext exists, backups included.
671
+
672
+ **Source sharing is direct (for now).** Source visibility reduces to the
673
+ source row's own RLS. Transitive source-sharing (folders / teams / inheritance,
674
+ a ReBAC-style model) is intentionally deferred until that shape is actually
675
+ needed — the primitives above compose with it when it lands.