# Conditional saves and receipts

> How ClayJS uses ETags, If-Match, and Save-ID receipts to prevent lost updates, handle HTTP 412 conflicts, and recover from timed-out saves.

Source: https://clayjs.com/docs/conditional-saves/

ClayJS protects documents from silent overwrites using HTTP entity tags (`ETag` and `If-Match`) and per-attempt save receipts (`Save-ID`). When the file on the host changes underneath an open tab, the host refuses the stale save instead of overwriting the newer version.

## `ETag` and `If-Match`

Conditional saves activate when the host's `/_/meta` discovery response includes `"conditional"` in `extensions` (supported by [Hyperclay](https://hyperclay.com/docs/), [HTML Clay](https://htmlclay.com/docs/), and [Hyperclay Local](https://hyperclaylocal.com/docs/); see [Host compatibility](https://clayjs.com/docs/compatibility/)). If a host never announces `conditional`, ClayJS never sends `If-Match`, even if a save response includes an `etag`.

On a conditional host, ClayJS tracks the version stamp (`etag`) of the file the tab is based on and sends it in the `If-Match` header on every save:

```http
POST /_/save HTTP/1.1
Document-URL: https://example.hyperclay.com/board
If-Match: "8f02c16e7b4d9a30"
Save-ID: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
```

ClayJS learns and updates the held stamp from four sources:

1. **The `documentetag` root attribute**: Stamped on `<html>` by the host when serving the page, read once at boot, and stripped before every save.
2. **Discovery (`/_/meta`)**: If the served HTML carried no `documentetag`, ClayJS seeds the stamp from `document.etag` in the `/_/meta` response.
3. **Accepted saves**: Every `2xx` save response updates the held stamp to `result.etag` (or clears the held stamp if the response omits `etag`).
4. **Disk sync frames**: When [live sync](https://clayjs.com/docs/plugins/live-sync/) applies a `disk` frame, ClayJS updates the stamp to match that frame.

If a tab holds no stamp yet (for example, if the user saves immediately after load before `/_/meta` has responded on a host that did not set `documentetag`), that single save is sent without `If-Match` and uses last-write-wins.

## The conflict path (`412`)

If the file on the host has changed since the version named in `If-Match` and live sync has not merged that change into the tab yet, the host refuses the save with HTTP `412 Precondition Failed` and writes nothing.

When a save receives HTTP `412`:

1. **`msgType: "conflict"`**: `clay.save()` resolves with `ok: false`, `msgType: "conflict"`, `code: "conflict"`, and the refusal metadata (`conflictEtag`, `changedBy`, `afterTimeout`, `hadTimeout`, `reason`, `retryAfter`).
2. **Root attribute and event**: ClayJS sets `<html savestatus="conflict">` and dispatches `clay:save-conflict` on `document` with `detail: { msg, msgType, timestamp, changedBy, afterTimeout, etag }`, where `etag` is the `conflictEtag` the host refused the save against.
3. **The autosave hold**: ClayJS suspends autosave so the tab stops sending requests that would either fail or overwrite the host. Your unsaved edits stay intact in the live DOM. Pressing `Cmd+S` (`Ctrl+S`) still attempts an explicit save, which will be refused again while the conflict stands.
4. **The conflict notice**: ClayJS shows a built-in conflict bar with three actions:
   - **Keep mine**: Calls `clay.save.overwrite()`, which fetches the host's current `etag` from `/_/meta` and runs `clay.save.force()` to overwrite the host's version with this tab's page.
   - **Accept theirs**: A two-step confirmation (**Accept theirs**, then **Yes, drop my edits**) that reloads the page and discards this tab's unsaved changes.
   - **Download my copy**: Downloads this tab's HTML document along with a list of replaced edits.

### Automatic resolution with live sync

When the [`sync`](https://clayjs.com/docs/plugins/live-sync/) plugin is loaded, most `412` refusals resolve automatically a moment later: when live sync receives and merges the frame whose `etag` matches the refusal's `conflictEtag` without losing local edits, ClayJS releases the autosave hold, sets `<html savestatus="saved">`, hides the conflict notice (on an `autosave` page), and fires `clay:save-conflict-resolved` (`detail: { timestamp }`) on `document`.

## What `clay.save()` and `clay.save.flush()` return

### `clay.save()`

`clay.save()` snapshots the page, sends it to the host if dirty, and returns `Promise<{ ok, msg, msgType, code, etag }>`. The promise **never rejects** (note that if a registered snapshot or transform hook throws during capture, the resolved object has `msgType: "error"` and no `ok` property).

Always branch on `result.msgType` rather than `result.ok`, because `ok: false` also covers skipped and queued saves:

| `ok` | `msgType` | Meaning |
|---|---|---|
| `true` | `"success"` (or `"warning"`) | The host accepted the save and returned the new `etag`. |
| `false` | `"skipped"` | Nothing was sent: `"Not in edit mode"`, `"No changes to save"`, or `"Save already in progress"` (when a save is already in flight, the new bytes are queued and sent automatically after it finishes). |
| `false` | `"conflict"` | HTTP `412` refusal (`code: "conflict"`). Also includes `changedBy`, `afterTimeout`, and `conflictEtag`. |
| `false` | `"error"` | The host returned an error status, a header was invalid (`code: "invalid-header"`), or the network request failed. |
| `false` | `"unknown"` | The 12-second save timeout expired and the host could not confirm whether the write landed. |

### `clay.save.flush(options)`

Use `clay.save.flush({ keepalive = false, timeoutMs = 15000 })` when you need a promise that resolves only once the host holds the exact bytes currently on the page (for example, before running an external script or hiding a manual-save tab).

`flush` waits for the initial load-settle window (`clay:baseline-settled`), waits for any in-flight save to finish, and retries (up to 4 times) if the page is edited while the save is in flight. `keepalive: true` passes `keepalive` to `fetch` for page-unload saves and is honored only when the UTF-8 HTML body is under 60 KB.

- **Resolves with `{ state, etag }`**:
  - `state: "view"`: The page is in view mode and cannot save.
  - `state: "clean"`: The host already has the current bytes; nothing was sent and `etag` is the last seen stamp.
  - `state: "saved"`: This call wrote the current bytes to the host and `etag` is the new stamp.
- **Rejects with an `Error` carrying `err.state`**:
  - `err.state === "failed"`: The save failed or `timeoutMs` (default `15000` ms) elapsed.
  - `err.state === "conflict"`: The page is in an unresolved `412` conflict. `flush` never overwrites a conflict on its own; overwriting always requires `clay.save.overwrite()`.
  - `err.state === "blocked"`: The DOM bytes are saved, but a check registered through `clay.registerUnsavedState` reports pending work outside the DOM.

## `clay.save.replace(html, { etag })`

One conditional write of a whole document this page did not build: `html` is the entire file as a string, and `etag` names the version it replaces. It is the write an upgrade makes, so it lives outside the save core (`src/core/save-replace.js`) and only the [`upgrade`](https://clayjs.com/docs/plugins/upgrade/) plugin loads it, which is also why it is present in view mode: on a template copy whose tab can edit, the upgrade loads there and `clay.save` holds this member alone.

- **The bytes go out as given.** No capture, no snapshot, no `beforeSave` and no document transform runs: the string handed in is already the whole document, and this page is not touched. It also does not retry.
- **The save lane's own request.** Endpoint, headers and the save-token route are the same as `clay.save()`'s, and `etag` becomes `If-Match`. With no `etag` the tab's held stamp is used, and a tab that has never saved seeds one from `/_/meta` first.
- **A host that never announced `conditional` gets no request at all.** Without the capability `If-Match` means nothing, so the answer is `unsupported` without a write.
- **One answer, and it never rejects:**

| Resolves | Meaning |
|---|---|
| `{ ok: true, etag }` | The host took the bytes. `etag` is the stamp it answered with, or `null` when it named none, and this tab holds that stamp from here on. |
| `{ ok: false, code: "conflict" }` | HTTP `412`: the file was no longer the version named. Nothing was written. |
| `{ ok: false, code: "forbidden" }` | HTTP `401` or `403`. |
| `{ ok: false, code: "unsupported" }` | The host does not announce `conditional`, so nothing was sent. |
| `{ ok: false, code: "failed" }` | Anything else: another status, a dropped connection, the 30-second timeout, or an `html` argument that is not a non-empty string. |

## Save receipts and timeout recovery

Every time ClayJS sends a save, it generates a fresh, opaque 128-bit identifier and sends it in the `Save-ID` request header. ClayJS sends `Save-ID` to every host and remembers the last 8 `Save-ID` values sent by the current tab.

A **receipt** is the host's record of the `Save-ID` that wrote the current version of the document.

### Why receipts matter when a save times out

Suppose your tab holds `ETag` `"v1"` and sends a save with `If-Match: "v1"` and `Save-ID: "attempt-1"`. The host writes the file and updates its `ETag` to `"v2"`, but the connection drops or the 12-second timeout expires before the `200` response reaches your browser.

Without a receipt, your tab still believes the file is at `"v1"`. If your tab retries with `If-Match: "v1"`, the host sees `"v2"` on disk and rejects your retry with a false `412` conflict against your own save. Conversely, if your tab blindly adopted `"v2"`, it could overwrite a collaborator's save that landed during the timeout.

With receipts, ClayJS resolves the ambiguity safely:

1. **Check the receipt on `/_/meta`**: When a save times out, ClayJS queries `/_/meta` (and if the host announces `save-history`, sends `Save-ID` and `Save-Request-Hash` to inspect `document.saveOutcome`).
2. **Confirmed landed**: If `document.saveId` matches the timed-out attempt's `Save-ID` (or `document.saveOutcome` reports `"applied"` for that `Save-ID` and hash), the host has proved that the bytes on disk came from this exact save. ClayJS adopts the new `etag` and resolves the save as `{ ok: true, msg: "Saved", msgType: "success", etag }`.
3. **Safe retry under the original `If-Match`**: If the host reports no receipt or `"unknown"`, ClayJS re-sends the save with the **original** `If-Match` and the **same** `Save-ID`.
4. **Late-duplicate `412` recovery**: If a retry or subsequent save receives a `412` whose receipt names any of the last 8 `Save-ID` values sent by **this tab**, ClayJS knows the version on disk is its own earlier save. It adopts the refusal's `etag`, mints a new `Save-ID`, and re-sends the current document conditionally without showing a conflict notice. If the `412` names a `Save-ID` from another tab or user, ClayJS raises a normal conflict (`afterTimeout: true`).
