# Wispist

> Persistent JSON documents and live updates for simple browser sites — no connection step, no application server.

---

# What is Wispist

_Wispist / Getting started_

> Wispist gives simple browser sites persistent JSON documents and live updates with no connection step, browser-visible secret, or dedicated application server.

**Wispist** is a lightweight data engine for simple websites. It gives ordinary HTML and JavaScript a persistent document store, safe concurrent mutations, and live updates while keeping the site itself static.

On a Wispdeck-hosted site, Wispist is already there:

```js
const checklist = wispist.collection("before-you-go");

const item = await checklist.add({
  text: "Buy travel insurance",
  done: false,
});

await checklist.update(item, { done: true });
```

There is no connection call, project identifier, endpoint, API key, or secret in that code. Wispdeck identifies the site and its active release from the request, then supplies an opaque namespace and policy to the embedded Wispist Engine. The same-origin browser client cannot choose another site's store.

## Static code, persistent data

A Wispist site is still a static bundle:

```text
site.zip
├── index.html
├── app.js
├── styles.css
└── wispist.json
```

`wispist.json` declares which collections the release may use and who may perform each operation. Wispdeck validates that declaration when the bundle is uploaded, and inserts a small parser-blocking client before site-authored elements in every served HTML `head`, so `globalThis.wispist` exists before later site scripts run.

The site's documents live in SQLite, outside the immutable release. Republishing code therefore keeps production data. An idle site has no dedicated process, timer, poller, goroutine, or database connection; work happens only while a viewer or mutation is active.

## The data model

Wispist v1 has three main concepts:

- A **namespace** is the host-selected isolation boundary. Wispdeck uses separate `live` and `draft` namespaces for each site.
- A **collection** is a declared, ordered set of documents, such as `before-you-go`.
- A **document** contains an opaque ID, an opaque revision, server timestamps, and a caller-owned JSON object in `data`.

Every replacement and deletion is conditional on an observed revision. If two browsers edit the same document concurrently, one succeeds and the other receives a [revision conflict](/wispist/problems/revision-conflict.md) rather than silently overwriting work it did not see.

Subscriptions use Server-Sent Events. The client first lists a consistent collection view, then consumes retained changes after that list cursor. It reconnects after ordinary network interruptions and relists if the retained history is no longer sufficient.

## Policy is public, enforcement is not

The release declaration contains policy, not credentials. The concise `shared` profile deliberately allows every visitor to list, read, create, update, delete, and subscribe:

```json
{
  "version": 1,
  "collections": {
    "before-you-go": {
      "access": "shared"
    }
  }
}
```

That is suitable for a genuinely public collaborative checklist. It is not user authorisation. Exact-origin checks, quotas, and request limits reduce cross-site and automated abuse, but any visitor can still change a `shared` collection.

Expanded declarations can set every operation to `anyone`, `authenticated`, or `nobody`. Wispdeck's first integration supplies anonymous principals to hosted site code, so `authenticated` is reserved for future identity integration rather than a way to reuse the dashboard cookie.

## Draft and live state

Wispdeck previews use a fresh private origin. Draft code reads and writes the site's draft namespace. The Current selector on that preview origin reads the live namespace with an unconditional server-side read-only override.

Publishing changes which release supplies public code and policy. It does not promote draft documents, clear live documents, or create a new production store. This prevents test data from silently replacing real data.

## What v1 deliberately leaves out

Wispist v1 is document CRUD plus live collection updates. It does not expose SQL, run user functions, execute scheduled jobs, perform joins, provide offline conflict resolution, accept file uploads, or offer multi-document transactions. A data directory supports one active process.

The small surface is intentional: Wispist is for the state that turns a static page into a useful little shared application, not a general application server.

## Where to go next

To build the checklist from the snippet above end to end, follow [Build a shared checklist](/wispist/getting-started/build-a-shared-checklist.md).

For the data model in full — namespaces, envelopes, revisions, conditional mutation — read [Documents and collections](/wispist/using-wispist/documents-and-collections.md).

For every field the declaration file accepts, read the [wispist.json reference](/wispist/reference/wispist-json.md).

---

# Build a shared checklist

_Wispist / Getting started_

> Add a persistent, live-updating checklist to a plain static site with one wispist.json declaration and the built-in browser client.

This page builds the smallest useful Wispist site: a checklist that persists and updates in every open browser. The site has no build step and no connection configuration.

## Declare the collection

Create `wispist.json` at the root of the site bundle:

```json
{
  "version": 1,
  "collections": {
    "before-you-go": {
      "access": "shared",
      "limits": {
        "maxDocuments": 250,
        "maxDocumentBytes": 4096
      }
    }
  }
}
```

The `shared` profile lets every viewer read and change the collection. Use it only when that is the intended product behaviour. The two limits lower Wispist's installation defaults for this collection.

## Write the page

Create `index.html`:

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Before you go</title>
  <script src="/app.js" defer></script>
</head>
<body>
  <main>
    <h1>Before you go</h1>

    <form id="new-item">
      <label>
        New item
        <input name="text" maxlength="180" required>
      </label>
      <button>Add</button>
    </form>

    <p id="status" aria-live="polite">Loading…</p>
    <ul id="items"></ul>
  </main>
</body>
</html>
```

Do not add the Wispist client script yourself on Wispdeck. Wispdeck inserts it before the page's own `script` element, so `wispist` exists when `app.js` runs.

## Add and render documents

Create `app.js`:

```js
const checklist = wispist.collection("before-you-go");
const form = document.querySelector("#new-item");
const list = document.querySelector("#items");
const status = document.querySelector("#status");

let documents = new Map();

function render(items) {
  documents = new Map(items.map((item) => [item.id, item]));
  list.replaceChildren();

  for (const item of items) {
    const row = document.createElement("li");
    const checkbox = document.createElement("input");
    const text = document.createElement("span");

    checkbox.type = "checkbox";
    checkbox.checked = Boolean(item.data.done);
    checkbox.dataset.id = item.id;
    text.textContent = String(item.data.text);
    row.append(checkbox, text);
    list.append(row);
  }

  status.textContent = `${items.length} shared item(s)`;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const input = new FormData(form).get("text").trim();
  if (!input) return;

  try {
    await checklist.add({ text: input, done: false });
    form.reset();
  } catch (error) {
    status.textContent = error.detail || "The item could not be added.";
  }
});
```

`add` creates a document with a generated ID and returns the complete document envelope. The client supplies the required idempotency key automatically.

## Apply safe updates

Add a delegated change listener:

```js
list.addEventListener("change", async (event) => {
  const checkbox = event.target.closest('input[type="checkbox"][data-id]');
  const item = checkbox && documents.get(checkbox.dataset.id);
  if (!item) return;

  checkbox.disabled = true;
  try {
    await checklist.update(item, { done: checkbox.checked });
  } catch (error) {
    checkbox.checked = Boolean(item.data.done);
    status.textContent = error.code === "revision_conflict"
      ? "Somebody else changed that item. Try again from the latest version."
      : error.detail || "The item could not be changed.";
  } finally {
    checkbox.disabled = false;
  }
});
```

`update` accepts the observed document, not only its ID. It shallow-merges the new fields locally and sends a replacement conditional on `item.revision`. A stale revision becomes a typed [revision conflict](/wispist/problems/revision-conflict.md).

## Subscribe

Start the live collection at the end of `app.js`:

```js
const unsubscribe = checklist.subscribe(render, {
  onError(error) {
    status.textContent = error.detail || "Live updates are unavailable.";
  },
});
```

`subscribe` returns immediately. It lists every page, opens a change stream from that list cursor, maintains a document map, and calls `render` with the initial state and every later state.

Call `unsubscribe()` when a long-lived page no longer needs the collection. Closing a page closes its EventSource automatically.

## Upload and test

Put the three files at the ZIP root:

```text
checklist.zip
├── index.html
├── app.js
└── wispist.json
```

Upload the archive as a Wispdeck site draft. In preview, the checklist uses draft data. Publish the release, open the public site in two independent browser windows, and add or check an item in one. The other should update without a refresh.

When a later draft exists, changes made in Draft remain separate. Select Current to see live data in read-only mode. Publishing that draft changes the code and declaration while leaving the public checklist intact.

## Where to go next

- [Documents and collections](/wispist/using-wispist/documents-and-collections.md) — the envelope, revision, and conditional-mutation model behind `update`.
- [Live updates](/wispist/using-wispist/live-updates.md) — what `subscribe` does underneath: snapshot, cursor, Server-Sent Events, reconnect, reset.
- [wispist.json reference](/wispist/reference/wispist-json.md) — every declaration field, profile, and limit.

---

# Documents and collections

_Wispist / Using Wispist_

> Understand Wispist namespaces, declared collections, document envelopes, revisions, ordering, and conditional mutation semantics.

Wispist stores JSON objects as revisioned documents inside declared collections. The host supplies the namespace; site code selects only a collection and, where needed, a document ID.

## Namespaces are the isolation boundary

A namespace is opaque to Wispist. It cannot be selected through the browser API and is never inferred from a collection name. Wispdeck binds every request to a stable site store and either its `live` or `draft` namespace.

The same collection name in two sites, or in one site's live and draft views, addresses different data. Republishing does not change the site's stable store key.

## Collections must be declared

A collection exists for browser access only when the active release declares it in `wispist.json`. Removing the declaration hides stored documents but does not delete them. Re-adding it makes them accessible again.

Names are 1 to 48 ASCII bytes, begin with a lowercase letter, and continue with lowercase letters, digits, hyphens, or underscores:

```text
[a-z][a-z0-9_-]{0,47}
```

Collection order is document creation order, with ID as the deterministic tie-breaker.

## A document has server and caller fields

```json
{
  "id": "passport",
  "revision": "opaque-revision-token",
  "createdAt": "2026-07-13T12:34:56.123Z",
  "updatedAt": "2026-07-13T12:35:04.456Z",
  "data": {
    "text": "Pack passport",
    "done": false
  }
}
```

Wispist owns `id`, `revision`, `createdAt`, and `updatedAt`. The caller owns `data`, whose root must be a JSON object. Nested arrays, objects, strings, numbers, booleans, and `null` are allowed.

JSON is strict. Invalid UTF-8, duplicate member names, more than 32 nesting levels, keys longer than 256 UTF-8 bytes, and oversized representations are rejected. Wispist stores compact JSON but does not give object-member order any meaning.

IDs may be caller-selected or generated. They contain 1 to 64 ASCII letters, digits, underscores, or hyphens and are case-sensitive. `.` and `..` are not valid IDs.

## Revisions prevent silent overwrite

A revision identifies one incarnation of one document. Every successful create or replacement produces a new opaque value, and deleting then recreating an ID does not reuse an old revision.

The easiest safe operations are the browser client's document-envelope methods:

```js
const observed = await checklist.get("passport");
const changed = await checklist.update(observed, { done: true });
await checklist.delete(changed);
```

`update` and `delete` carry the observed revision. If another caller changed the document first, Wispist returns `revision_conflict`. It never falls back to last-write-wins.

Creating a deterministic ID uses create-only semantics:

```js
await checklist.create("passport", {
  text: "Pack passport",
  done: false,
});
```

That operation fails with a revision conflict if `passport` already exists.

## One document per transaction

Each create, replacement, or deletion commits the document state and one ordered change record in the same SQLite transaction. A subscriber can therefore never observe a change whose document mutation failed to commit.

V1 does not provide multi-document transactions. Model one atomic unit as one document when several fields must change together.

## Where to go next

For the client methods that carry these semantics — `get`, `create`, `replace`, `update`, `delete` — read the [Browser API reference](/wispist/reference/browser-api.md).

For what happens when a conditional mutation loses a race, read [Revision conflict](/wispist/problems/revision-conflict.md).

For how draft and live namespaces relate across releases, read [Drafts and publishing](/wispist/using-wispist/drafts-and-publishing.md).

---

# Live updates

_Wispist / Using Wispist_

> How Wispist subscriptions combine paginated snapshots, retained changes, Server-Sent Events, reconnection, and reset without polling.

`collection.subscribe(callback)` keeps one collection view current without polling. It combines an initial paginated list with a same-origin Server-Sent Events stream.

## Snapshot, then changes

Opening a subscription does four things:

1. Lists every page of the collection.
2. Records the change cursor captured at the first page.
3. Opens `/_wispist/v1/changes` after that cursor.
4. Applies create, update, and delete events to an in-memory map.

Pagination is not a frozen SQLite snapshot across requests. The cursor is what closes the gap: mutations that race the list appear in the retained change stream after the first page's watermark.

The callback receives the complete ordered document array and an event:

```js
const stop = checklist.subscribe((documents, event) => {
  render(documents);
  if (event.type === "initial") {
    console.log("Initial state is ready");
  }
}, {
  onError(error) {
    showStatus(error.detail);
  },
});
```

Call `stop()` to close the stream and cancel reconnect work.

## Delivery and reconnection

Wispist commits a mutation before publishing it to in-memory listeners. On connection, the server registers the listener before reading retained changes, sends the backlog, removes duplicate queued events by cursor, and then continues live.

Delivery is at least once. Cursors are opaque and the client de-duplicates them. Ordinary disconnects reconnect with bounded exponential backoff and jitter.

Change history is finite. If a cursor is older than retained history, or a subscriber falls behind its bounded event queue, the server sends a `reset` event and closes. The browser client relists automatically rather than risking silent divergence.

## Active compute only

An open EventSource is active work. Wispist caps streams per site and client address and sends periodic comments to keep healthy intermediaries from treating an idle connection as abandoned.

There is no listener, poller, or goroutine for a site with no active subscription. Reading an untouched collection or opening its first stream does not create a SQLite file; the first mutation does.

## Where to go next

For the wire-level change stream — cursors, event shapes, heartbeats — read the [HTTP API reference](/wispist/reference/http-api.md#change-stream).

For the stream caps and retention bounds behind `reset`, read [Limits and abuse controls](/wispist/reference/limits.md).

---

# Drafts and publishing

_Wispist / Using Wispist_

> Wispdeck keeps Wispist draft data separate from live data, makes Current preview read-only, and preserves production documents across publish and rollback.

Wispist data belongs to a site, not to one release. Releases select code and access policy; they do not each get a new production database.

Wispdeck binds each request as follows:

| View | Declaration | Namespace | Mutations |
| --- | --- | --- | --- |
| Public published site | Published release | Live | Per collection policy |
| Public site with no release | None | None | Denied |
| Private preview, Draft | Draft release | Draft | Per collection policy |
| Private preview, Current | Published release | Live | Always denied |

The Draft namespace remains stable across successive draft uploads for the same site, so ordinary iteration does not discard test data.

## Publishing changes code, not data

Publishing or rolling back atomically changes the release pointer used by the public origin. Existing live documents remain where they are.

In particular:

- Publishing the first release does not copy draft documents into live.
- Republishing preserves live documents.
- Rolling back code preserves live documents.
- Removing a collection declaration hides its documents without deleting them.
- Re-adding the declaration exposes those documents again.

Promotion is deliberately not implicit. Draft data can be incomplete, destructive, private, or nonsensical. A future copy or promotion feature must be an explicit management action.

## Current is a safe comparison view

When both a public release and a new draft exist, preview offers Current and Draft. Current loads public code and live data on the private preview origin, but Wispdeck sets an unconditional read-only binding. A collection declaration cannot relax it.

`wispist.mode` reports `live-preview` and `wispist.readOnly` is `true`, allowing the page to hide editing controls. Those values are only presentation hints; the server rejects mutations even if site code ignores them.

## Where to go next

For what a rejected Current-preview mutation looks like to code, read [Forbidden](/wispist/problems/forbidden.md).

For the declaration each release carries, read the [wispist.json reference](/wispist/reference/wispist-json.md).

---

# wispist.json reference

_Wispist / Reference_

> The complete v1 release declaration schema, collection-name grammar, access profiles, operation policies, and collection-specific limits.

`wispist.json` is an optional file at the site archive root. It declares the collections exposed to browser code by that release.

The parser is strict:

- The file must be UTF-8 JSON no larger than 64 KiB.
- Duplicate object members and unknown fields are errors.
- `version` must be `1`.
- `collections` is required and may contain at most 32 entries.
- Collection limits may lower, but never raise, host defaults.
- Missing `wispist.json` means zero declared collections.

An invalid declaration rejects the complete upload. It never falls back to a permissive policy.

## Top-level object

| Member | Type | Required | Meaning |
| --- | --- | --- | --- |
| `version` | integer | yes | Protocol declaration version; exactly `1`. |
| `collections` | object | yes | Collection name to collection declaration. |

## Collection names

Names match:

```text
[a-z][a-z0-9_-]{0,47}
```

They are ASCII, case-sensitive, and compared byte-for-byte.

## Collection declaration

| Member | Type | Required | Meaning |
| --- | --- | --- | --- |
| `access` | string or object | yes | A built-in profile or all six operation policies. |
| `limits` | object | no | Lower document count and byte limits. |

### Shared profile

```json
{
  "access": "shared"
}
```

`shared` expands to `anyone` for `list`, `read`, `create`, `update`, `delete`, and `subscribe`. Every visitor can read and change the collection.

### Expanded access

```json
{
  "access": {
    "list": "anyone",
    "read": "anyone",
    "create": "nobody",
    "update": "nobody",
    "delete": "nobody",
    "subscribe": "anyone"
  }
}
```

Every operation must appear exactly once:

| Operation | Controls |
| --- | --- |
| `list` | Listing collection documents. |
| `read` | Reading one document and receiving its body in a subscription. |
| `create` | Generated-ID POST and selected-ID create. |
| `update` | Replacing an existing document. |
| `delete` | Deleting an existing document. |
| `subscribe` | Opening a change stream for the collection. |

Each value is one of:

| Value | Meaning |
| --- | --- |
| `anyone` | Permit every principal. |
| `authenticated` | Require an authenticated principal asserted by the host. |
| `nobody` | Deny the operation. |

Wispdeck initially supplies anonymous principals to ordinary hosted-site and preview JavaScript. Preview authorisation grants access to the preview, not authenticated Wispist authority.

`read` and `list` are intentionally independent, as are `read` and `subscribe`.

### Collection limits

```json
{
  "limits": {
    "maxDocuments": 250,
    "maxDocumentBytes": 4096
  }
}
```

| Member | Type | Range | Default |
| --- | --- | --- | --- |
| `maxDocuments` | integer | 1 to host maximum | 1,000 |
| `maxDocumentBytes` | integer | 2 to host maximum | 32 KiB |

These limits apply to that collection. The namespace also has a total live or draft byte limit.

## See also

- [Limits and abuse controls](/wispist/reference/limits.md) — the installation defaults these declarations may lower.
- [Documents and collections](/wispist/using-wispist/documents-and-collections.md) — what a declared collection contains.
- [Forbidden](/wispist/problems/forbidden.md) — what a denied operation returns.

---

# Browser API reference

_Wispist / Reference_

> Complete reference for the automatic global Wispist client, collection CRUD methods, subscriptions, environment fields, and WispistError.

Wispdeck defines one non-enumerable, non-writable global before site-authored elements run:

```text
wispist.version
wispist.mode
wispist.readOnly
wispist.collection(name)
```

There is no connect method. The client makes no request during evaluation.

## Environment fields

| Field | Type | Meaning |
| --- | --- | --- |
| `version` | number | Client protocol major; `1`. |
| `mode` | string | `live`, `draft`, or `live-preview`. |
| `readOnly` | boolean | Whether the host has forced mutations off. |

`mode` and `readOnly` are synchronous presentation metadata. The server enforces the real binding independently.

## Collection handle

```js
const items = wispist.collection("before-you-go");
```

`collection(name)` validates the collection-name grammar locally and returns a frozen handle. The server still requires the active release to declare it.

### `list(options?)`

Returns a promise for every document in creation order. `options.limit` selects the page size from 1 to 250; the client follows pagination automatically.

```js
const documents = await items.list({ limit: 100 });
```

### `get(id)`

Returns one complete document envelope or rejects with `not_found`.

```js
const document = await items.get("passport");
```

### `add(data)`

Creates a generated-ID document and returns it. `data` must be a non-null object and not an array. The client generates the required idempotency key.

```js
const document = await items.add({ text: "Passport", done: false });
```

### `create(id, data)`

Creates exactly the selected ID and returns it. It uses create-only semantics and rejects with `revision_conflict` if that ID exists.

### `replace(document, data)`

Conditionally replaces the observed document with `data` and returns the new envelope. A concurrent change rejects with `revision_conflict`.

### `update(document, changes)`

Shallow-merges `changes` into `document.data` locally, then performs `replace`.

```js
const updated = await items.update(document, { done: true });
```

An `undefined` change is omitted by JSON serialisation but does not delete the old property from the local merge. To remove properties or make nested changes, construct the complete new object and call `replace`.

### `delete(document)`

Conditionally deletes the observed document. The promise resolves with no value.

## Subscribe

```js
const unsubscribe = items.subscribe((documents, event) => {
  render(documents);
}, {
  limit: 250,
  onError(error) {
    showError(error);
  },
});
```

The callback runs once with `event.type === "initial"` and after each applied change. Documents remain in creation order, with ID as the deterministic tie-breaker.

The client reconnects after recoverable EventSource failures with bounded exponential backoff and jitter. A server `reset` causes a fresh list. `onError` receives initial or terminal errors; ordinary reconnects do not create unhandled promise rejections.

`subscribe` returns the unsubscribe function immediately.

## `WispistError`

Rejected operations use an Error subclass with:

| Field | Meaning |
| --- | --- |
| `name` | Always `WispistError`. |
| `type` | RFC 9457 problem type URI, when an HTTP problem was returned. |
| `code` | Ergonomic code mapped from a known problem type. |
| `title` | Stable problem title. |
| `detail` | Safe occurrence-specific explanation. |
| `status` | HTTP status, when available. |
| `instance` | Problem occurrence URI, when available. |
| `requestId` | `X-Request-ID` value, when available. |
| `problem` | Complete decoded Problem Details object. |

The wire protocol does not contain a separate code. `type` is the stable machine identifier; `code` is client convenience. Network failures use `network_error` without a type or HTTP status. An invalid non-problem response uses `invalid_response`.

Known codes are listed in [Problem Details](/wispist/problems/overview.md).

## Browser dependencies and side effects

The client uses `fetch`, `EventSource`, and Web Crypto. It has no package dependency, local-storage use, service worker, analytics, or telemetry. Requests are same-origin, use same-origin credentials, and disable browser caching.

## See also

- [HTTP API reference](/wispist/reference/http-api.md) — the routes these methods call.
- [Problem Details](/wispist/problems/overview.md) — every `code` a `WispistError` can carry.
- [Live updates](/wispist/using-wispist/live-updates.md) — how `subscribe` behaves across disconnects and resets.

---

# HTTP API reference

_Wispist / Reference_

> Wispist v1 routes, JSON envelopes, conditional requests, idempotency, pagination, change streams, headers, and same-origin requirements.

Wispist v1 is served from the hosted site's own origin under `/_wispist/`:

```text
GET    /_wispist/client/v1.js
GET    /_wispist/v1
GET    /_wispist/v1/collections/{collection}/documents
POST   /_wispist/v1/collections/{collection}/documents
GET    /_wispist/v1/collections/{collection}/documents/{document}
PUT    /_wispist/v1/collections/{collection}/documents/{document}
DELETE /_wispist/v1/collections/{collection}/documents/{document}
GET    /_wispist/v1/changes
```

Use the browser client unless direct HTTP control is necessary.

## General rules

- API responses use `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`.
- JSON responses use `application/json; charset=utf-8`.
- JSON request bodies require `Content-Type: application/json`.
- Every response includes `X-Request-ID`. A caller-provided UUID may be retained.
- Errors use RFC 9457 `application/problem+json`.
- Unsupported methods on known resources return `405` with `Allow`.
- Paths and identifiers must be decoded, canonical, and free of encoded separators or dot segments.
- No CORS permission is emitted.

Unsafe requests require one exact same-origin `Origin` header. If `Sec-Fetch-Site` is present, it must be `same-origin`.

## Service description

```http
GET /_wispist/v1
```

```json
{
  "name": "Wispist",
  "version": 1,
  "mode": "live",
  "readOnly": false,
  "collections": ["before-you-go"]
}
```

## List documents

```http
GET /_wispist/v1/collections/before-you-go/documents?limit=100&after=opaque
```

`limit` defaults to 100 and may be 1 to 250. `after` is an opaque pagination cursor.

```json
{
  "documents": [
    {
      "id": "passport",
      "revision": "opaque",
      "createdAt": "2026-07-13T12:34:56.123Z",
      "updatedAt": "2026-07-13T12:34:56.123Z",
      "data": { "text": "Pack passport", "done": false }
    }
  ],
  "after": null,
  "changes": "opaque-change-cursor"
}
```

The `changes` cursor is captured by the first page and repeated by later pages. List all pages, then consume changes after it to reconcile concurrent mutations.

## Create a generated ID

```http
POST /_wispist/v1/collections/before-you-go/documents
Origin: https://site.example
Idempotency-Key: a-strong-random-value
Content-Type: application/json

{"data":{"text":"Buy insurance","done":false}}
```

The key is required and contains 16 to 128 visible ASCII characters. The same namespace, key, and request fingerprint returns the original `201 Created` response for at least 24 hours. Reusing the key for a different request returns [idempotency conflict](/wispist/problems/idempotency-conflict.md).

The response includes `Location`, the document ETag, and the complete envelope.

## Read

```http
GET /_wispist/v1/collections/before-you-go/documents/passport
```

The ETag is the quoted opaque revision. `If-None-Match` supports wildcard, lists, and weak comparison for GET/HEAD and may return `304 Not Modified`.

## Create or replace a selected ID

Create only:

```http
PUT /_wispist/v1/collections/before-you-go/documents/passport
Origin: https://site.example
If-None-Match: *
Content-Type: application/json

{"data":{"text":"Pack passport","done":false}}
```

Replace:

```http
PUT /_wispist/v1/collections/before-you-go/documents/passport
Origin: https://site.example
If-Match: "observed-revision"
Content-Type: application/json

{"data":{"text":"Pack passport","done":true}}
```

Exactly one precondition is required. Create returns `201`; replacement returns `200`. Both return a new document and ETag.

## Delete

```http
DELETE /_wispist/v1/collections/before-you-go/documents/passport
Origin: https://site.example
If-Match: "observed-revision"
```

Success is `204 No Content`. The deletion appends a change containing ID and deleted revision but no document body.

## Change stream

```http
GET /_wispist/v1/changes?collections=before-you-go&after=opaque
Accept: text/event-stream
```

`collections` is repeatable with 1 to 8 distinct declared values. Every collection must permit `subscribe` and `read`.

```text
id: opaque-change-cursor
event: change
data: {"collection":"before-you-go","operation":"update","document":{...}}

```

A deletion supplies `id` and `revision` instead of `document`. A `reset` event means the client must list again. If `after` is absent, the stream begins after the current high-water mark and does not replay the collection.

The server sends heartbeat comments while otherwise idle. Delivery is at least once; de-duplicate by cursor.

## See also

- [Browser API reference](/wispist/reference/browser-api.md) — the supported client over these routes.
- [Problem Details](/wispist/problems/overview.md) — the error shape every route uses.
- [Limits and abuse controls](/wispist/reference/limits.md) — body, pagination, stream, and rate bounds.

---

# Limits and abuse controls

_Wispist / Reference_

> Wispist v1 storage, document, request, pagination, subscription, change-retention, idempotency, and rate-limit defaults.

Wispist applies hard resource limits and bounded rate buckets before expensive work. A release may lower collection limits but cannot raise installation policy.

## Resource defaults

| Resource | Default |
| --- | ---: |
| Collections per release | 32 |
| Documents per collection | 1,000 |
| Document JSON | 32 KiB |
| Live document data per site | 10 MiB |
| Draft document data per site | 5 MiB |
| List page | 100; maximum 250 |
| Collections per SSE stream | 8 |
| SSE streams per site and client | 6 |
| SSE streams per site | 100 |
| Pending events per stream | 128 |
| Request envelope allowance | document limit plus 4 KiB |
| Live idempotency records per namespace | 10,000 |
| Retained changes per namespace | 10,000 |
| Change age | 7 days |
| Idempotency retention | at least 24 hours |

The document-data quota counts compact document JSON, not release files. Change and idempotency tables are separately bounded.

## Initial Wispdeck rate buckets

| Bucket | Sustained | Burst |
| --- | ---: | ---: |
| Reads per site and client | 600/minute | 100 |
| Mutations per site and client | 60/minute | 20 |
| Mutations across one site | 300/minute | 60 |
| Generated-ID creates across one site | 10,000/day | 100 |
| All Wispist requests | 6,000/minute | 1,000 |

Client keys come from Wispdeck's trusted-proxy-aware address resolution. Untrusted forwarding headers are ignored.

Buckets use bounded memory and expire after inactivity. Concurrent streams use counters rather than token rates. A rejected request returns [rate limited](/wispist/problems/rate-limited.md) with `Retry-After`.

Rate limits are availability controls, not authorisation. A public `shared` collection remains publicly writable below the rate allowance.

## See also

- [wispist.json reference](/wispist/reference/wispist-json.md) — how a release lowers collection limits.
- [Quota exceeded](/wispist/problems/quota-exceeded.md), [Request too large](/wispist/problems/request-too-large.md), [Rate limited](/wispist/problems/rate-limited.md) — the problems these bounds produce.

---

# Problem Details

_Wispist / Problems_

> Wispist errors use RFC 9457 Problem Details with permanent type URIs, request-correlated instances, stable titles, and ergonomic browser codes.

Wispist API errors use RFC 9457 Problem Details and `application/problem+json`:

```json
{
  "type": "https://learn.peios.org/wispist/problems/revision-conflict/",
  "title": "Revision conflict",
  "status": 412,
  "detail": "The document changed after it was read.",
  "instance": "urn:uuid:019c0000-0000-7000-8000-000000000000"
}
```

The type URI is the primary machine identifier. It is permanent even if the implementation or documentation later moves. `title` is stable for that type; `detail` describes this occurrence. `status` matches the HTTP status.

`instance` is a `urn:uuid:` URI built from the same UUID returned in `X-Request-ID`. Give that request ID to an operator when investigating a server-side failure.

Validation problems may add an `errors` array of JSON Pointer and detail objects. Internal SQL, paths, stack traces, document bodies, tokens, and policy predicates are never returned.

The browser client maps known type URIs to convenient `error.code` values:

| HTTP | Type | Browser code |
| ---: | --- | --- |
| 400 | [Invalid request](/wispist/problems/invalid-request.md) | `invalid_request` |
| 400 | [Invalid JSON](/wispist/problems/invalid-json.md) | `invalid_json` |
| 401 | [Authentication required](/wispist/problems/authentication-required.md) | `authentication_required` |
| 403 | [Forbidden](/wispist/problems/forbidden.md) | `forbidden` |
| 404 | [Not found](/wispist/problems/not-found.md) | `not_found` |
| 405 | [Method not allowed](/wispist/problems/method-not-allowed.md) | `method_not_allowed` |
| 409 | [Idempotency conflict](/wispist/problems/idempotency-conflict.md) | `idempotency_conflict` |
| 409 | [Quota exceeded](/wispist/problems/quota-exceeded.md) | `quota_exceeded` |
| 412 | [Revision conflict](/wispist/problems/revision-conflict.md) | `revision_conflict` |
| 413 | [Request too large](/wispist/problems/request-too-large.md) | `request_too_large` |
| 415 | [Unsupported media type](/wispist/problems/unsupported-media-type.md) | `unsupported_media_type` |
| 428 | [Precondition required](/wispist/problems/precondition-required.md) | `precondition_required` |
| 429 | [Rate limited](/wispist/problems/rate-limited.md) | `rate_limited` |
| 503 | [Temporarily unavailable](/wispist/problems/temporarily-unavailable.md) | `temporarily_unavailable` |

HTTP headers retain their normal authority. `Retry-After`, `ETag`, `Allow`, and `WWW-Authenticate` are not replaced by Problem Details members.

---

# Invalid request

_Wispist / Problems_

> HTTP 400 problem returned when a Wispist path, query, identifier, cursor, precondition combination, or JSON envelope is invalid.

Canonical type:

```text
https://learn.peios.org/wispist/problems/invalid-request/
```

Status: `400 Bad Request`. Browser code: `invalid_request`.

Wispist returns this problem when the request cannot be interpreted under the v1 resource contract. Examples include an unknown or repeated query parameter, invalid collection or document ID, non-canonical path, malformed cursor, unknown request-envelope member, or contradictory conditional headers.

This differs from [Invalid JSON](/wispist/problems/invalid-json.md): the JSON may be syntactically valid while its protocol envelope or request metadata is not.

Correct the request rather than retrying it unchanged. Use the browser client where possible; it constructs canonical paths, envelopes, pagination, and mutation preconditions.

---

# Invalid JSON

_Wispist / Problems_

> HTTP 400 problem returned for malformed, ambiguous, non-object, deeply nested, invalid UTF-8, or otherwise disallowed document JSON.

Canonical type:

```text
https://learn.peios.org/wispist/problems/invalid-json/
```

Status: `400 Bad Request`. Browser code: `invalid_json`.

The submitted document representation is not permitted JSON. Wispist rejects malformed or invalid UTF-8 input, a `data` root that is not an object, duplicate object member names, more than 32 nesting levels, keys longer than 256 UTF-8 bytes, and trailing JSON values.

Generate the body with a normal JSON serialiser and pass a non-null object to the browser client. Duplicate members must be fixed at the producer; Wispist does not choose which ambiguous value wins.

An oversized representation uses [Request too large](/wispist/problems/request-too-large.md) instead.

---

# Authentication required

_Wispist / Problems_

> HTTP 401 problem returned when collection policy requires an authenticated principal and the Wispist host supplied an anonymous caller.

Canonical type:

```text
https://learn.peios.org/wispist/problems/authentication-required/
```

Status: `401 Unauthorized`. Browser code: `authentication_required`.

The collection operation is declared `authenticated`, but the host did not bind an authenticated principal. The response includes `WWW-Authenticate`.

Wispdeck's initial hosted-site integration supplies anonymous principals to public and preview JavaScript. A dashboard session or private preview cookie does not automatically become Wispist identity. Until a host identity integration is configured, change the operation to `anyone` only if public access is genuinely intended, or leave it inaccessible.

An authenticated principal that still lacks permission receives [Forbidden](/wispist/problems/forbidden.md).

---

# Forbidden

_Wispist / Problems_

> HTTP 403 problem returned when Wispist policy, origin enforcement, or a host read-only override denies an operation.

Canonical type:

```text
https://learn.peios.org/wispist/problems/forbidden/
```

Status: `403 Forbidden`. Browser code: `forbidden`.

The bound principal may not perform the operation. Common causes are:

- The collection operation is `nobody`.
- A mutation did not carry the exact bound site `Origin`, or Fetch Metadata identified it as cross-site.
- The request is using Wispdeck's Current preview, whose live-data binding is unconditionally read-only.
- A configured authoriser denied the current or proposed document.

Do not retry unchanged. Check `wispist.readOnly` to adjust Current-preview UI, and compare the active release's `wispist.json` with the operation being attempted. Client metadata is only a UI hint; the server remains authoritative.

---

# Not found

_Wispist / Problems_

> HTTP 404 problem returned when a Wispist resource, declared collection, document, or retained store state is unavailable.

Canonical type:

```text
https://learn.peios.org/wispist/problems/not-found/
```

Status: `404 Not Found`. Browser code: `not_found`.

The requested resource is unavailable. A document may not exist, the active release may not declare the collection, or the path may not identify a v1 resource.

Wispist does not reveal hidden stored documents merely because an older release once declared them. Removing a declaration makes the collection unavailable without deleting its data.

For `get`, treat this as an absent document where that is valid application state. For an entire collection, inspect the declaration attached to the release serving this request.

---

# Method not allowed

_Wispist / Problems_

> HTTP 405 problem returned when the Wispist path identifies a known resource but that resource does not implement the request method.

Canonical type:

```text
https://learn.peios.org/wispist/problems/method-not-allowed/
```

Status: `405 Method Not Allowed`. Browser code: `method_not_allowed`.

The path names a known Wispist resource, but its method is not supported. The `Allow` response header lists valid methods and is authoritative.

For example, a collection document resource supports `GET`, `HEAD`, `PUT`, and `DELETE`, while the change stream supports only `GET`. Use the route table in the [HTTP API reference](/wispist/reference/http-api.md) or the browser client.

---

# Idempotency conflict

_Wispist / Problems_

> HTTP 409 problem returned when a generated-ID POST reuses a live idempotency key for a different request fingerprint.

Canonical type:

```text
https://learn.peios.org/wispist/problems/idempotency-conflict/
```

Status: `409 Conflict`. Browser code: `idempotency_conflict`.

The `Idempotency-Key` was already used in this namespace for a different generated-ID POST. Wispist retains each successful key and response for at least 24 hours so a lost response can be retried safely.

If this is a retry, send exactly the same method, path, and document data. If it is a new logical create, generate a new strong random key. The built-in `collection.add` method does this automatically.

This problem concerns POST request identity. Concurrent document revisions use [Revision conflict](/wispist/problems/revision-conflict.md).

---

# Quota exceeded

_Wispist / Problems_

> HTTP 409 problem returned when a mutation would exceed a Wispist collection document count, namespace byte limit, or idempotency-record bound.

Canonical type:

```text
https://learn.peios.org/wispist/problems/quota-exceeded/
```

Status: `409 Conflict`. Browser code: `quota_exceeded`.

The mutation is valid in isolation but would exceed a storage bound. The transactional check may cover collection document count, total live or draft document bytes, or the maximum number of unexpired idempotency records.

Delete data that is no longer needed, reduce the proposed document, or ask the operator to review installation policy. A release declaration may lower limits but cannot raise them.

A single request or document that exceeds its byte limit uses [Request too large](/wispist/problems/request-too-large.md).

---

# Revision conflict

_Wispist / Problems_

> HTTP 412 problem returned when a create-only ID exists or a replacement or deletion no longer matches the observed document revision.

Canonical type:

```text
https://learn.peios.org/wispist/problems/revision-conflict/
```

Status: `412 Precondition Failed`. Browser code: `revision_conflict`.

The mutation's HTTP precondition did not match current state:

- `If-None-Match: *` was used to create an ID that already exists.
- `If-Match` carried an older revision for replacement or deletion.

Fetch or wait for the latest document, reconcile the user's intent, then submit a new conditional mutation using that revision. Never loop blindly: another caller may have made a meaningful conflicting change.

The browser client's `replace`, `update`, and `delete` methods accept an observed document envelope so safe concurrency is the normal path.

---

# Request too large

_Wispist / Problems_

> HTTP 413 problem returned when a Wispist request body or document representation exceeds its configured byte allowance.

Canonical type:

```text
https://learn.peios.org/wispist/problems/request-too-large/
```

Status: `413 Content Too Large`. Browser code: `request_too_large`.

The request body or `data` representation exceeds the active collection limit. Wispist limits the body before decoding and allows only a small fixed amount beyond the document limit for the JSON envelope.

Reduce the document or split independent records into separate documents. A release may request a higher collection limit only up to the operator's host maximum.

Total namespace storage exhaustion uses [Quota exceeded](/wispist/problems/quota-exceeded.md).

---

# Unsupported media type

_Wispist / Problems_

> HTTP 415 problem returned when a Wispist document mutation does not declare an application/json request body.

Canonical type:

```text
https://learn.peios.org/wispist/problems/unsupported-media-type/
```

Status: `415 Unsupported Media Type`. Browser code: `unsupported_media_type`.

Document create and replacement bodies require:

```http
Content-Type: application/json
```

Media-type parameters such as `charset=utf-8` are accepted, but another base type or a missing header is not. Set the header and send the documented `{"data": {...}}` envelope.

JSON that has the correct media type but invalid content uses [Invalid JSON](/wispist/problems/invalid-json.md) or [Invalid request](/wispist/problems/invalid-request.md).

---

# Precondition required

_Wispist / Problems_

> HTTP 428 problem returned when a Wispist replacement or deletion omits the conditional revision required for safe concurrency.

Canonical type:

```text
https://learn.peios.org/wispist/problems/precondition-required/
```

Status: `428 Precondition Required`. Browser code: `precondition_required`.

Wispist does not offer unconditional last-write-wins mutation. A selected-ID create requires `If-None-Match: *`. Replacing or deleting an existing document requires `If-Match` with the quoted observed revision.

Read the current document and use `replace`, `update`, or `delete` with that complete envelope. If the supplied revision is no longer current, the next response is [Revision conflict](/wispist/problems/revision-conflict.md).

---

# Rate limited

_Wispist / Problems_

> HTTP 429 problem returned when a bounded Wispist request token bucket or concurrent change-stream allowance has been exhausted.

Canonical type:

```text
https://learn.peios.org/wispist/problems/rate-limited/
```

Status: `429 Too Many Requests`. Browser code: `rate_limited`.

A read, mutation, generated-ID create, installation-wide request, or concurrent change stream exceeded its bounded allowance. `Retry-After` gives the minimum server-selected delay in seconds.

Wait for that delay and retry with backoff. Avoid tight loops, duplicate subscriptions, and unnecessary full-list refreshes. The built-in subscription client already reconnects with bounded exponential backoff.

Rate limiting is an availability control. It does not change collection policy or make public writes private.

---

# Temporarily unavailable

_Wispist / Problems_

> HTTP 503 problem returned when the Wispist store is busy, its bounded open-store cache is saturated, or an internal operation cannot safely complete.

Canonical type:

```text
https://learn.peios.org/wispist/problems/temporarily-unavailable/
```

Status: `503 Service Unavailable`. Browser code: `temporarily_unavailable`.

Wispist could not safely complete the operation. Expected transient causes include a busy SQLite store or temporary saturation of the bounded open-store cache. The response may include `Retry-After`.

Retry idempotent reads after a short backoff. Retry generated-ID POST only with the same idempotency key and identical request. Conditional replacements and deletions may be retried with the same observed revision; a committed competing change then becomes [Revision conflict](/wispist/problems/revision-conflict.md).

If the problem persists, give the operator the `X-Request-ID`. Wispist does not expose internal error text in the response.
