# Retry-safe writes

> Send a request ID with capture, remember, or a document save so a retry after a lost response finishes the same write instead of duplicating or overwriting it.

Section: Guides  
Canonical: https://gno.sh/docs/retry-safe-writes  
Markdown: https://gno.sh/docs/retry-safe-writes.md

A timeout, dropped connection, crash, or server restart can leave a caller unsure whether a write landed. Attach an optional request ID to a capture, a remember, or a document save and GNO records it in a private local ledger. Retrying with the same ID returns the original outcome, or finishes an interrupted write, instead of creating a second note, superseding a fact twice, or overwriting a newer edit.

Callers that send no request ID keep their response shapes, with one documented exception: capture and document saves now run under the shared write lease, so a busy lease returns the typed busy error (`409 LOCKED` for REST saves) and two saves from the same revision cannot both win. A CLI or SDK capture whose lexical sync fails still comes back as a receipt with `sync.status: "failed"`. See [Without a request ID](#without-request-id).

## Where request IDs are accepted

| Operation                        | CLI                                                         | MCP                                 | REST                                         | SDK                                   |
| -------------------------------- | ----------------------------------------------------------- | ----------------------------------- | -------------------------------------------- | ------------------------------------- |
| Capture                          | `gno capture ... --request-id <id>`                         | `gno_capture` argument `requestId`  | `POST /api/capture` body `requestId`         | `client.capture({ ..., requestId })`  |
| Remember (add or supersede)      | `gno remember ... --add\|--supersede ... --request-id <id>` | `gno_remember` argument `requestId` | `POST /api/memory/remember` body `requestId` | `client.remember({ ..., requestId })` |
| Document save (content and tags) | not available                                               | not available                       | `PUT /api/docs/:id` body `requestId`         | not available                         |

- An ID is 1 to 128 characters: letters, digits, `.`, `_`, `:`, and `-`, starting with a letter or digit. A UUID works. The format is checked before anything is admitted.
- A remember with a request ID needs an explicit `add` or `supersede` decision. A candidates-only call that carries an ID is rejected with `REQUEST_ID_INVALID`.
- The Web UI capture dialog and editor send request IDs for you; see [Web UI](#web-ui).
- The browser clipper route `POST /api/capture/clip` does not accept `requestId`; a body containing it is rejected with `400 CLIPPER_INVALID_REQUEST`. The clipper keeps its own [idempotency-key recovery](https://gno.sh/docs/browser-clipper#recovery).

## Check before you retry

1. Generate a fresh ID (a UUID) for each write intent and keep it before you send the request.
2. After a timeout or a lost response, look the ID up before doing anything else.
3. `committed`: use the recorded result and do not resend. `pending`: resend the exact same call with the same ID; GNO finishes it. `not_found`: nothing was accepted under the ID; resend with the same ID. `expired`: the ID already ran and will not run again; check current state before choosing a new ID.
4. Never reuse an ID for a changed payload. A new intent gets a new ID.

```
id=$(uuidgen)
gno capture --file ./call-notes.md --collection notes --request-id "$id" --json
# The terminal closed before the result arrived.
gno request-status "$id"
# pending or not_found: run the identical command again with the same ID.
gno capture --file ./call-notes.md --collection notes --request-id "$id" --json
```

| Surface | Lookup                                                                                                                   |
| ------- | ------------------------------------------------------------------------------------------------------------------------ |
| CLI     | `gno request-status <request-id> [--json]`                                                                               |
| MCP     | `gno_request_status { requestId }`, read-only, registered with `--enable-write` on the full tool profile (not in `core`) |
| REST    | `GET /api/requests/:requestId`                                                                                           |
| SDK     | `client.requestStatus(requestId)`                                                                                        |

```
{
  "requestId": "3f2c9a0e-5b1d-4c7a-9e21-6d0f8a4b7c11",
  "status": "committed",
  "operation": "capture",
  "createdAt": "2026-09-24T08:00:00.000Z",
  "updatedAt": "2026-09-24T08:00:00.120Z",
  "result": {
    "uri": "gno://notes/inbox/call-notes.md",
    "docid": "#abc123",
    "contentHash": "..."
  }
}
```

- `status` is `pending`, `committed`, `expired`, or `not_found`. `operation` is `capture`, `remember`, or `document.update`.
- `result` appears only for committed requests: the `uri`, `docid`, and `contentHash` (capture, remember) or `sourceHash` (document save). It never contains note or fact text.
- `not_found` carries only `requestId` and `status`. Lookups see only the caller’s own [namespace](#namespaces).
- The CLI text output ends with the next step for each status.

## What a write returns with a request ID

A successful write that carried an ID gains one field. It is absent when no ID was sent.

```
"request": {
  "requestId": "3f2c9a0e-5b1d-4c7a-9e21-6d0f8a4b7c11",
  "status": "committed",
  "replayed": false,
  "committedAt": "2026-09-24T08:00:00.120Z"
}
```

- The first execution returns `replayed: false`. An identical retry returns the retained outcome unchanged with `replayed: true`; nothing runs again.
- REST replays keep the original HTTP status, for example 201 for capture and remember.
- A replayed document save returns `jobId: null`: the original save already started its index refresh.
- CLI text output adds `Request: <id> committed`, plus `(replayed, nothing written again)` on a replay.

## Same ID, same intent

- The same ID with the same payload, destination, and expected revision or predecessor is the same intent. GNO replays it, or finishes it if it was interrupted.
- Transport identity is not part of the intent. The remember `caller` and `session`, MCP session IDs, and server instance IDs are excluded, so a retry after a reconnect or a server restart still matches.
- The same ID with a different payload, destination, revision, predecessor, or operation fails with `REQUEST_ID_CONFLICT`. The ID belongs to another intent.
- Concurrent identical requests converge on one outcome: one executes and the others replay. If the executing writer holds the write lease longer than the wait, the others get `REQUEST_PENDING`; retry the same ID later.

## When a write counts as committed

| Operation          | Committed when                                                                               |
| ------------------ | -------------------------------------------------------------------------------------------- |
| Capture            | The note file is written and lexically synced, so it is retrievable.                         |
| Remember add       | The fact file is written and lexically synced.                                               |
| Remember supersede | The successor is written and synced and its `supersedes` edge is projected.                  |
| Document save      | The source file is written and its new `sourceHash` is recorded, along with any tag changes. |

A committed document save means the canonical file on disk is updated. Lexical search catches up in a deferred sync job and embeddings follow through the scheduler. Neither is part of the commit, and a failure in either never undoes the save: run `gno update` or `gno embed` to recover. A retried exact-duplicate `remember --add` is recorded as its `existing` outcome and replays as such.

## Rejected or interrupted

- **Rejected before any write**: validation errors, a stale predecessor hash, a supersede conflict, a document revision `CONFLICT`, a missing document, or a busy lease. Nothing is recorded, the lookup reads `not_found`, and retrying the same ID evaluates it again against current state.
- **Accepted, written, then interrupted**: a crash, a killed process, or a lexical sync failure such as `CAPTURE_SYNC_FAILED`, `MEMORY_SYNC_FAILED`, or `MEMORY_SUPERSEDE_PROJECTION_FAILED`. With a request ID a sync failure is returned as an error on every surface, and the request stays `pending`. Retrying the same ID finishes the recorded write (sync, edge projection) instead of writing again. Retrying with a new ID would be a second write.
- **Target changed after it was written**: if the recorded file was edited, deleted, or recreated before the retry, GNO refuses with `REQUEST_RECOVERY_CONFLICT` and leaves the current state untouched. GNO never recreates a note you deleted. Read the current state and decide again; a new ID is a new intent.

The guarantee is one durable local side effect per admitted request for these writes. It makes no claim about external systems, and the deferred index refresh and embeddings after a document save are separate work.

## Error codes

| Code                         | Meaning                                                                                                       | HTTP | CLI exit |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | ---- | -------- |
| `REQUEST_ID_INVALID`         | Malformed or oversized ID, or an ID on a non-write call                                                       | 400  | 1        |
| `REQUEST_ID_CONFLICT`        | The ID was already used for a different intent                                                                | 409  | 1        |
| `REQUEST_EXPIRED`            | The ID was used before and its full receipt has expired; it will not run again                                | 410  | 1        |
| `REQUEST_PENDING`            | Accepted and still in progress elsewhere; retry the same ID later                                             | 409  | 4        |
| `REQUEST_RECOVERY_CONFLICT`  | The recorded write’s target changed or disappeared after it was written; nothing was overwritten or recreated | 409  | 2        |
| `REQUEST_CAPACITY_EXHAUSTED` | The ledger is full; rejected before any write                                                                 | 507  | 2        |
| `REQUEST_LEDGER_UNAVAILABLE` | The ledger cannot be opened; rejected before any write                                                        | 503  | 2        |

- REST: `{ "error": { "code": "...", "message": "..." } }`.
- CLI: the error envelope code is `VALIDATION`, `BUSY`, or `RUNTIME`, with the request code in `details.requestCode`.
- SDK: a `GnoSdkError` (`VALIDATION` or `RUNTIME`) with the request code in `details.code`.
- MCP: tool error text `CODE: message` and `structuredContent { error: CODE, ... }`.

`REQUEST_RECOVERY_CONFLICT`, `CONFLICT`, and `MEMORY_PREDECESSOR_HASH_MISMATCH` all mean the same thing for a caller: read the current document or recall the current fact, then decide again. Never force an overwrite.

## Who can see a request

- The CLI, the local SDK, stdio MCP, and the REST API of `gno serve` share one local-owner namespace per index.
- HTTP MCP uses the authorized identity: loopback, or the digest of the configured bearer token. It is stable across server restarts and is not tied to a session or client name.
- Distinct HTTP MCP identities cannot read, replay, or probe each other’s requests. Another identity’s ID reads as `not_found`, and the same ID under another identity is simply that caller’s own request.
- Rotating the token creates a new namespace. Old requests cannot be looked up or resumed under the new token, and a client must not automatically resend an uncertain old write under the new token as if it were new.
- Shared credentials share authority. There is no multi-user authorization layer.

## Local ledger, retention, and privacy

Each index has one private SQLite ledger next to its index database, at `<data dir>/write-receipts/<index db filename>`:

- Linux: `~/.local/share/gno/write-receipts/index-default.sqlite`
- macOS: `~/Library/Application Support/gno/data/write-receipts/index-default.sqlite`
- Windows: under `%LOCALAPPDATA%\gno\data\write-receipts\`

On POSIX systems the directory is created `0700` and the file `0600`. The ledger is local state: nothing in it leaves your machine.

- It lives outside the index database. Index rebuilds, `gno update`, re-embedding, and deleting or rebuilding `index-*.sqlite` do not touch it. `gno reset` preserves `write-receipts/` and removes everything else in the data directory; nothing in GNO removes the ledger. Deleting that directory by hand discards replay protection for every request it recorded. Include it when you back up private GNO state.
- Committed receipts keep their full outcome for 30 days, then are compacted to a permanent minimal tombstone (namespace, request ID, operation, digest, timestamps). A tombstoned ID returns `REQUEST_EXPIRED`, reads `expired`, and never runs again.
- The ledger has one fixed cap of 100,000 rows, receipts and tombstones together. When it is full, new requests with an ID are rejected with `REQUEST_CAPACITY_EXHAUSTED` before any write, existing receipts still replay, and writes without an ID are unaffected. There is no configuration setting and no cleanup command.
- It holds private recovery data: destination paths, hashes, and retained outcomes, and a remember outcome includes the fact text. None of it is exposed through status lookups, logs, or public diagnostics; a lookup returns only the content-free pointer shown above.
- Request IDs are separate from recall receipts and capture provenance receipts. A recall `receipt` fences remembered text; a `requestId` identifies one write for retries.

## Web UI

The capture dialog and document saves (editor autosave, `Cmd/Ctrl+S`, and tag saves) generate one request ID per submitted intent and reuse it while the same payload is retried, for example with **Try again** after a network error. Edited content or different tags get a new ID, and after a success the next submit is a new intent.

A save whose response was lost and is then retried gets the committed result instead of a false “Document changed on disk” conflict. Pending and conflict errors show the server message. Stale edits still get `CONFLICT` and are never force-overwritten. The unconfirmed request ID is kept for the browser tab, so retrying the same capture or save after a page refresh still replays instead of writing twice; after a refresh the editor reloads the current document from disk.

## Without a request ID

Writes without an ID keep their response shapes and outcomes, with a weaker guarantee when a response is lost and the caller retries:

- A retried capture may create a suffixed duplicate (`create_with_suffix`) or fail because the note already exists (`error`).
- A retried `remember --add` returns `existing`, or adds the fact again if it was superseded in between.
- A retried supersede fails with `MEMORY_SUPERSEDE_CONFLICT`.
- A retried document save fails with `CONFLICT` when it sends `expectedSourceHash`, or overwrites a newer edit when it does not.
- A write interrupted after the file landed but before sync can be duplicated by a retry.

One change applies to every client: capture and document saves run under the shared write lease, and the SDK capture takes it too.

- If the lease stays busy past the wait, the write returns the typed busy error and nothing is written. For REST saves through `PUT /api/docs/:id` (the Web UI editor and tag editing) that is `409 LOCKED`.
- Two saves from the same revision cannot both win: the revision check and file write happen under the lease, and the second save gets `409 CONFLICT`.
- A `gno capture` or `client.capture()` whose lexical sync fails still exits 0 or resolves with a receipt carrying `sync.status: "failed"`; run `gno update` to index the note. Opening an existing note that was never indexed (`open_existing`) still returns `sync.status: "skipped"`. Only a capture that carries a request ID turns a sync failure into an error and a pending request.
