latticesql 4.3.7 → 5.0.0

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.
@@ -0,0 +1,200 @@
1
+ # Computed tables
2
+
3
+ A **computed table** is a live, read-only view built from the tables you already
4
+ have. You describe _what each field is_ — a copied column, a calculation, an
5
+ AI-derived value, or a total across linked rows — and Lattice compiles that
6
+ description into a SQL VIEW and registers it as a queryable table. The values
7
+ are never copied: every read reflects the current state of the records the view
8
+ is built from.
9
+
10
+ Everything here is **additive and opt-in**: a workspace with no `computed:`
11
+ section behaves exactly as before.
12
+
13
+ ## The shape of a definition
14
+
15
+ A computed table has one **base** table (a declared entity or another computed
16
+ table) and a set of named **fields**. The view always projects the base's
17
+ primary key as `id` first, then each field in declaration order:
18
+
19
+ ```yaml
20
+ computed:
21
+ ticket_summary:
22
+ base: tickets
23
+ fields:
24
+ title: { kind: alias, source: title }
25
+ team: { kind: alias, source: assignee.team.name }
26
+ is_urgent: { kind: calc, expr: 'priority >= 3', type: boolean }
27
+ tag_count: { kind: aggregate, via: ticket_tags.tag, fn: count }
28
+ ```
29
+
30
+ Definitions live in the workspace config next to `entities:`. The GUI builder
31
+ (below) reads and writes the same section — a hand-edited config and a
32
+ GUI-built view are the same thing.
33
+
34
+ ## The five field kinds
35
+
36
+ ### `alias` — copy a field
37
+
38
+ Project a column of the base table, or follow declared `belongsTo` relations
39
+ with a dotted path:
40
+
41
+ ```yaml
42
+ title: { kind: alias, source: title }
43
+ team: { kind: alias, source: assignee.team.name } # tickets → people → teams
44
+ ```
45
+
46
+ ### `calc` — a calculation
47
+
48
+ A sandboxed expression over base columns and dotted paths. Expressions support
49
+ arithmetic, comparisons, `and`/`or`/`not`, `case … when`, `cast`, and a fixed
50
+ function set: `coalesce`, `nullif`, `lower`, `upper`, `trim`, `length`,
51
+ `substr`, `replace`, `abs`, `round`. Raw SQL never passes through — the
52
+ expression is parsed and re-emitted, so anything outside the grammar is
53
+ rejected at definition time.
54
+
55
+ ```yaml
56
+ is_urgent: { kind: calc, expr: 'priority >= 3', type: boolean }
57
+ label: { kind: calc, expr: "coalesce(nickname, name, 'unknown')" }
58
+ ```
59
+
60
+ `type` is the display type (defaults to `text`).
61
+
62
+ ### `ai_classify` — an AI-assigned category
63
+
64
+ A model assigns each row one label from a fixed set, based on one input field:
65
+
66
+ ```yaml
67
+ sentiment:
68
+ kind: ai_classify
69
+ input: body
70
+ prompt: How does the customer feel in this message?
71
+ labels: [happy, neutral, frustrated]
72
+ ```
73
+
74
+ Each **distinct input value** is labeled once and the result is materialized
75
+ into a bookkeeping table the view joins against — the model is never re-run at
76
+ read time, and two rows with the same input always get the same label.
77
+
78
+ ### `ai_transform` — AI-written text
79
+
80
+ A model derives a free-form value from one or more input fields (input order is
81
+ part of the identity — reordering inputs is a new definition):
82
+
83
+ ```yaml
84
+ summary:
85
+ kind: ai_transform
86
+ inputs: [title, body]
87
+ prompt: Summarize this ticket in one sentence.
88
+ ```
89
+
90
+ Results are cached per row, keyed on the current input values — see
91
+ [staleness](#refreshing-ai-values--staleness) below.
92
+
93
+ ### `aggregate` — a total across links
94
+
95
+ Fold many linked rows into one value per base row, through a junction table:
96
+
97
+ ```yaml
98
+ tag_count: { kind: aggregate, via: ticket_tags.tag, fn: count }
99
+ total_paid: { kind: aggregate, via: invoice_payments.payment, fn: sum, column: amount }
100
+ ```
101
+
102
+ `via` is `<junctionTable>.<remoteRelation>`; `fn` is one of `count`, `sum`,
103
+ `avg`, `min`, `max`, `concat`; `column` names the remote column to aggregate
104
+ (required for every `fn` except `count`).
105
+
106
+ ## Building one in the GUI
107
+
108
+ Open the **Tables** tab. The explorer's third column is **Computed Tables**;
109
+ its header carries a **+ New** button that opens the builder (`#/computed/new`).
110
+
111
+ 1. **Name it** — lowercase letters, numbers, and underscores.
112
+ 2. **Pick the base table** ("Built from"). Fields can come from the base and
113
+ anything linked to it — the pickers list every reachable column, including
114
+ dotted paths through linked tables and aggregate targets through junctions.
115
+ 3. **Add fields.** Each row is a name plus a kind — _Copy a field_,
116
+ _Calculation_, _AI category_, _AI text_, or _Total across links_ — with the
117
+ inputs that kind needs.
118
+ 4. **Preview.** A dry run compiles the definition against your live schema and
119
+ shows up to 20 sample rows. Nothing is created or saved by a preview. Each
120
+ field is stamped ✓ (compiled) or ✕ (the failing field), and the compiled
121
+ SELECT is available under the collapsed **Definition (SQL)** block.
122
+ 5. **Create.** Enabled once the current definition has previewed successfully
123
+ (any edit re-requires a preview). Creating registers the view, persists the
124
+ definition to the workspace config, and lands you on the view's rows.
125
+
126
+ Editing an existing view (**Edit definition →** in a computed card's detail
127
+ panel, or `#/computed/<name>`) is the same form plus two more actions:
128
+ **Refresh values** (runs the AI fill now, streaming per-field progress) and
129
+ **Remove**.
130
+
131
+ The explorer also shows the projection itself: a dashed connector from the base
132
+ table to the computed view, and the view's detail panel lists the base under
133
+ _Upstream · sources_.
134
+
135
+ ## Refreshing AI values + staleness
136
+
137
+ AI fields are **materialized, never computed at read time**. A background fill
138
+ runs when a definition with AI fields is created or changed; you can also run
139
+ it on demand (**Refresh values** in the builder, or **Refresh** in the Tables
140
+ explorer's detail panel).
141
+
142
+ Staleness is handled by construction: an AI value is cached against the exact
143
+ input values it was derived from. When a source row changes, its cached value
144
+ no longer matches, so the field reads **blank for that row until the next
145
+ refresh** — you may see a gap, but never a stale value. Unchanged rows keep
146
+ their cached values (no re-billing for what's already known), and a changed
147
+ prompt, label set, input list, or model invalidates exactly that field's cache.
148
+
149
+ ## Read-only rows
150
+
151
+ A computed table's rows are derived, not authored. The GUI marks the view with
152
+ a **Computed** badge on its rows page and record pages, shows where the values
153
+ come from, and offers no editing affordances (no field editor, no markdown
154
+ editing, no per-row delete). The server enforces the same rule: any direct
155
+ write to a computed table is refused with a message pointing you at the source
156
+ tables or the definition. To change what a computed table shows, change the
157
+ records it is built from — or edit its definition.
158
+
159
+ The source tables are protected in the other direction too: deleting or
160
+ renaming a table is refused while a computed table reads from it, naming the
161
+ definitions that depend on it.
162
+
163
+ ## Deleting + undo
164
+
165
+ Removing a computed view drops the view and its definition (and its AI-value
166
+ cache). Nothing about the source tables changes. The operation is recorded in
167
+ version history and is **revertible** — undo re-creates the view from the
168
+ captured definition. A computed table that other computed tables are built on
169
+ cannot be deleted until they are deleted or repointed.
170
+
171
+ A refresh, by contrast, only fills in AI-derived cells; it appears in history
172
+ as an informational entry with nothing to revert.
173
+
174
+ ## HTTP API
175
+
176
+ The GUI drives computed tables through `lattice gui`'s local server. On a team
177
+ cloud, mutating verbs (and preview/refresh, which belong to the owner-side
178
+ builder) are owner-only — a scoped member gets a 403.
179
+
180
+ | Method | Endpoint | What it does |
181
+ | ------ | ------------------------------------ | ----------------------------------------------------------------------- |
182
+ | GET | `/api/computed-tables` | List definitions with per-field fill/error state |
183
+ | GET | `/api/computed-tables/:name` | One definition plus its compiled SELECT (`{ def, sql }`) |
184
+ | GET | `/api/computed-tables/fields?base=` | Field-picker candidates for a base: columns, dotted paths, aggregates |
185
+ | POST | `/api/computed-tables/preview` | Dry-run a definition (`{ def, limit? }`) — no DDL, nothing persisted |
186
+ | POST | `/api/computed-tables` | Create (`{ name, def }`) — registers the view + persists the definition |
187
+ | PUT | `/api/computed-tables/:name` | Update the definition (`{ def }`); dependents recompile in order |
188
+ | DELETE | `/api/computed-tables/:name` | Delete the view + definition (refused while dependents exist) |
189
+ | POST | `/api/computed-tables/:name/refresh` | Run the AI fill now; streams per-field progress as NDJSON |
190
+
191
+ The preview response is `{ columns, rows, sql, fieldTypes, pendingAi }` —
192
+ `pendingAi` maps each AI field to how many items a fill pass would enqueue.
193
+ The refresh stream emits one JSON object per line:
194
+ `{ phase: 'field', field, message }` when a field starts,
195
+ `{ phase: 'field-done', field, filled, pending, error? }` when it finishes,
196
+ and a final `{ done: true }` (or `{ phase: 'error', message }`).
197
+
198
+ Definition shape errors, unknown bases/columns, and dependency refusals come
199
+ back as `400 { error }` with the compiler's message verbatim — the same text
200
+ the builder shows in its error strip.
@@ -1,166 +1,162 @@
1
- # Connectors
1
+ # MCP Connectors
2
2
 
3
3
  Connectors sync data from external systems into Lattice as **connected data
4
4
  types** — tables whose rows are ingested from a source rather than authored
5
- locally. They make external data first-class Lattice data: queryable, full-text
6
- searchable, rendered to context, linked on the graph, and ACL-scoped on a cloud.
7
-
8
- A _connector_ talks to one external product (a _toolkit_) and exposes its object
9
- types as connected data types. The built-in connector is **Jira**, which talks to
10
- Jira Cloud's REST + Agile APIs directly via [`jira.js`](https://github.com/MrRefactoring/jira.js)
11
- using your own Atlassian credentials — there is no broker service and no extra
12
- API key. Adding another source (Gmail, Slack, …) is a new `Connector`
13
- implementation, not changes to the core.
14
-
15
- > `jira.js` is an **optional dependency**. The package compiles and runs without
16
- > it; it is loaded lazily and a clear error is thrown only when the Jira connector
17
- > is actually used. Install it to use the connector: `npm install jira.js`.
18
-
19
- ## Concepts
20
-
21
- | Concept | Meaning |
22
- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
23
- | **Connector** | An implementation of the fetch/auth SPI (e.g. the Jira connector). |
24
- | **Toolkit** | An external product a connector serves (e.g. `jira`). |
25
- | **Connected data type** | A Lattice table with a `source` descriptor — its rows are synced from a toolkit model. |
26
- | **Connector instance** | A registered connection (`__lattice_connectors` row): which toolkit, the per-member connection handle, and sync state. |
27
-
28
- A connected table's **natural key is its primary key** (a stable external id), so
29
- re-syncs upsert idempotently. Every row carries connector lineage:
30
- `_source_connector_id` and `_source_model` (immutable) plus `_source_synced_at`.
31
-
32
- ## Credentials
33
-
34
- The Jira connector authenticates as **you**, with an Atlassian **API token**
35
- (HTTP Basic auth: your account email + the token). Create one at
36
- <https://id.atlassian.com/manage-profile/security/api-tokens>. Lattice validates
37
- the credentials against Jira on connect (`GET /myself`) and stores them in the
38
- **machine-local encrypted credential store** — they never leave the machine
39
- except to call the Jira API directly, and they are never written to the registry,
40
- logs, or any rendered context.
41
-
42
- ## Using the GUI
43
-
44
- Open **Settings Connectors**, fill in your Jira **site URL**
45
- (`https://your-domain.atlassian.net`), **account email**, and **API token**, then
46
- **Connect**. The credentials are validated and the initial sync runs. Each
47
- connected toolkit shows its status + last-synced time with **Refresh** and
48
- **Disconnect** buttons, and connected data types get a "Connected" badge in the
49
- Objects list.
50
-
51
- ## Quick start (programmatic)
5
+ locally. Every connector is an **MCP connection**: Lattice runs as a local
6
+ [Model Context Protocol](https://modelcontextprotocol.io) client and talks to
7
+ the server directly from your machine. There is no provider-specific connector
8
+ code and no broker a provider is just another MCP server URL.
9
+
10
+ ## Connecting a server (GUI)
11
+
12
+ Open **Configure MCP Connectors**. Every connection you have lives in this
13
+ tab: name (the server's self-reported name from the MCP handshake), URL, status,
14
+ last sync, and per-server **Refresh** / **Disconnect** / **Reconnect** actions.
15
+
16
+ To add one, paste the server's URL into **Add an MCP connector** and click
17
+ **Connect**:
18
+
19
+ - **Open servers** (no auth) connect and sync immediately.
20
+ - **OAuth servers** open the provider's own sign-in in your browser. Approve it
21
+ and return to Lattice — the connection completes on a loopback callback and
22
+ the first sync runs.
23
+ - **Servers that require a pre-registered OAuth client** (no dynamic
24
+ registration and no client-ID-metadata-document support) get a clear prompt:
25
+ the form reveals **client ID / client secret** fields for the credentials the
26
+ provider issued you, then the same OAuth sign-in runs with them.
27
+
28
+ Each added server is its own connection connect as many as you like,
29
+ side by side. **Disconnect** soft-deletes the synced rows (recoverable), prunes
30
+ their rendered context, revokes the stored tokens, and keeps the server URL so
31
+ **Reconnect** can re-run the sign-in without re-entering anything.
32
+
33
+ ## How OAuth client identity works
34
+
35
+ Lattice identifies itself to a provider's authorization server using the first
36
+ mechanism the server supports, in this order:
37
+
38
+ 1. **A stored client** — either from a previous dynamic registration or the
39
+ client ID you entered by hand.
40
+ 2. **Client-ID metadata document (CIMD)** — the modern MCP mechanism: the
41
+ client_id IS a stable HTTPS URL pointing at a static JSON document that
42
+ describes the app (name, redirect URIs, grant types). Lattice's document is
43
+ hosted at `https://latticedesktop.com/oauth/client-metadata.json`.
44
+ 3. **Dynamic client registration** (RFC 7591), for servers that offer a
45
+ registration endpoint.
46
+
47
+ Every flow is a PKCE (S256) authorization-code flow for a public client; tokens
48
+ are refreshed automatically.
49
+
50
+ ### The privacy model
51
+
52
+ The hosted metadata document is **static app identity, not a data plane**:
53
+
54
+ - It is the same fixed file for every Lattice install, contains **zero user
55
+ data**, and is never written.
56
+ - Only the **provider's authorization server** fetches it (to answer "who is
57
+ this client asking for access?"). Your machine and your browser never touch
58
+ the hosting domain during the flow.
59
+ - MCP traffic flows **directly** between your local Lattice and the MCP server —
60
+ nothing is proxied through any Lattice-hosted service.
61
+ - OAuth tokens live only in the machine-local encrypted credential store
62
+ (AES-256-GCM under a machine-local master key). They never enter the
63
+ workspace database, API responses, or logs.
64
+
65
+ Self-hosters can point `LATTICE_MCP_CLIENT_METADATA_URL` at their own document;
66
+ setting it to an empty string disables the mechanism entirely (dynamic
67
+ registration and manual client entry still work).
68
+
69
+ ## What gets synced
70
+
71
+ The connector introspects the server and pulls its readable content into one
72
+ connected table, **`mcp_items`**:
73
+
74
+ | column | meaning |
75
+ | --------- | --------------------------------------------------------------------- |
76
+ | `item_id` | natural key (`<tool>:<id>` for items, `resource:<uri>` for resources) |
77
+ | `kind` | `item` (from a read tool) or `resource` (from `resources/list`) |
78
+ | `tool` | the MCP tool that produced an item |
79
+ | `server` | the server's hostname — items from different servers stay apart |
80
+ | `title` | best-effort title |
81
+ | `summary` | best-effort description/snippet |
82
+ | `data` | the raw JSON payload (for resources: `{ uri, mimeType }`) |
83
+
84
+ Discovery calls the server's `tools/list`, invokes each **no-argument read
85
+ tool** (bounded per tool; obviously write-shaped tools are never called), and
86
+ then lists the server's advertised **resources** — its "available files" — via
87
+ the standard `resources/list`. Rows are full Lattice rows: queryable, full-text
88
+ searchable, rendered to context, per-member `private` by default, and stamped
89
+ with immutable lineage (`_source_connector_id`, `_source_model`). A re-sync
90
+ upserts on the natural key and soft-deletes rows that vanished from the source.
91
+
92
+ Freshness is pull-based — there is no background scheduler. Syncs run on
93
+ connect, on **Refresh**, and on GUI load when the last sync is older than an
94
+ hour.
95
+
96
+ ## Library API
97
+
98
+ The same engine is a library surface:
52
99
 
53
100
  ```typescript
54
101
  import {
55
- JiraConnector,
102
+ genericConnector,
56
103
  createConnector,
57
104
  syncConnector,
58
105
  syncIfStale,
59
106
  disconnectConnector,
60
107
  } from 'latticesql';
61
108
 
62
- const connector = new JiraConnector();
63
-
64
- // Validate + store the member's Atlassian credentials, returning a connection handle:
65
- const { connectionId, displayName } = await connector.connect({
66
- site: 'https://your-domain.atlassian.net',
67
- email: 'you@example.com',
68
- apiToken: process.env.JIRA_API_TOKEN!,
69
- });
109
+ const connector = genericConnector();
70
110
 
111
+ // GUI-less flows drive beginConnect/completeConnect (OAuth) or connect a local
112
+ // stdio server; the registry + sync engine are shared with the GUI routes:
71
113
  const connectorId = await createConnector(db, {
72
- connector: 'jira',
73
- toolkit: 'jira',
74
- displayName: displayName ?? 'jira',
114
+ connector: 'mcp',
115
+ toolkit: 'mcp',
116
+ displayName: 'My MCP server',
75
117
  connectionRef: connectionId,
76
118
  connectedBy: 'user-123',
77
119
  });
78
-
79
- await syncConnector(db, connector, connectorId); // defines the tables + ingests
80
- ```
81
-
82
- ## Sync model
83
-
84
- - **`syncConnector(db, connector, connectorId)`** — full sync: paginated +
85
- bounded fetch per model, idempotent upsert with lineage stamping, soft-delete
86
- of rows that vanished from the source, and graph-edge derivation from FK
87
- columns. Records the outcome on the connector; an external-sync failure is
88
- re-thrown (never swallowed).
89
- - **`syncIfStale(db, connector, connectorId, maxAgeMs?)`** — no-op if the last
90
- sync is within `maxAgeMs` (default 1 hour). Call it on app load.
91
- - **`syncStaleConnectors(db, connector, maxAgeMs?)`** — sync every stale
92
- connector this implementation serves. The GUI calls this on load, so connected
93
- data refreshes hourly **without a scheduler**. Manual refresh forces a sync.
94
-
95
- Reads done by the sync engine are bounded and column-projected — it never scans a
96
- full table to diff. Per-parent models iterate the parent's already-synced keys
97
- (comments are fetched per issue; sprints per board).
98
-
99
- ## Disconnecting
100
-
101
- ```typescript
102
- await disconnectConnector(db, connector, connectorId, { outputDir });
120
+ await syncConnector(db, connector, connectorId);
121
+ await syncIfStale(db, connector, connectorId); // re-sync when stale (1h)
122
+ await disconnectConnector(db, connector, connectorId); // soft teardown
103
123
  ```
104
124
 
105
- Soft-deletes every ingested row (children before parents), prunes rendered
106
- context files, marks the connector `disconnected` (use `{ mode: 'hard' }` to also
107
- remove the registry row), and drops the stored credentials. Soft-deleted rows
108
- drop out of the rendered context, full-text search, and the GUI listings (all of
109
- which filter `deleted_at IS NULL`), and their graph edges are removed — so the
110
- data is no longer available to the agent while remaining physically present and
111
- restorable.
112
-
113
- ## Cloud ACL
114
-
115
- On a cloud (Postgres) workspace, the **owner** scopes connected data per member:
116
-
117
- ```typescript
118
- await enableConnectorRls(db, connector, 'jira');
119
- ```
120
-
121
- This enables Row-Level Security on the registry and the toolkit's connected
122
- tables and applies each type's default visibility — `private` (visible only to
123
- the connecting member) or `everyone` (shared with the team). It is a no-op on
124
- SQLite, a non-cloud Postgres, or for a non-owner role.
125
-
126
- Because each member connects with their own Atlassian credentials, the data a
127
- member syncs is already scoped to what they can see in the source — per-user
128
- auth, not a shared admin credential. Derived enrichment over connected rows
129
- inherits the source's visibility automatically: write it through
130
- `db.observe(table, pk, { changeKind: 'derived', sourceRef: [connectedRowId] })`,
131
- and the source-gated fold hides it from a viewer who can't see the source.
132
-
133
- ## The Jira toolkit
134
-
135
- Connecting Jira creates six connected data types:
136
-
137
- | Table | Natural key | Notes |
138
- | --------------- | ----------- | ------------------------------------------------------------- |
139
- | `jira_projects` | project key | FTS on name/description |
140
- | `jira_issues` | issue key | FTS on summary/description; edges project, assignee, sprint |
141
- | `jira_comments` | comment id | fetched per issue; edges issue, author |
142
- | `jira_users` | account id | |
143
- | `jira_boards` | board id | edge → project |
144
- | `jira_sprints` | sprint id | fetched per board; edge → board |
145
-
146
- ## Adding a connector
147
-
148
- Implement the `Connector` SPI and point the GUI/registry at it. The SPI is small:
149
-
150
- - `connector` / `toolkits()` / `models(toolkit)` — identity + the connected
151
- `ConnectedModelDef`s (table schema, natural key, FK relations → graph edges).
152
- - `listChanges(toolkit, model, ctx)` — an async iterable of normalized
153
- `ExternalRecord`s for one model, **paged and bounded** (per-parent models read
154
- `ctx.parentKey`).
155
- - `disconnect(connectionRef)` — revoke / drop the stored connection.
156
- - `authorize` / `completeAuth` — for an OAuth-redirect source. A
157
- credential-based connector (like Jira) instead exposes a `connect(creds)`
158
- method that validates + stores the credentials and returns a connection handle;
159
- the GUI route calls it when the connector supports it.
160
-
161
- The sync engine, graph wiring, teardown, and ACL all work from the model
162
- descriptors — no further code is required.
163
-
164
- ```
165
-
166
- ```
125
+ Library consumers embedding a specific provider can use
126
+ `introspectiveConnector(spec)` (the same engine pre-pointed at a fixed endpoint
127
+ with its own table name) or `SimpleMcpConnector` (hand-typed models + per-model
128
+ tool bindings) see `src/connectors/types.ts` for the SPI.
129
+
130
+ On a cloud workspace, the owner's `enableConnectorRls(db, connector, 'mcp')`
131
+ scopes connected rows per member (private by default).
132
+
133
+ > `@modelcontextprotocol/sdk` is an **optional dependency**. The package
134
+ > compiles and runs without it; it is loaded lazily and a clear error is thrown
135
+ > only when an MCP connector is actually used. Install it to use connectors:
136
+ > `npm install @modelcontextprotocol/sdk`.
137
+
138
+ ## Security
139
+
140
+ - **SSRF guard**: every HTTP hop — the server URL, redirects, and each
141
+ OAuth-discovered endpoint is DNS-resolved and re-validated against a
142
+ private/loopback/link-local/metadata-address blocklist before it is fetched.
143
+ - **Token storage**: machine-local encrypted file, never the shared DB.
144
+ - **Error hygiene**: sync errors surfaced in the GUI are sanitized so a raw DB
145
+ constraint error can never echo another member's data.
146
+
147
+ ## Troubleshooting
148
+
149
+ - **"Incompatible auth server: does not support dynamic client registration"** —
150
+ the server supports neither CIMD nor dynamic registration. The GUI's add form
151
+ reveals client ID/secret fields; create an OAuth client in the provider's
152
+ admin console and paste its credentials.
153
+ - **The browser shows a provider error at sign-in** — the provider's
154
+ authorization server could not validate the client. If you overrode
155
+ `LATTICE_MCP_CLIENT_METADATA_URL`, make sure that URL is publicly reachable
156
+ and serves JSON.
157
+ - **"Sign-in didn't complete"** the loopback callback never arrived (browser
158
+ closed mid-flow, or the sign-in was abandoned). Click Connect again.
159
+ - **A server connects but syncs nothing** it may expose only tools that
160
+ require arguments (skipped in introspective mode) and no resources. The
161
+ connection is still valid; data arrives when the server offers no-argument
162
+ read tools or resources.
package/docs/desktop.md CHANGED
@@ -38,6 +38,15 @@ browser tab.
38
38
  while keeping the auth session local to the app.
39
39
  - **Upgrade-on-run.** On launch the app checks the release channel via
40
40
  `Deno.autoUpdate()` and the `latest.json` manifest published with each release.
41
+ - **Update-available hint while running.** A window left open for a long time would
42
+ otherwise miss releases that ship after launch, so the GUI also polls the same
43
+ `latest.json` (read-only — no download or relaunch) and shows an **"Update
44
+ available — Restart to update"** link next to the version chip when a newer
45
+ release is published. Clicking it runs the bundled updater (download + relaunch).
46
+ - **Disable auto-update.** Set `LATTICE_NO_AUTO_UPDATE=1` to pin the app to its
47
+ current version — no manifest probe, no `Deno.autoUpdate()`, no relaunch (the
48
+ desktop equivalent of the CLI's `lattice gui --no-auto-update`). Useful for
49
+ testing, air-gapped, or reproducible-demo runs.
41
50
  - **Upgrade-on-install.** The download page always links the latest release, so a
42
51
  fresh install is always current.
43
52
 
@@ -66,6 +75,45 @@ Releases are cut by the `Desktop Release` workflow on a `v*` tag: it builds both
66
75
  OSes, generates `latest.json`, and uploads the installers + manifest to the
67
76
  GitHub Release.
68
77
 
78
+ ### Code signing (maintainers)
79
+
80
+ Builds are **ad-hoc (unsigned) by default** — contributors need no certificates,
81
+ and the unsigned `.pkg` still installs to `/Applications` (with the macOS
82
+ "unidentified developer" prompt on first launch). Real signing activates only
83
+ when credentials are supplied through the environment; no identity, team, or key
84
+ value is hardcoded anywhere in the repo.
85
+
86
+ To produce a signed + notarized macOS build locally, export before running
87
+ `npm run desktop:build:mac:pkg`:
88
+
89
+ - `SIGN_APP_IDENTITY` — the Developer ID **Application** identity (certificate
90
+ name or SHA-1) passed to `codesign`
91
+ - `SIGN_INSTALLER_IDENTITY` — the Developer ID **Installer** identity passed to
92
+ `pkgbuild` (optional; without it the `.pkg` itself is unsigned)
93
+ - notarization credentials, either:
94
+ - `NOTARY_PROFILE` — a `notarytool store-credentials` keychain profile name, or
95
+ - `NOTARY_KEY_FILE` + `NOTARY_KEY_ID` + `NOTARY_ISSUER_ID` — an App Store
96
+ Connect API key (`.p8` file path, key id, issuer id)
97
+
98
+ The app is signed inside-out under the hardened runtime with
99
+ [`scripts/lattice.entitlements`](../scripts/lattice.entitlements) (JIT
100
+ entitlements required by the embedded JavaScript runtime), then the `.app`,
101
+ `.pkg`, and `.dmg` are each notarized and stapled, hard-failing unless Apple
102
+ returns **Accepted**.
103
+
104
+ The `Desktop Release` workflow does the same automatically when these repository
105
+ secrets are configured (absent secrets — e.g. on forks — still produce working
106
+ unsigned artifacts):
107
+
108
+ | Secret | Contents |
109
+ | ---------------------------------------------------------------- | -------------------------------------------------------- |
110
+ | `MACOS_CERT_APP_P12` / `MACOS_CERT_APP_P12_PASSWORD` | Developer ID Application cert (base64 `.p12`) + password |
111
+ | `MACOS_CERT_INSTALLER_P12` / `MACOS_CERT_INSTALLER_P12_PASSWORD` | Developer ID Installer cert (base64 `.p12`) + password |
112
+ | `MACOS_KEYCHAIN_PASSWORD` | password for the ephemeral build keychain |
113
+ | `MACOS_APP_IDENTITY` / `MACOS_INSTALLER_IDENTITY` | the two signing identity names |
114
+ | `NOTARY_KEY_P8` | App Store Connect API key (base64 `.p8`) |
115
+ | `NOTARY_KEY_ID` / `NOTARY_ISSUER_ID` | the API key's id + issuer id |
116
+
69
117
  ## Limitations
70
118
 
71
119
  - **Image processing (`sharp`) and `sqlite-vec` acceleration are unavailable** in
package/docs/retrieval.md CHANGED
@@ -92,6 +92,47 @@ Opt-in per-table approximate-nearest-neighbor index built from the stored vector
92
92
  `doctor` reports). Requires the extension server-side (pgvector) or loaded
93
93
  (sqlite-vec).
94
94
 
95
+ Once built, the index is **kept in sync with writes** — you don't rebuild it by
96
+ hand after every change. On Postgres each insert/update/delete mirrors the row
97
+ into the index incrementally (on the same fire-and-forget path as the embedding
98
+ write), and `refreshEmbeddings` reconciles the index after a bulk backfill. As a
99
+ universal safety net across backends, `search()` **verifies the index is in sync
100
+ with the stored vectors before trusting it** (a cheap count-parity check); if the
101
+ index has drifted it transparently falls back to the exact in-process scan over
102
+ the source-of-truth store, so a stale index is never silently served — it only
103
+ ever costs a slower query, never a wrong result. (Incremental per-row maintenance
104
+ is currently Postgres-only; a `sqlite-vec` index falls back to the scan after a
105
+ write until it is rebuilt — correct either way.)
106
+
107
+ **Cloud members.** In a multi-member cloud, a scoped member has no grant on the
108
+ internal embeddings store or the native index, so its `search()` / `hybridSearch()`
109
+ reach the vectors only through a `SECURITY DEFINER` function that returns just the
110
+ chunks for rows the member may see (filtered by the same row-visibility rule that
111
+ governs every other read, keyed on the member's role) and scores them in-process.
112
+ The member scan is exact and has no over-fetch by which a member could infer hidden
113
+ rows; result rows are re-checked by row-level security on the base relation. Owners
114
+ and local (non-cloud) callers are unaffected — the routing is automatic.
115
+
116
+ **Tuning & operations.** The HNSW index can be tuned at build time via
117
+ `embeddings.index = { m, efConstruction }` and per query via `search(..., { efSearch })`
118
+ (`hybridSearch` too); all default to pgvector's own values, so omitting them builds
119
+ and queries exactly as before. `lattice index status` shows per-table index health
120
+ (dimension, params, build time, staleness) from an internal `__lattice_vector_index`
121
+ registry; `lattice reindex <table>` rebuilds one table's index, and `lattice doctor
122
+ --fix` rebuilds any index it reports stale. An auto-rebuild after a bulk
123
+ `refreshEmbeddings` reuses the recorded build params.
124
+
125
+ **Scale.** For larger corpora, `embeddings.index.quantization = 'halfvec'` (pgvector
126
+ ≥ 0.7) stores the index at 16-bit half precision — roughly halving its memory at a
127
+ small recall cost — while the embeddings store stays full precision, so the exact
128
+ scan fallback and any later full-precision rebuild are unaffected. Lattice runs
129
+ against a **single** Postgres or SQLite database: there is no sharding, no
130
+ replication, and no distributed index, and `search()` / `hybridSearch()` keep the
131
+ `topK ≤ 1000` bound (`SEARCH_TOPK_MAX`) so one query can't fan out into a
132
+ whole-table read. These are deliberate boundaries — when you need billions of
133
+ vectors, horizontal scale-out, or a managed vector service, run a dedicated vector
134
+ database alongside Lattice and keep Lattice as the system of record.
135
+
95
136
  > **v4.2 — bounded retrieval reads.** `search()` / `hybridSearch()` clamp the
96
137
  > caller's `topK` (`clampTopK`, `SEARCH_TOPK_MAX = 1000`) **before** the indexed
97
138
  > arm over-fetches `topK * N` candidates, so a single large `topK` can't fan out