# Handling rejected writes

An optimistic write the API server refuses snaps back on its own — the engine rebases it away, so you write no rollback code. Your only job is to *surface* it: `onRejected(envelope, reason)` fires with the mutation that failed and why, and you show a toast, reopen the form, or offer a retry.

An [optimistic write](/docs/client) applies to your local view instantly, before the server has seen it.
Usually the server agrees and the write commits. Sometimes it refuses — a validation fails, the caller
isn't allowed, a precondition no longer holds. When that happens the engine **rebases the write away**:
it rewinds the pending mutation and the view snaps back to authoritative state. You never write rollback
code. What you *do* write is the part the engine can't: telling the user.

## How a write gets rejected

A [mutator](/docs/mutators) is rejected when its authoritative run can't complete:

- **A failed arg parse** — the wire args don't satisfy the mutator's `.args` schema (a malformed or
  tampered call).
- **A `throw` in the body** — a precondition guard (`if (!allowed) throw …`) rolls the server transaction
  back.
- **A blanket `authorizeMutation` gate** — no valid session, so the mutation never runs.
- **A server-only guard** — an ownership or entitlement check the client couldn't make.

All of these land the same way on the client: the optimistic prediction is discarded on the next rebase,
and `onRejected` fires.

## Surface it with `onRejected`

Pass `onRejected` to `createRindleClient`. It receives the mutation `envelope` (`{ clientID, mid, name,
args }`) and the `reason` string from the server:

```ts
const app = await createRindleClient({
  schema, mutators,
  onRejected: (envelope, reason) => {
    // the view has ALREADY snapped back — this is purely how you tell the user
    showToast(`Couldn't ${envelope.name}: ${reason}`);
  },
});
```

Because the rollback is automatic, `onRejected` is where UX lives, not correctness. Common moves: a toast
with the `reason`; reopening the form the user submitted (their input is in `envelope.args`); or offering
a one-tap retry.

## Reject vs. accepted-but-no-op

Not every "nothing happened" is a rejection. A mutator can legitimately **do nothing** — a body that
no-ops for a non-owner, identically on both tiers — and that is *accepted*: it's not an error, `onRejected`
doesn't fire, and there's nothing to snap back because the prediction was already a no-op. Reserve a
`throw` (a hard reject) for a write that should visibly *fail*; use a silent no-op for one that should
simply have no effect. (The full model — hard reject vs. accepted-but-no-op — is in
[the API server](/docs/api-server#driving-the-shared-mutators).)

## In the wild — Strut

[Strut](https://github.com/tantaman/strut)'s authority refuses writes with a `throw`. Making a deck
private, for instance, checks ownership *and* a Pro entitlement — either failing throws a `403`, which
rolls back the server transaction and snaps the optimistic change back:

```ts
// server/rindle-api.ts
const owns = await tx.query(q.deck.where.id(a.id).where(deck.owner_id(user)).one())
if (owns == null) throw new RindleApiError('forbidden', 'not permitted to edit this deck', 403)
if (a.visibility === 'private') {
  const ent = await getEntitlements(user)
  if (!ent.canKeepPrivate)
    throw new RindleApiError('forbidden', 'Private decks are a Pro feature. Upgrade to keep this deck private.', 403)
}
```

*[`server/rindle-api.ts` L226–255](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/server/rindle-api.ts#L226-L255) · Strut*

The browser client just registers a handler for the snap-back — here it logs; a production app would toast:

```ts
// src/rindle/client.ts
onRejected: (envelope, reason) =>
  console.error(`[rindle] ${envelope.name} rejected:`, reason),
```

*[`src/rindle/client.ts` L101–102](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/rindle/client.ts#L101-L102) · Strut*

The [`create-rindle`](/docs/create-rindle) starter ships a runnable version of the full loop: a `postMessage`
mutator whose server run rejects a body containing "spam," an `onRejected` handler wired to a `<Toaster>`,
and a composer where you can watch the optimistic message appear and then snap back with a toast — the
whole rejection path end to end.

## See also

- [Isomorphic mutators](/docs/mutators) — `throw` to hard-reject; normalize so prediction == commit.
- [The API server](/docs/api-server#driving-the-shared-mutators) — the two rejection shapes and
  server-only guards.
- [Authorizing reads & writes](/docs/authorization) — the guards that produce most rejections.
- [The browser client](/docs/client) — how the rebase discards a rejected write.

---

[View this page on Rindle](https://rindle.sh/docs/rejected-writes)
