# Seige SDK

Seige gives a browser app sign-in, a database, file storage and AI, all kept
in the user's own encrypted Seige account. There is no backend to run and
nothing to register:

```js
import { createSeige } from "@seige_sdk/sdk";

const seige = createSeige({ appName: "Trips" });

await seige.from("trips").insert({ city: "Lisbon", nights: 3 });
const { data: trips } = await seige.from("trips").select().order("city");
await seige.files.put(`trips/${file.name}`, file);
const { data: answer } = await seige.ai.generate({ prompt: "What should I pack?", input: trips });
```

That is a complete app. The first call that needs the user asks them to
connect the app to their Seige account, in a Seige window, and then carries
on. Tables are created the first time you insert into them.

## Install the package

```sh
npm install @seige_sdk/sdk
```

Use an ES-module bundler that supports module workers and WebAssembly. Vite 5
to 8, Next.js 16 and webpack 5 need no configuration (see Bundlers below). If
you serve the build yourself, serve `.wasm` files as `application/wasm`. Use HTTPS in production; `http://localhost` works for
development. The browser needs IndexedDB, Web Locks, module workers, WebCrypto
and BroadcastChannel.

To build an archive from a Seige checkout, run `npm run build` followed by
`npm run pack:sdk`, then install `/path/to/seige/dist/sdk/seige_sdk-sdk-0.6.2.tgz`.
The package contains JavaScript modules, TypeScript declarations, this guide and
the compiled WebAssembly crypto dispatcher used by Seige.

| Import | Available API |
| --- | --- |
| `@seige_sdk/sdk` | `createSeige`: sign-in, queries, files, AI and sync for browser apps |
| `@seige_sdk/sdk/node` | `createSeigeNode` for supported Node 22.23.4+, 24.22.1+ or 26.12.1+: queries and files over explicit connections |
| `@seige_sdk/sdk/browser` | The browser runtime, also exported by the main import |
| `@seige_sdk/sdk/client` | `createLowLevelClient` over explicit OAuth and storage connections |
| `@seige_sdk/sdk/connection` | OAuth, DPoP, app-key proofs and storage connection |
| `@seige_sdk/sdk/encrypted-folder` | The encrypted folder protocol |
| `@seige_sdk/sdk/database` | Node SQLite adapter and filesystem persistence |

Browser bundles reject the Node-only entrypoints.

## Service capabilities

SDK 0.6.0 adds `await seige.capabilities({ signal })`. It returns
`{ data: { familySharing, calendar }, error }` without opening sign-in, loading
keys or requesting access. Each value is `null` for unsupported service
capabilities or `{ version: 1 }` for a supported version this SDK understands.
Service support is separate from user permission. Network failures return an
error rather than an unsupported result.

SDK 0.7.0 implements `households` and `shared` on BrowserClient. A deployment
with the shared-workspace service reports `familySharing: { version: 1 }`.
Calendar remains unavailable (`calendar: null`). Older deployments still return
`null`; check capabilities before rendering family controls. The
`@seige_sdk/sdk/sharing-contracts` subpath exports the API types and stable errors.

### Family workspaces

Household creation, invitations, recipient acceptance, owner key approval and
removal open first-party Seige screens. Each person uses their own account.
`households.manage()` lets the person approve which household names and scoped
membership IDs this installation can see. Account keys and invitation credentials
stay in Seige; apps never receive a global user directory.

```js
const { data: support } = await seige.capabilities();
if (!support?.familySharing) throw new Error("Family sharing is unavailable");
const managed = await seige.households.manage();
if (managed.error) throw managed.error;
const {data: households, error} = await seige.households.list();
if (error) throw error;
const ownerHousehold = households.find(h => h.role === "owner");
const created = await seige.shared.create({
  householdId: ownerHousehold.id, name: "Family kitchen"
}); // call from a user click: opens a Seige approval window
if (created.error) throw created.error;
const opened = await seige.shared.open({ resourceId: created.data.id });
if (opened.error) throw opened.error;
const workspace = opened.data;
const snapshot = await workspace.read();
if (snapshot.error) throw snapshot.error;
const publication = await workspace.mutate({
  mutationId: crypto.randomUUID(), expectedRevision: snapshot.data.revision,
  schemaVersion: 1,
  records: [{ objectId: crypto.randomUUID(), payload: new TextEncoder().encode("Coffee") }]
});
```

`shared.list({ householdId })` lists only this app's resources. Members request
read or read/write approval with `shared.requestAccess`; every browser installation
approves separately. Handle reads verify encrypted batches, writer signatures and
server receipts. `record.memberId` identifies the publisher within that household;
apps must validate any display-name attribution against it. A write returns a
publication state: retain drafts until `published`. `conflict` requires a new read
and a deliberate merge; the SDK does not merge application records.

One exact signed mutation is queued per resource in the encrypted device vault.
`handle.retry()` safely replays it; mutation IDs cannot be reused with different
contents. `handle.pending()` decrypts queued records for review/export.
`shared.pending({resourceId})` returns queue metadata and
`shared.discardPending({resourceId,mutationId})` explicitly discards the local queue,
even when the person no longer has resource access. Never silently discard a queue.
`sync.flush()` also attempts queued workspace publication.

`shared.subscribe({resourceId,cursor,signal})` returns a Result containing an
AsyncIterable of opaque invalidations. Reread authoritative records after each
hint; reconnect from the last snapshot revision. `changes({since})` returns the
latest records and verified tombstones after a revision, rather than every edit.
Snapshot pagination rejects concurrent revision changes; retry the complete read.
The current snapshot limit is 32 MiB decoded, mutations are at most 100 records
and 256 KiB plaintext, and resource key history is limited to 128 epochs.

Member removal or installation disconnection immediately blocks that authority
and freezes new writes. The owner uses `shared.rotate({resourceId})` from a click
to distribute a fresh resource key to remaining members. Each installation must
reapprove access before writing. Remaining members retain approved historical keys;
content and keys previously copied by a removed person cannot be recalled. Newly
approved household members need the owner's **Update shared keys** action before
approving app access. Ownership transfer is not supported.

See [shared-workspace architecture](../../docs/architecture/shared-workspaces.md)
for native enforcement, limits and recovery behavior.

## Bundlers

The browser SDK starts two module workers, one for encryption and one for
SQLite, and each loads a WebAssembly file. The bundler emits the workers and
the Wasm files from `new Worker(new URL("…", import.meta.url))` and
`new URL("…", import.meta.url)` references in the package, so these setups
need no SDK-specific configuration:

| Bundler | Tested | Configuration |
| --- | --- | --- |
| Next.js 16 with Turbopack (the default) | `next build`, `next start` and `next dev` | None. Import the SDK from a client component (`"use client"`). |
| Vite 5, 6, 7 and 8 | `vite build` and the `vite` development server | None. `worker: { format: "es" }` is optional. |
| webpack 5 | Production build | None. |

Notes:

- Serve `.wasm` files as `application/wasm`. Vite, Next.js and most hosts do
  this already.
- Under Vite's development server, the SDK is pre-bundled into
  `node_modules/.vite/deps/`. The SDK then loads its workers from
  `node_modules/@seige_sdk/sdk/`, which the development server also serves.
  If an app keeps dependencies elsewhere (for example a workspace that does
  not hoist the package to the project's `node_modules`) and a call reports
  `worker_unavailable`, exclude the SDK from pre-bundling:
  `optimizeDeps: { exclude: ["@seige_sdk/sdk"] }`.
- The crypto worker and its Wasm (about 3.1 MB, about 1 MB with gzip) load
  when a call needs them, or, while no user is signed in, once the page is
  idle, because signing in needs them first. The SQLite worker and its Wasm
  (about 870 KB, about 400 KB with gzip) load with the first call that reads
  or writes data. `seige.auth.user()` itself downloads neither. The SDK's
  JavaScript in the page is about 230 KB (about 85 KB with gzip). Serve the
  Wasm files with compression and long-lived caching; bundlers give them
  content-hashed names.

## How it works

**Results.** Every call returns `{ data, error }` and never throws. `error` is
a `SeigeError` with a stable `code`, a `message` that names the specific
reason, and a `hint`. The hint is written for the developer: it says what
happened and what the app can do next. Choose the text users see from `code`.
`seige.sync.status()` and `seige.sync.resources()` are the exceptions to the
`{ data, error }` shape: they return plain values. `seige.auth.onChange()`
returns an unsubscribe function.

```js
const { data, error } = await seige.from("trips").select();
if (error) console.warn(error.code, error.message, error.hint);
```

`error.code` is one of the codes below (the `SeigeErrorCode` type, and
`ERROR_CODES` at run time). Queries, and the SQL methods of a `seige.db.open()`
handle, also return Postgres SQLSTATE codes: `23505` (unique value already
exists), `23502` (missing required column), `23503` (foreign key), `23514`
(check constraint), `42P01` (no such table), `42703` (no such column),
`42601` (malformed query), `42501` (a statement Seige never allows, such as
`ATTACH` or a `PRAGMA` that changes storage), `22P02` (value does not match
the column type), `22023` (value cannot be stored) and `21000` (update or
delete without a filter), plus `PGRST116` when `single()` does not match
exactly one row and `20` when a query was aborted. These are the
`QueryErrorCode` type and `QUERY_ERROR_CODES` at run time. `error.code` is
typed as `SeigeErrorCode | QueryErrorCode`, so comparing it with a misspelled
code, such as `"SIGN_IN_CANCELED"`, is a type error.

| Code | When | Retry |
| --- | --- | --- |
| `ACCESS_DENIED` | The user declined in the Seige window. `message` is `ai_access_denied` when they declined only AI access; the app stays connected. | When the user asks |
| `SIGN_IN_CANCELLED` | The user closed the Seige window or dismissed the page prompt. | When the user asks |
| `POPUP_BLOCKED` | The browser blocked the Seige window. | From a click |
| `REAUTH_REQUIRED` | The app's Seige session ended. | After `seige.auth.signIn()` |
| `PERMISSION_REVOKED` | The user disconnected the app or removed its access. | The next call asks again |
| `PERMISSION_DENIED` | The app lacks the access the call needs, such as write access to a folder (`message` is `resource_read_only`). A write through a database handle opened with `{ readonly: true }`, whether through `run()`, `exec()`, `batch()` or its `from()`, has `message` `read_only` and status 403. | After `seige.request()`, or through a handle opened without `readonly` |
| `NETWORK_UNAVAILABLE` | Seige could not be reached. The call did not complete. Writes that already succeeded are published automatically later; sign-in and AI calls are not retried. | Later |
| `RATE_LIMITED` | Seige answered HTTP 429, for example the hourly AI limits. `details.retryAfter` is the wait in seconds when Seige sent one; `seige.ai.usage()` reports `resetAt`. | After the wait |
| `ALLOWANCE_EXCEEDED` | The request exceeds available storage or AI credit, or the account is over an allowance (HTTP 402, or 413 `quota_exceeded`). | After capacity is available or the allowance changes |
| `CONFLICT` | A conflicting change; the hint names the call that resolves it. | After resolving |
| `IN_USE` | Another browser or tab kept writing this database for about 45 seconds. The write was not saved, so retrying it does not duplicate it. A write whose page closed during the wait is kept and runs the next time the app opens in that browser. | Shortly |
| `MIGRATION_FAILED` | A migration could not be applied. | After fixing the migration |
| `CONSENT_REQUIRED` | The user has not approved app storage yet. | After `seige.auth.signIn()` |
| `UNSUPPORTED` | A supabase-js feature Seige does not support, such as `textSearch()` or `csv()`. | No |
| `OPERATION_FAILED` | Any other failure. `message` is the reason, for example `worker_unavailable`, `opener_policy_blocks_sign_in`, `sign_in_timed_out`, `blocked_by_csp`, `folder_member_limit`, or `file_not_found` when `seige.files.get()` names a path that does not exist. | Depends on the reason |

There is no separate code for a missing file: check
`error.code === "OPERATION_FAILED" && error.message === "file_not_found"`, or
list the folder first with `seige.files.list()`.

`error.status` is the HTTP status when Seige answered. `error.details` carries
structured context: `retryAfter` for `RATE_LIMITED`, `worker` and `reason` for
`worker_unavailable`, and the OAuth `error` and `description` for a sign-in
that Seige ended with an error.

**Connecting the app.** Seige identifies an app by the website it runs on, so
there is no client ID or callback address to set up. The first call that needs
the user's data opens Seige in a small window: the user signs in if needed and
approves this website's access to its own folder in their account. The call
waits and continues when they approve. If the call did not come from a click,
the page first shows a small "Continue with Seige" prompt, because browsers
open windows only in response to a click. Its button, or the next click in the
app that calls the SDK, opens the window. A read when the page loads is such a
call; to avoid the prompt, call `seige.auth.user()` on load and, when its
`data` is `null`, make the first data call from a click. The first `seige.ai`
call asks for AI access the same way. Approval is remembered in this browser, so later calls
and reloads never ask again.

To ask up front, for example from a "Sign in" button, call
`seige.auth.signIn()`. `seige.auth.user()` returns `{ data: user }`, or
`{ data: null }` when no one is signed in, and never asks. The user is `{ id }`, where `id` is a stable identifier
for the account that is specific to the app's website; another website sees a
different value. After the user approves `seige.auth.signIn({ email: true })`,
it also has `email` and `emailVerified`. Seige gives apps no username or
display name.

If the user closes the window or dismisses the page prompt, the call returns
`SIGN_IN_CANCELLED`; if they decline in the window, `ACCESS_DENIED`, including
for `seige.from()` queries. Calls waiting for the same window receive the same
result rather than asking again. If the user later disconnects the app in Seige, the
next call asks them to connect it again. If this browser's session ended, for
example because a token renewal was interrupted, the next call asks them to
sign in again, and the page prompt says that their session ended.

`seige.auth.onChange(({ user }) => ...)` calls back after this tab signs in or
out, after its session ends (`user` is `null`), and when another tab of the
same app signs in or out. It is not called for the state at the time of
subscribing; read that with `seige.auth.user()`. It returns a function that
unsubscribes.

```js
const stop = seige.auth.onChange(({ user }) => render(user));
```

Sign-in returns its result from the Seige window to the page through
`window.opener`. A page sent with `Cross-Origin-Opener-Policy: same-origin`
cuts that link, so the SDK checks the page's own headers before it opens Seige
and returns `OPERATION_FAILED` with message `opener_policy_blocks_sign_in`.
It reads them when the SDK starts, so the error normally comes before any
window opens. Send `Cross-Origin-Opener-Policy: same-origin-allow-popups`, or
no COOP header. With `same-origin-allow-popups`, a registered app's callback
page loads in the Seige window without `window.opener`; the SDK on that page
hands the result to the waiting tab over a `BroadcastChannel` of the app's
origin and closes the window.
A Seige window that returns no result within 10 minutes, for example because
its last page failed to load, ends the call with `sign_in_timed_out`. When
Seige ends a sign-in with an error, for example after the user chooses "Return
to app and report this error", the call returns `OPERATION_FAILED` with Seige's
reason as the message.

If the SDK's crypto or SQLite worker, or the WebAssembly it loads, cannot
start, calls return `OPERATION_FAILED` with message `worker_unavailable`. The
hint names the file and the likely cause: a bundler that did not emit the
worker, a 404 or wrong MIME type, or a Content-Security-Policy without
`worker-src 'self'` or `'wasm-unsafe-eval'`. The page prompt and connection
badge use a constructed stylesheet and inline SVG, so they need neither
`'unsafe-inline'` styles nor `data:` images.

**App names and home folders.** `appName` is an optional display label (up to
80 characters). Seige shows it alongside the website origin and uses it when
creating a new home folder. Existing folders keep their names. The label never
selects a folder or changes the client identity: two origins using the same
name have separate homes. Access to another folder requires explicit consent.
Localhost origins are for browser development; a port is not a durable identity
for an installed desktop app. Native app identity and reconnect policy are
separate from this browser API.

**Registered apps.** An app registered in Seige's connected-app settings passes
its `clientId`. It then shows its registered name on Seige's approval screen and
can use several callback pages on the same host (registration rejects callback
URLs on different hosts or ports). A registered app's callback page must load the
SDK, which hands the result back to the page that opened the window.

## Browser integration

This example is the activation check: it saves a row and a file and reads both
back from the copy stored in Seige. It runs as published in Seige's own tests.

```js
import { createSeige } from "@seige_sdk/sdk";

// Saves a row and a file, then reads both back from Seige.
export async function firstWrite(options) {
  const seige = createSeige(options);
  try {
    const saved = await seige.from("messages").insert({ text: "Hello" }).select().single();
    if (saved.error) throw saved.error;
    const path = `notes/${crypto.randomUUID()}.txt`;
    const file = await seige.files.put(path, new Blob(["Hello"]));
    if (file.error) throw file.error;
    // A file write returns once it is saved on this device; wait for Seige.
    const synced = await seige.sync.flush();
    if (synced.error) throw synced.error;
    const { data: blob } = await seige.files.get(path);
    // Read the row from the copy saved in Seige, not this browser's cache.
    const { data: remote } = await seige.db.open("app.sqlite", { readonly: true });
    const { data: rows } = await remote.from("messages").select("text").eq("id", saved.data.id);
    await remote.close();
    const { data: user } = await seige.auth.user();
    return { user, rows, fileHead: file.data.head, text: await blob.text() };
  } finally {
    seige.close();
  }
}
```

Call `firstWrite()` from your app. Pass `{ issuer }` to use another Seige
environment, or `{ clientId }` for a registered app. Each run adds one row and
one file.

## Queries

`seige.from(table)` builds a query as a chain: pick a table, an operation,
filters and ordering. It runs on the app's encrypted SQLite database,
`app.sqlite` in the app's folder (set `database` in `createSeige` to rename
it). Each query is one atomic transaction. When Seige is reachable, it is saved
to Seige before it resolves; while Seige is unreachable, it resolves once saved
in this browser and publishes later, as described in
[Saving and syncing](#saving-and-syncing). Results are
`{ data, error, count, status }`.

```js
const { data: open, error } = await seige
  .from("todos")
  .select("id, title, done")
  .eq("done", false)
  .order("created_at", { ascending: false })
  .limit(20);

await seige.from("todos").insert({ title: "Pack camera" });
await seige.from("todos").update({ done: true }).eq("id", id);
await seige.from("todos").delete().eq("id", id);
```

**Tables are automatic.** The first insert into a table creates it, with an
`id` (a UUID, unless you supply one) and a `created_at` timestamp. A key that
the table does not have yet becomes a new column on insert or update. Reading
a table that does not exist yet returns no rows, unless the app uses
migrations (see below). Each column's type comes from
the first value written to it: `true`/`false` read back as booleans, and
objects and arrays read back as JSON. A misspelled column in a filter is an
error, with code `42703`.

Supported:

- `select(columns, { count, head })`, including aliases (`label:title`) and
  JSON paths (`data->>key`)
- `insert`, `upsert(values, { onConflict, ignoreDuplicates })`, `update` and
  `delete`, with `.select()` to return the affected rows
- Filters: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`,
  `contains`, `containedBy`, `overlaps` (JSON arrays), `match`, `not`, `filter`
  and `or("a.eq.1,and(b.gt.2,c.is.null)")`
- Modifiers: `order` (nulls last when ascending), `limit`, `range`, `single`,
  `maybeSingle`, `abortSignal`, `throwOnError`

Behavior to know:

- A query reads one table. To combine tables, query each and join the results
  in your app, or use SQL through `seige.db.open()`.
- `update()` and `delete()` require at least one filter.
- In bulk inserts, a key missing from a row uses the column default. Pass
  `defaultToNull: true` to insert `NULL` instead.
- Deleting a row applies the `ON DELETE CASCADE` and `ON DELETE SET NULL`
  actions of foreign keys defined by migrations. Foreign keys are enforced: a
  row that references a missing row fails with `23503`.
- There are no access policies to write: the database belongs to the user, and
  your app can reach only the folders the user approved.

Constraint errors use Postgres codes, for example `23505` for a unique
violation and `23502` for a missing required value. `.single()` returns
`PGRST116` when the query does not match exactly one row.

### Migrations (optional)

For constraints such as unique or required columns, define tables in SQL
migration files and pass them to `createSeige({ migrations })`, as
`[{ name, sql }]` or as a map of file paths to SQL. With Vite:

```js
const migrations = import.meta.glob("./migrations/*.sql", {
  query: "?raw",
  import: "default",
  eager: true,
});
const seige = createSeige({ migrations });
```

Migrations run in name order the first time the database opens, all in one
transaction. Applied names and a SHA-256 checksum of each migration's SQL are
recorded in the `_seige_migrations` table inside the user's database, so
another app granted the same folder can check the schema version.

- Applied migrations are matched by name. A renamed migration runs again, and
  a new migration whose name sorts before an applied one still runs, after the
  migrations already applied. Name files with a timestamp or sequence prefix,
  such as `20261019120000_create_books.sql`.
- Never edit an applied migration; add a new one. A migration whose SQL changed
  after it was applied fails with `MIGRATION_FAILED`, and the hint names the
  file. Line endings and leading or trailing whitespace are not changes.
- When a migration fails, none of the pending migrations is applied, and every
  query of that database returns `MIGRATION_FAILED` until the migration is
  fixed. The error message and hint name the migration that failed.
- Migrations cannot use `PRAGMA`, `ATTACH`, transaction control or triggers. A
  migration that does fails with `MIGRATION_FAILED`. Queries can read a table's
  columns with `select * from pragma_table_info('books')`.

Tables defined by migrations keep the columns the migrations give them; only
automatic tables gain columns on write. When the app passes migrations,
reading or writing a table that does not exist, such as a misspelled name,
fails with `42P01`: reads do not return no rows, and inserts do not create an
automatic table. A column that the table's migrations do not define fails with
`42703`, and the hint says to add it in a new migration. Apps without
migrations keep automatic tables: reading a table never written returns no
rows, and the first insert creates it.

An insert that omits `id` into a table whose primary key is a single text
column named `id` gets a UUID, as automatic tables do. An `integer primary key`
keeps SQLite's numbering. To match an automatic table, write:

```sql
create table books (
  id text primary key,
  created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
  title text not null
);
```

`seige.db.open(name, { migrations })` opens another database; its handle has
its own `from()`, plus `all(sql, params)`, `get` and `exec` for custom SQL.
Handles returned by `db.open()` and `files.open()` report errors by throwing,
like database drivers.

### Saving and syncing

A file write returns as soon as the change is encrypted and stored in this
browser, usually within a few milliseconds. The SDK then publishes it to Seige
in the background, in the order it was saved. Reads on this device return the
newest saved change at once. When several saves of one file are waiting, only
the newest is sent.

Show a change as saved when the write returns. Publication is reported
separately:

- `seige.sync.status()` returns one of the strings `synced`, `syncing`,
  `offline`, `needs_attention` or `reauthentication_required`, not a
  `{ data, error }` result. It has no change event; read it again after writes
  or on an interval. `seige.sync.resources()`
  lists the state of each file and database this instance has used, as
  `{resource, state, reason}`. Both describe the SDK once it has started:
  before the page's first other call, `status()` reports `synced` even if an
  earlier visit left changes to publish. `sync.flush()` starts the SDK and
  reports them.
- `await seige.sync.flush()` waits until every change saved on this device has
  been published. It returns `NETWORK_UNAVAILABLE` while Seige is unreachable,
  `CONFLICT` for a conflicted change, `IN_USE` when another browser keeps
  writing the database (see below), `ALLOWANCE_EXCEEDED` while an upload is
  refused because the account is out of storage, and `REAUTH_REQUIRED` when
  the user must sign in again. Call it before an action that needs the data in Seige, such as
  signing out, rather than after every write.

Database writes through `seige.from()` and `db.exec()` publish to Seige before
they return when Seige is reachable. SQLite runs in memory, and the SDK keeps an encrypted copy for
recovery. If Seige is unreachable, a database change stays in an encrypted
queue in the browser and the call still succeeds.

**Several browsers.** A database has one writer at a time. A browser takes the
database's writer lease when it writes and hands it back once the write is
published, so another browser usually finds it free. A `seige.db.open()`
handle keeps the lease until `close()`. A browser that stops without handing
it back, for example after a crash, keeps it for at most 30 seconds after its
last write; a page that is closed or navigated away asks Seige to release it,
which usually succeeds. Reading never takes the lease, so the same app open in
another browser or tab can always read the newest version stored in Seige.
A write made while another browser holds the lease waits for it, for up to
about 45 seconds, and then applies to the newest version, including the other
browser's changes. While it waits, `sync.resources()` lists the database with
`state: "syncing"` and `reason: "open_elsewhere"`, and the waiting write is
stored in this browser. If the page closes during the wait, the write runs on
the newest version the next time the app calls the SDK in this browser; if
this browser also had unpublished changes and the database changed elsewhere,
it is reported through `seige.sync` as a `CONFLICT`. If the lease
is not released in time, the write is not saved and fails with `IN_USE`; the
app can try again or ask the user to close the app in the other browser. A
change already saved in this browser, for example during an outage, is kept
and published once the lease is free.

Pending changes survive a reload. The SDK retries them when connectivity
returns, when the page becomes visible, and with backoff while it is open.
`seige.sync.retry()` retries pending work now. Quota, integrity, permission and
version failures are reported, not retried. Every retry checks current
authorization, so a pending write cannot outlive a revoked grant.

**Reading a file you intend to write back.** `seige.files.read(path)` returns
`{ content, head, mime }`: the bytes and the version of exactly those bytes.
Pass that `head` as `put()`'s `expectedHead`. `seige.files.get(path)` returns
the content alone, with no version, so it must not be the basis of a
conditional write: the head would have to come from a separate
`seige.files.list()`, and the two can disagree. A read while Seige is
unreachable is served from this browser's cache and can be older than the
listing, so a write quoting the listed head would be accepted and would
publish over the newer version it claimed to be based on. `read()` keeps the
pair together, so a stale read carries its own stale head and the write is
refused with `CONFLICT`, which the app resolves by reading again and merging.

```js
const { data: file } = await seige.files.read("diary.json");
const next = update(JSON.parse(new TextDecoder().decode(file.content)));
const saved = await seige.files.put(
  "diary.json",
  new TextEncoder().encode(JSON.stringify(next)),
  { expectedHead: file.head },
);
if (saved.error?.code === "CONFLICT") return retryFromCurrent();
```

**Folder handles.** `seige.files.open(folder)` returns a handle that reads and
writes whole files by object ID, for apps that keep their state in a few
documents. `put(object, content, { expectedHead })` returns
`{ queued: true, head }`. `head` names the version just saved on this device:
pass it as `expectedHead` with the next change. An older head fails with
`CONFLICT`, because a newer change replaced that version. `flush(object)` waits
until the file is published and returns its head in Seige, or
`{ queued: true }` while Seige is unreachable.

```js
const { data: folder } = await seige.files.open(folderId);
let { head } = await folder.get(documentId);
// On every edit: returns once the change is stored on this device.
({ head } = await folder.put(documentId, bytes, { expectedHead: head }));
// Before signing out: wait until Seige has the document.
await folder.flush(documentId);
```

### Offline conflicts

A conflict occurs when a pending change was based on a version that another
device has since replaced. The SDK never merges the two versions and
never overwrites the newer remote version. For the conflicted file or database:

- Retries stop. Other files and databases continue to synchronize, and
  `sync.retry()` and `sync.flush()` report `CONFLICT` after retrying them until
  the conflict is resolved.
- The local change stays in the encrypted SDK queue.
- `sync.status()` reports `needs_attention`, and `sync.resources()` lists the
  resource with `state: "needs_attention"` and `reason: "conflict"`.
- Reads return the newer remote version. Writes to that file or database fail
  with `CONFLICT` until the conflict is resolved.

`seige.files.getConflict(path)` returns `{ data }` with `null` or a file
conflict. `seige.db.getConflict()` returns `{ data }` with `null` or the
conflict of the database that `seige.from()` uses; pass a name for another
database. A folder handle's `getConflict(object)` and a database handle's
`getConflict()` return the conflict directly. A conflict has `baseHead`,
`remoteHead` and `pending`, the local change. The pending change is decrypted
only in this browser. Resolve a conflict in one of two ways:

```js
const { data: conflict } = await seige.files.getConflict("notes/today.txt");
if (conflict) {
  // Either drop the local change and keep the remote version:
  await conflict.resolve("discard-local");
  // Or write an application-defined result on top of the remote version:
  await conflict.resolve(async ({ remote, pending }) =>
    mergeNotes(remote.content, pending.content),
  );
}
```

For the database behind `seige.from()`, copy the rows the local change added
into the remote version. Use `INSERT OR IGNORE` so rows both versions share
are kept once:

```js
const { data: conflict } = await seige.db.getConflict();
if (conflict) {
  const mine = await conflict.pending.all("SELECT * FROM todos");
  // Show `mine` next to the current list, then on the user's choice:
  await conflict.resolve(async ({ remote, pending }) => {
    for (const row of await pending.all("SELECT * FROM todos"))
      await remote.run(
        "INSERT OR IGNORE INTO todos (id, created_at, title) VALUES (?, ?, ?)",
        row.id,
        row.created_at,
        row.title,
      );
  });
}
```

Inside a resolver, use only `remote` and `pending`; `seige.from()` waits until
the resolution finishes.

A file resolver receives `{head, content, metadata}` for the remote version and
`{content, metadata}` for the pending change. It returns the new content, or
`{content, metadata}`; metadata defaults to the pending change's metadata. A
database resolver receives `remote`, a writable copy of the remote database
(`exec`, `run`, `get`, `all`), and `pending`, a read-only view of the local
database (`get`, `all`, `export`). Apply the local edits that should survive to
`remote`; the SDK then publishes that copy. A database resolution does not merge
SQL automatically. Resolving a database requires a connection to Seige because
it reads the remote version; `discard-local` on a file works offline.

The resolved value is written with the remote head that the resolver read as
its predecessor. If the remote version changes again in the meantime, the
result becomes a new conflict. There is no option to overwrite the remote
version with the local change. Private SQLite databases have one active writer;
this API does not merge concurrent edits from multiple users.

Files support relative paths such as `receipts/september.pdf`. `put` accepts a
`Blob`, `File`, or `Uint8Array`; `get` returns the file as a `Blob` in `data`;
`list("receipts/")` (or `list("receipts")`) returns the entries inside the
folder, not the folder itself, with paths, object IDs, types, sizes, and
verified heads. Subfolders are entries with `type: "folder"`; a folder can be
deleted once it is empty, so delete the files first, or keep only
`type === "file"` entries when exporting. `delete` is permanent and frees the file's storage: the entry
does not go to Seige Files trash, and `get` then returns `file_not_found`. The
app's own database file (`app.sqlite`) is not listed and cannot be replaced or
deleted through `files`. Parent directories use the existing encrypted
Files metadata, so files remain interoperable with Seige Files. Updates use the
observed predecessor; ambiguous names and stale predecessors are errors.

Access to the user's other folders is explicit:

```js
const { data: folder } = await seige.request({
  resource: "folder", id: selectedFolderId, access: ["read"]
});
if (folder) {
  const invoice = await folder.files.get("september.pdf");
}
```

The ID is a Seige folder UUID. If access is missing, the Seige window asks the
user to share that folder, and the call continues when they approve. The result
has its own `files` and `db` methods, which throw on errors like other handles.
It does not change the app's own folder or grant access to anything else.
`seige.request({ access: "read" })` or `"write"` lets the user choose folders
to share.

Tokens, app-only keys, metadata, pending writes, and snapshots are encrypted in
origin-local IndexedDB. Its non-extractable WebCrypto wrapping key is available
to authorized code in that origin; this does not protect against compromised app
JavaScript. Account-wide private keys never enter the SDK. Plaintext exists in
memory while the app processes data. Clearing browser storage removes local
pending work; it does not remove previously committed account data.

`auth.signOut()` revokes this device's OAuth token family and clears local active
authority across tabs. It keeps encrypted data and app keys, and leaves other
devices signed in. A failed remote sign-out still clears local authority and
returns an error; it cannot claim server revocation while the service is
unreachable. Reauthentication obtains fresh session authority for the same
retained resources. If this browser is still signed in to Seige, `auth.signIn()`
returns directly to an already approved installation. Seige checks the account,
installation keys, live approval, requested scopes, and current folder grants
before reusing consent. Signing out does not disconnect the installation.
New browsers, expired or revoked approvals, and added permissions require consent.
Explicit folder selection still opens the trusted picker. Clearing app storage
removes its device keys and requires a new approval.
`seige.close()` stops one instance without revoking its session.

The SDK refreshes access tokens automatically. When Seige's token endpoint
definitively rejects the refresh token, for example with `invalid_grant`, the
next call asks the user to sign in again in the Seige window. When the
refresh request did not reach token processing, the session stays signed in,
calls fail with `NETWORK_UNAVAILABLE`, and pending work retries with the normal
backoff. This covers a browser that reports itself offline, a request that
failed before it carried the DPoP nonce Seige requires, and a `429` or `503`
answer. Seige rotates the refresh token on every use and treats a second use of
a spent token as theft: it revokes the authorization of that browser's
installation. The app's other browsers are separate installations and stay
connected. If a refresh may have reached Seige and its
answer was lost, such as a dropped connection after the nonce was sent, a
timeout or another `5xx` error, the SDK does not send that token again, and the
next call asks the user to sign in again. In each case local data and queued
encrypted writes are kept and publish after the user signs in again.

Seige requires a server-issued DPoP nonce in every OAuth request proof. Seige's
discovery document carries a current nonce in its `DPoP-Nonce` header, and the
SDK uses it for the first pushed authorization request of a sign-in. A nonce
stays valid for about three minutes. When the page loaded the discovery
document longer ago than that, Seige refuses the request without storing it,
answering `400 use_dpop_nonce` with a fresh `DPoP-Nonce` header, and the SDK
sends the request again with that nonce. The browser
console then lists the first answer as a failed request. It is expected and
does not mean that sign-in failed. The SDK does not keep a nonce between page
loads.

## Browser installations and keys

Each browser profile that runs your app is its own installation. On first use
the SDK generates the installation's recipient and writer keys in that browser.
Only public certificates, with proofs signed by both keys, are sent to Seige.
The proofs bind the keys to the app (its website, or its client ID) and to
that browser's DPoP key.
Seige records the public certificates and grants folder keys to them; it never
generates or stores the private keys.

The private keys are v6 OpenPGP secret keys, stored only as AES-256-GCM
ciphertext in the SDK's IndexedDB vault. The wrapping key is a non-extractable
WebCrypto key, and the additional authenticated data binds the origin, the vault
namespace and the record name. The keys are decrypted into JavaScript and Wasm
memory while in use; JavaScript strings cannot be zeroised. Script running in
the app's origin, whether injected through XSS or shipped in a malicious app
build, can use or copy them. The non-extractable wrapping key prevents export
through WebCrypto; it does not prevent forensic reading of a browser profile.

A new browser goes through full consent. When the app requests selected folders,
Seige pre-selects the folders the app's other browsers can use now, and the user
confirms or changes them. Signing in again on the same browser, for example
after the session ends, keeps its installation and its existing access.

Disconnecting a browser in Settings, or disconnecting the whole app, retires
that installation's keys permanently. Seige replaces the affected folders' keys
in the same step, so a retired key cannot decrypt anything written after the
disconnect; content it already received cannot be recalled. The SDK reports
`reauthentication_required` from `sync.status()`. Pending local changes stay
encrypted in the browser and are not reported as saved. The next call that
needs the account asks the user to connect the app again, which creates new
keys and a new installation. After
the user approves it, the SDK sends the kept changes again with the new keys;
packets signed with the retired keys are deleted, never sent. A change whose
remote version was replaced in the meantime becomes a conflict. The new
installation receives the folder keys for earlier versions as well as the
current one, so existing files and databases stay readable.

`seige.auth.pendingFingerprint()` returns `{fingerprint, thumbprint, phrase}` for the
keys this browser will present, creating them if needed; it returns `null` while
signed in. `seige.auth.fingerprint()` returns the authorized installation's
value. Seige's approval screen computes the same fingerprint from the keys it
received. While authorization is pending, the SDK shows a card on the app's page with the
connection badge, and the consent screen shows the same badge.
The emoji is only a visual cue, not a security check. Both screens expose the
full fingerprint under "Connection details". Apps rendering their own comparison
UI can use `phrase` from either fingerprint method.

The phrase is a 66-bit comparison hint, using six 11-bit indices from the first
66 bits of the fingerprint into the fixed BIP-39 English word list. It is public,
not a password or recovery seed, and is never used to identify a folder, derive a
key, or authorize a grant. The full 120-bit display fingerprint remains available
for stronger comparison; the 256-bit thumbprint and cryptographic bindings are
unchanged. The full-fingerprint check detects
a Seige identity service, database or insider replacing the app's public keys
between your app and the consent screen, provided your app's origin is honest and
the consent code Seige serves is unmodified. It does not protect against a
compromised app origin or modified consent code, and it relies on the user
comparing the values; it is an additional check, not the primary protection.

Seige's servers can see how many installations each app has on an account, their
public keys, the dates they were added and removed, and the sizes and timing of
requests. Browser labels such as "Chrome on macOS" are derived on Seige's
consent page and stored only encrypted to the account owner's key.

## App-scoped inference

Call `seige.ai.generate({ prompt, input })`. The first call asks the user for
AI access; `seige.request({ access: "ai" })` asks ahead of time. The result's
`data` contains `text` and reported token `usage`. Seige controls credentials,
routing, budgets and current authorization. Pass an `AbortSignal` as `signal`
to cancel a request.

Each request reserves an estimated cost from the user's Seige AI allowance and
settles at the reported actual cost, or a request ceiling when cost is unknown.
The user also sets a monthly limit for each app when they allow AI: $2.00 by
default, with $0.50, $5, $10, $20 or Off, changeable at any time under
Settings → Connected apps. Off keeps the app connected without AI.

`ai.generate` fails with code `ALLOWANCE_EXCEEDED` when an estimate does not fit
in the app's remaining monthly limit (`error.status` 402 and an
explanatory `error.message`), when it exceeds the account's available credit
(including pending reservations), or when the account is over its storage or AI
allowance. `seige.ai.usage()` reports `spending`: `usedUsd`, `limitUsd`,
`remainingUsd` (the app's limit, capped by the account balance),
`accountBalanceUsd`, `resetAt` (the first day of next month, UTC) and
`manageUrl`, the Seige page where the user changes the limit. Show
`remainingUsd` next to AI features, and when it is zero, link to `manageUrl`
instead of offering a request that will fail. AI credit has no automatic hourly
reset.
Seige also limits AI requests per hour for each app and
account; over the limit, `ai.generate` fails with `RATE_LIMITED` and
`seige.ai.usage()` also reports hourly counts with `requestsRemaining` and
`resetAt`. It never asks
for AI access: before the user approved it, it returns `CONSENT_REQUIRED` with
message `ai_consent_required`. AI calls are never retried automatically.

If the user declines AI access in the Seige window, the call returns
`ACCESS_DENIED` with message `ai_access_denied`. The app stays connected and
its data and file calls keep working.

Storage over the allowance behaves differently because files are saved in the
browser first: `seige.files.put()` resolves once the file is stored encrypted
in this browser, and the upload runs in the background. When Seige refuses the
upload for quota, `seige.sync.status()` becomes `needs_attention`,
`seige.sync.resources()` lists the file with reason `allowance_exceeded`, and
`seige.sync.flush()` returns `ALLOWANCE_EXCEEDED`. The file stays queued and
publishes on a later retry once the user frees space.

For structured output, supply `schema: {parse(value) { ... }}` or a compatible
schema library object. The SDK requests JSON, parses the answer and validates it
through that parser; the validated value is returned as `data.value`. Invalid
JSON or a schema rejection is an error, not a successful typed answer. Describe
the expected fields in the prompt.

This is a standard model endpoint, not an agent. It receives only the input the
application supplies, which can come only from data the user gave the app. It
has no tools and no access to the account. Apps may build loops using repeated
calls, under their existing grants.

## Advanced compatibility entrypoints

The sections below describe lower-level adapters retained for existing
integrations. The browser `createSeige()` API above manages these
mechanisms internally. Ordinary browser apps do not provide keys, token stores,
request signatures or retry workers themselves.

## Register and connect

Browser apps do not need to register: without a client ID, `createSeige()` identifies the app by its website. Register an app to show its own name on the approval screen, or to decrypt data on a backend. Register in `https://account.seige.ai/settings/apps`: application name, exact HTTPS redirect URIs, and app type. Registration never grants storage access by itself.

- **App with a backend (default):** register public ES256 JWKS, keep the assertion private key on your backend, and declare where data is decrypted. This profile requires signed JAR/PAR and `private_key_jwt`.
- **Browser-only app:** select the public profile, with data decrypted on the user's device. No JWKS or client secret is registered. Pass `clientType: "public"` to `createOAuthClient` and omit `assertionKey` and `assertionKid`.

Both profiles require pushed authorization requests, S256 PKCE and DPoP. Public-client CORS permits only origins from that client's registered redirect URLs, or the website of an app identified by its website. An app identified by its website has the client ID `web-` followed by its origin in base64url; Seige returns its sign-in result to a relay page that passes it only to a window on that origin, and user identifiers are specific to that origin. Existing registrations retain confidential authentication and mandatory signed requests. Browser-held confidential client secrets are not supported.

A browser public client uses this configuration:

```js
const oauth = await createOAuthClient({
  issuer: "https://account.seige.ai/oauth",
  clientId,
  redirectUri: "https://tally.example/",
  clientType: "public",
  dpopKey,
});
```

Persist browser app keys in origin-local IndexedDB, keep each pending authorization transaction in sessionStorage, consume callbacks once, and coordinate rotating refresh tokens across tabs. Keys and tokens are accessible to code executing in that app's origin; this does not protect against compromised app JavaScript. Never cache OAuth callback URLs in a service worker. `oauth.receiptKey` exposes the verifier built from issuer-validated discovery for `connectStorage`.

The initial `storage.app` consent lets the user name a new folder or explicitly approve access to an existing one. The SDK app name suggests a label; otherwise the website hostname is suggested without its port. An existing folder requires a prominent warning and explicit trust action for new access, even after a previous connection was revoked. The home binding belongs to the connection and uses the selected folder ID; a matching name never silently grants access. Owner-signed grants and independent folder keys constrain the app to its approved folders; other folders require separate consent. A low-level connection without a key mode binds one recipient/writer key pair per account and app, and clients manage those keys themselves. The browser entrypoint instead uses one installation per browser, each with its own keys; see [Browser installations and keys](#browser-installations-and-keys).

The SDK needs a **local** native/WASM hybrid crypto dispatcher. Never implement `cryptoOperation` by forwarding requests to an HTTP service: key generation, signing, sealing and opening happen at the authorized endpoint.

```js
import {
  createOAuthClient,
  createDpopKey,
  createAppKeyProofs,
  connectStorage,
} from "@seige_sdk/sdk";

// Generate once, then persist these app keys in protected storage.
const recipient = await cryptoOperation({ operation: "generate" });
const writer = await cryptoOperation({ operation: "generate" });
const dpopKey = await createDpopKey();
const oauth = await createOAuthClient({
  issuer: "https://account.seige.ai/oauth",
  clientId,
  redirectUri,
  assertionKey,
  assertionKid,
  dpopKey,
});
const keys = await createAppKeyProofs({
  clientId,
  recipient,
  writer,
  cryptoOperation,
});
const { url, transaction } = await oauth.authorize({
  scope: "openid offline_access storage.app",
  access: "app",
  keys,
});
// Save transaction in the user's protected server session, then redirect to url.
// At the exact registered callback:
const { tokens, identity } = await oauth.callback({
  url: receivedCallbackUrl,
  transaction: savedTransaction,
});
// Delete the used transaction; persist tokens securely.
```

`identity` is verified, not merely decoded JWT content. The SDK checks issuer, signature, audience, nonce, state, authorized party and access-token hash when supplied. Storage-authorized claims include `account`, `owner_public`, and `connection`; identity-only login does not expose the storage identity. The signed request binds the registered client to its recipient/writer public keys and possession proofs.

Scopes:

| Scope            | Access                                                 |
| ---------------- | ------------------------------------------------------ |
| `openid`         | Pairwise identity for sign-in                          |
| `email`          | Seige email address                                    |
| `offline_access` | Rotating refresh token                                 |
| `storage.app`    | One stable storage folder dedicated to this app        |
| `storage.read`   | Explicitly selected additional folders, read only      |
| `storage.write`  | Explicitly selected additional folders, read and write |

Set `access: 'select-read'` with `storage.read`, or `access: 'select-write'` with `storage.write`. The user chooses folders at consent. Requesting only `openid`/`email` does not create storage. Serially refresh with `oauth.refresh(refreshToken)` and durably replace the old token; replay invalidates the grant family. A storage request uses the access token **and** a fresh DPoP signature. Keep the same DPoP key for the grant's lifetime.

## Files and durable writes

```js
import { createRemoteJWKSet } from "jose";
import { createFileStore } from "@seige_sdk/sdk/database"; // Node only

const queue = await createFileStore("./state/queue");
const pins = await createFileStore("./state/pins");
const storage = await connectStorage({
  origin: "https://account.seige.ai",
  issuer: "https://account.seige.ai/oauth",
  clientId,
  identity,
  dpopKey,
  accessToken: async () => currentTokens.access_token,
  recipientPrivate: recipient.private,
  writerPrivate: writer.private,
  cryptoOperation,
  receiptKey: createRemoteJWKSet(new URL(trustedDiscovery.jwks_uri)),
  queue,
  pins,
});
const folder = await storage.folder(storage.connection.folders[0].folder);
await folder.stage({ object: fileId, content: bytes, metadata: "notes.txt" });
await folder.awaitRemote(fileId);
const file = await folder.open(fileId);
const restored = await file.read();
```

The discovery issuer and JWKS URL must be validated against your configured issuer. Do not construct a verifier from an arbitrary key embedded in a receipt. Applications with multiple users/connections must namespace queue and pin stores by account and connection. Browser stores must implement `load(key)`, `save(key, value)` and `clear(key)` with durable transactions and app-appropriate protection.

`stage()` persists an exact encrypted packet locally. `flush()` / `awaitRemote()` publish it and verify a signed acceptance receipt before advancing the durable head. Retries reuse identical ciphertext. A lost acknowledgment is recoverable without preparing new ciphertext. `pending(object)` reports locally queued work. Treat a local save and remote durability as different states in your UI.

`folder.open(object)` authenticates manifests before fetching chunks. `read(offset, length)` retrieves authenticated blocks with a bounded cache; `read()` returns the entire object. Lists expose opaque IDs and heads. File names and application metadata belong in the encrypted manifest. Objects can contain arbitrary app bytes, not only documents.

The service atomically verifies current connection, grant, epoch, quota, optional writer fence and expected predecessor at publication. A revision conflict is not success and must not be automatically overwritten. Applications should surface conflict recovery or merge data intentionally.

## Node queries

`createSeigeNode` from `@seige_sdk/sdk/node` opens databases with the same
query API and migrations as the browser:

```js
import { createSeigeNode } from "@seige_sdk/sdk/node";

const seige = createSeigeNode({ storage });
const db = await seige.db.open({
  folder,
  object: databaseId,
  path: "./state/trips.sqlite",
  migrations: [{ name: "001_trips", sql: "create table trips (id integer primary key, city text not null)" }],
});
await db.from("trips").insert({ city: "Lisbon" });
const { data } = await db.from("trips").select().order("city");
await db.flush();
await db.close();
```

In Node, query writes commit to the local SQLite file, like the adapter's
other writes. `flush()` publishes them and verifies Seige's receipt; `close()`
flushes by default. Queries, filters, error codes, value decoding and
migration rules match the browser, and migrations are rejected if they use
`PRAGMA`, `ATTACH`, transaction control or triggers. `db.batch()`, `exec`,
`run`, `get` and `all` remain available for custom SQL.

## Advanced Node SQLite adapter

```js
import { openDatabase } from "@seige_sdk/sdk/database";
const db = await openDatabase({
  folder,
  object: databaseId,
  path: "./notes.sqlite",
});
db.database.exec("CREATE TABLE IF NOT EXISTS notes (body TEXT)");
db.database.prepare("INSERT INTO notes VALUES (?)").run("Hello");
await db.flush();
await db.close();
```

This legacy Node-only adapter writes a plaintext local SQLite file at the supplied path. It is outside the encrypted-device-storage browser API described above. SQL runs against that local SQLite database. `flush()` uses SQLite's online backup API, which captures a consistent database including WAL changes. Never copy a live `.sqlite` file independently of its WAL. The adapter holds a renewable 30-second remote lease with a monotonically increasing fence, plus a local exclusive lock. A stale process cannot commit after another writer acquires a new fence.

The adapter persists its baseline, lease holder and pending encrypted bytes for recovery. A database whose local baseline differs from the remote head is not silently uploaded over newer content. An expired pending upload can be republished under a new upload ID/fence while retaining the exact ciphertext and predecessor. Closing defaults to flushing; `close({ flush: false })` closes without claiming remote durability.

Changed 64 KiB blocks are encrypted under a fresh random AES-256-GCM key for each preparation. Unchanged blocks remain in existing versions. Encrypted manifests bind lengths, metadata, base/result hashes, changed positions and the content key. Associated data binds scope, object, revision, predecessor and ordinal. The signed header binds ordered ciphertext hashes and the manifest.

The SQLite adapter keeps one acknowledged plaintext baseline in its protected process memory and checks it against the authenticated remote content hash before reuse. Warm flushes upload changed blocks without downloading the whole database again; restart or uncertain recovery may require one fresh download. Local backup/comparison still scales with database size, and remote flush latency includes network and verification work. This is a local database with encrypted synchronization, not a network SQLite VFS or multi-writer SQL service. Checkpoints bound cold-read delta chains; they are independent of membership changes. Once a checkpoint commits, the service removes superseded encrypted blocks and releases their storage quota. A reader interrupted by compaction must reopen the latest checkpoint. Compact retry receipts remain for up to one hour so a lost acknowledgement can be recovered without keeping obsolete file content. Permanent deletion releases file content and retains a small tombstone that prevents reuse of the deleted object ID.

## Revocation and historical data

Account settings replaces the affected folder's access key when access is removed. New grants contain fresh keys sealed only to remaining recipients. Existing encrypted blocks stay in place; a new-epoch delta can use the authenticated previous epoch as its base. Remaining readers receive explicit `historicalGrants` needed for retained data. A historical key does not authorize a current write.

Reconnect storage after membership changes. Never continue writing with an old grant when the service rejects its epoch. Removing access cannot erase plaintext, keys or ciphertext an app already copied, and old keys can still open old ciphertext obtained later. It prevents disclosure of future protected changes. See [revocation design](https://github.com/liuzzi/seige/blob/main/docs/architecture/connected-app-revocation.md) for the research and exact boundary.

The user's account keys, an app's long-term recipient key and unrelated folders are not rotated on an ordinary folder revocation. Refresh tokens and ordinary file edits do not cause folder-key rotation. A compromise may require additional key replacement.

## Cryptographic module

`createFolderSdk({ cryptoOperation, clock? })` supplies the lower-level primitives:

- `createFolder({ account, folder?, epoch? })`: trusted owner creates independent folder key material.
- `rotateFolder(scope)`: generate independent next-epoch material locally.
- `grant({ owner, scope, connection, recipientPublic, writerPublic, operations, expiresAt })`: owner-signed authorization plus a recipient-encrypted key envelope.
- `verifyGrant({ grant, ownerPublic, expected? })`: verify the complete `{id,signed,envelope}` grant and its public claims; no secret keys needed.
- `openGrant({ grant, ownerPublic, recipientPrivate, expected })`: decrypt a pinned grant; returns a session.
- `verifyWrite({ ownerPublic, scope, object, previous, packet, writerAuthorization })`: public signature/hash verification, returning a candidate head.
- Session `prepareWrite`, `openWrite`, `openManifest`, `lock`: encrypted deltas, authenticated sparse reading and local handle lifecycle.

`expected` on `openGrant` contains `account`, `folder`, `epoch`, `connection`, `grantId`. On `verifyGrant`, the grant ID field is `id`. `writerAuthorization` is `{grantId,connection,epoch}` resolved from authenticated state, never simply trusted from the submitted packet. Historical writer expiry exceptions require verification of the original signed acceptance receipt. A current reader grant is still required.

Only grants and encrypted packets belong on the wire. `scope`, owner keys, recipient private keys, writer private keys, local snapshots and decrypted results stay inside authorized clients. Local `lock()` rejects handle operations but cannot remotely erase copied keys or guarantee physical erasure of JavaScript memory.

## Limits and trust boundary

Snapshots: 100,000,000 bytes. Block size: 65,536 bytes. Metadata: 65,536 bytes. At most 1,526 changed blocks. Decoded signed/encrypted controls: 1 MiB. Large snapshots may require several times their size in memory. App connections/history use bounded JSON responses; content quotas include ciphertext and pending reservations. Encrypted authorization metadata uses a separate 24 MiB allowance; new approvals stop at 20 MiB to leave space for revocation.

The hybrid protocol's format identifier retains `seige-folder-delta-preview-1` for compatibility.

Use one coordinated SDK runtime for each durable queue/pin store. Handles sharing the same pin-store object serialize operations; separate processes or independently created store adapters require exclusive ownership or external coordination. Durable local head/epoch pins detect rollback relative to observed state. A service-signed receipt is not an independent witness against that same service. Fresh-device rollback resistance needs an authenticated checkpoint transfer or independent witness. Trusted client updates, unlocked devices and approved recipients remain security boundaries. The identity service authenticates the app-to-key binding during consent, and the account owner's signature on the consent covers the installation ID, the app and the exact public keys that receive grants. App private keys are generated in the app's browser and never sent to Seige, so Seige's servers cannot deliver or disclose them. A malicious or compelled issuer could still present its own public key as the app's during consent, or serve modified consent code; the consent-screen fingerprint lets a user detect the first when the app displays its own value. Code running in the app's origin has full use of the app's keys. HTTPS is required on hosted endpoints; plaintext content is encrypted before transport. Seige can observe account/routing metadata, sizes and timing.

## Unreleased

Each app has a monthly AI spending limit that the user chooses on the consent
screen (default $2.00, or Off) and can change under Connected apps.
`seige.ai.usage()` returns `spending` with the app's dollar usage, limit,
remaining amount, the account balance, the reset date and the Seige page for
changing the limit. A request over the app's limit fails with
`ALLOWANCE_EXCEEDED`.

## Changes in 0.5.3

AI allowance refusals describe available credit, including pending requests.
The SDK's allowance guidance distinguishes monetary credit from hourly request
limits; AI credit has no automatic hourly reset. Server admission now preserves
allowance errors and returns hourly capacity for requests refused before generation.

## Changes in 0.5.2

- Error codes are a fixed set, typed as `SeigeErrorCode`. `SEIGE_OPERATION_FAILED`,
  `SEIGE_CONSENT_REQUIRED` and `SEIGE_UNSUPPORTED` are now `OPERATION_FAILED`,
  `CONSENT_REQUIRED` and `UNSUPPORTED`. `SEIGE_INVALID_QUERY` and
  `SEIGE_INVALID_VALUE` are now the SQLSTATE codes `42601` and `22023`. For
  this release, `error.legacyCode` keeps the old code.
- HTTP 429 returns `RATE_LIMITED` instead of `NETWORK_UNAVAILABLE`, and HTTP
  413 `quota_exceeded` returns `ALLOWANCE_EXCEEDED` instead of
  `OPERATION_FAILED`.
- Every error has a hint. `error.details` can be an object.
- A registered app sent with `Cross-Origin-Opener-Policy:
  same-origin-allow-popups` completes sign-in instead of returning
  `SIGN_IN_CANCELLED` when its callback page loses `window.opener`.
- A Content-Security-Policy that blocks the request to Seige returns
  `OPERATION_FAILED` with message `blocked_by_csp`, as documented, instead of
  `NETWORK_UNAVAILABLE`.
- `seige.ai.usage()` no longer opens the Seige window. Without AI access it
  returns `CONSENT_REQUIRED` with message `ai_consent_required`.
- `files.list("folder")` no longer includes the folder itself.
- New: `seige.auth.onChange()`. When another tab of the app signs in again
  after a sign-out, this tab uses the new session instead of reporting a
  sign-out.
- `QueryErrorCode` lists the SQLSTATE and PostgREST codes queries return
  (`QUERY_ERROR_CODES` at run time), so `error.code` is a closed union.
- With migrations, inserting into a table that does not exist fails with
  `42P01` instead of creating an automatic table.
- Writes through a database handle opened with `{ readonly: true }` fail with
  `PERMISSION_DENIED`, message `read_only`, instead of a raw `SQLITE_AUTH`
  error or `42501`. The handle's SQL methods return the same SQLSTATE codes as
  `seige.from()`. The `ReadonlyBrowserDatabase` type has no `run()`, `exec()`
  or `flush()`.
- Constraint errors that the SDK returns are no longer also printed to the
  console by SQLite.
- HTTP 429 and other error responses from Seige carry CORS headers and expose
  `Retry-After`, so a rate-limited sign-in in the browser returns
  `RATE_LIMITED` with `details.retryAfter` instead of `NETWORK_UNAVAILABLE`.
- The low-level exports on the main entry (`createOAuthClient`,
  `connectStorage`, `createFolderSdk`, `PlatformClient`, `createAI` and
  related) still work and are marked deprecated. Import the connection and
  folder functions from `@seige_sdk/sdk/connection` and
  `@seige_sdk/sdk/encrypted-folder`; use `seige.ai` instead of `createAI`.

## Testing an app that uses the SDK

[Testing apps that use Seige](https://seige.ai/developers/testing/) shows how
to drive the Seige window from Playwright, wait for writes, prove the data is
in Seige and run the tests in CI. After `seige.close()`, calls on that client
return `OPERATION_FAILED` with message `client_closed`.

## Verification

From a prepared Seige checkout, `npm run check:sdk` packs the SDK twice to check
reproducibility, installs it in a temporary external project using locked
dependency artifacts, checks browser/Node types and bundling, and runs the
existing cryptographic, OAuth, storage-recovery, and SQLite suites against that
installed archive. It requires the existing native crypto test binary. The push
and full verification gates build that binary before checking the package.

```sh
cargo build --locked -p seige-crypto-core --bin seige-crypto
node --test tests/security/encrypted-folder.test.mjs tests/security/delegated-client.test.mjs
node --test tests/security/oauth-issuer.test.mjs
```

Tests exercise a real issuer HTTP exchange, hybrid-key possession, DPoP/PKCE, refresh replay, real SQLite WAL backup, sparse reads, exact lost-ACK retry, stale leases, receipts and one-block updates across key epochs. Service integration tests additionally require an isolated real PostgreSQL database. Browser qualification covers account consent/settings and WASM/native interoperability.
