# Changesets — aggregate writes

A **changeset** writes a parent row and its owned relations — junction
memberships, normalized phones/emails, line items — in **one request**,
replacing the hand-rolled multi-table action. You declare the one fact the
compiler was missing — which relations are **compositions** (owned by this
root) vs **references** (independent entities) — and the compiler generates
the whole write path.

Declaring `owns` on a v1-pinned project is a compile error.

## Declaring the boundary — `owns`

{/* doc-compile: skip — the fence is the `owns` boundary only: columns/firewall/guards/masking/read are elided, and it names three sibling tables (`tasks`, `people`, `taskChecklistItems`) it deliberately does not repeat. */}
```ts title="quickback/features/projects/projects.ts"
export default feature("projects", {
  // ...columns, firewall, guards, masking, read...
  create: { access: { roles: ["admin", "owner"] } },
  update: { access: { roles: ["admin", "owner"] } },

  owns: {
    tasks: {
      table: "tasks",            // camelCase table name — a defineTable in this project
      fk: "projectId",           // FK on the CHILD pointing at this root's PK
      refs: {                    // client-supplied pointers at INDEPENDENT entities
        assigneeId: "people",
        reviewerId: "people",    // nullable refs may be omitted or null
      },
      inherit: ["organizationId"], // parent columns copied onto every inserted child
      owns: {                    // grandchildren — structural nesting, depth-capped
        checklist: { table: "taskChecklistItems", fk: "taskId", inherit: ["organizationId"] },
      },
    },
  },
});
```

| Key | Meaning |
|---|---|
| `table` | The owned child — **must** be a `defineTable`/`feature()` in the project. String literal, never an identifier. Auth tables can never be owned. |
| `fk` | The spine: the child column pointing at this root. **Compiler-stamped** on inserts; a client-supplied value is rejected. |
| `refs` | Columns pointing at independent entities. Client-supplied, **existence-checked inside the caller's tenant** (the referenced table's firewall is ANDed) — on inserts **and** patches. The referenced row is never written through. |
| `inherit` | Tenant/denorm columns copied verbatim from the (firewall-verified) parent row onto every inserted child. Compiler-stamped; client values rejected. |
| `owns` | Nested owned relations. Max 3 levels of nesting (the aggregate spans at most 4 tables). |

A child's [`q.stamp`](/define/schema#qstamp--proven-principal-identity-columns) identity columns are a **fourth, disjoint** source, sitting alongside `fk`/`inherit`/`refs`. On a changeset **insert** the engine stamps them from the **caller's proven scope claim** (`ctx.scope.<kind>.<claim>`) — *not* from the parent row — but only when the caller is a scope-only principal; an org-admitted caller keeps their client value (org-firewall-bounded). The contrast is exact: `inherit` copies **tenant** columns from the firewall-verified *parent*, `q.stamp` copies **identity** from the proven *token*. A required claim absent on a scope principal fails the op with **403** — emitted *before* the ref-existence checks, so a claim-less caller cannot probe which rows exist. A `refs` key may not alias a `q.stamp` column (a column is compiler-stamped **or** a client ref, never both). On a changeset **patch** op the stamp column is **immutable** — a client value for it is rejected (400) like any systemManaged column, matching the plain update path. Because the stamp lives inside the changeset handler, a **keyed** (`Idempotency-Key`) replay neither re-writes nor re-stamps; a keyless retry re-writes and re-stamps.

**Owning a relation is not a write grant.** Every changeset op is admitted by
the **child table's own crud declaration**: `insert` requires `create:` on the
child, `patch` requires `update:`, `delete` requires `delete:` (soft mode only
in v1) — each with its own `access`. There is no per-relation access override:
access is declared exactly once, on the child.

### `routes: false` — changeset admission without a raw route

Widening a child's access for changeset use must not silently open its raw
CRUD surface. The `create`, `update`, and `delete` op configs accept
`routes: false` (`upsert`/`put` does not): the access stays declared (it is
the changeset admission gate) while the raw HTTP route — and its
auto-promoted batch variant — is never mounted, documented, or counted in
the operations manifest.

```ts title="quickback/features/projects/tasks.ts"
update: { access: { roles: ["member", "admin", "owner"] }, routes: false },
```

## The wire contract

One blessed path — **RFC 5789 media-type dispatch** on the routes you already
have. No subpath.

Paths below use the default `/api/v1` prefix. Changesets are a
**semantics** feature and v2 is the default, so they are available without
moving your URLs — set [`contract.routes`](/platform/api-contract#one-axis-routes)
to `"v2"` only if you also want the `/api/v2` prefix.

- `PATCH /api/v1/<resource>/:id` with
  `Content-Type: application/vnd.quickback.changeset+json` — apply a changeset
  to an existing root. Plain `application/json` keeps the flat-column PATCH.
- `POST /api/v1/<resource>` with the same media type — create the root and its
  children in one changeset.

```jsonc
// PATCH /api/v1/projects/prj_1   (Content-Type: application/vnd.quickback.changeset+json)
{
  "patch": { "name": "Renamed" },              // sparse ROOT patch (optional)
  "tasks": {
    "insert": [
      {
        "lid": "t1",                            // client correlation id
        "title": "Ship it",
        "assigneeId": "per_9",                  // ref — tenant-checked
        "checklist": { "insert": [{ "label": "QA pass" }] }  // grandchild, FKs onto t1's minted id
      }
    ],
    "patch":  [{ "id": "tsk_2", "state": "done" }],
    "delete": ["tsk_3"]
  }
}
```

```jsonc
// Response
{
  "success": true,
  "root": { "id": "prj_1", "name": "Renamed", /* masked through the root's masking */ },
  "created": { "t1": "tsk_9a…", "/tasks/insert/0/checklist/insert/0": "tci_44…" },
  "meta": { "ops": 4, "transactional": false }
}
```

- Inserts/patches apply parent-first (document order); deletes apply
  children-first.
- `lid` correlates a minted id in `created`; entries without a `lid` key on
  their JSON Pointer. A nested insert's FK comes from its parent op's minted
  id — an unresolvable parent lid is a pointer-carrying **400**, never a
  silent re-parent onto the root.
- `Prefer: return=minimal` omits `root` from the response. v1 representation
  is **root-row-only** (re-read through the root's masking) — nested aggregate
  reads are the phase-2 `?include=` reader; use [`defineView`
  includes](/define/read) today.

### Headers

| Header | Behavior |
|---|---|
| `If-Match` | Optimistic concurrency against the **root revision** — the root's `updatedAt` (or the injected `modifiedAt` audit column). Accepted value forms: `W/"<revision>"`, `"<revision>"`, or the bare revision value, where `<revision>` is the raw value of the revision column as returned in the root row. **Do not echo `GET /:id`'s `ETag` header into `If-Match`** — that ETag is the contract layer's body-hash ETag, not this revision, and will always **412**. Compared inside the transaction; mismatch → **412**. Every accepted changeset touches the revision. A root with no revision column answers `If-Match` with **428 PRECONDITION_UNSUPPORTED** — never silently ignored; the POST variant always answers 428 (no pre-existing revision to match). |

`If-Match` is honored with the **identical comparator on the plain
`application/json` write lanes** too: single-record `PATCH`, `PUT`, and
`DELETE` compare the supplied revision against the fetched row (mismatch →
**412**), and `PATCH`/`PUT` additionally pin the revision into the write's
`WHERE` (compare-and-swap), so a concurrent writer between the fetch and the
write surfaces as **412** on every provider — including the transaction-less
D1 / Neon-HTTP lanes. A `PUT` with `If-Match` against a missing row is a
**412** (RFC 9110: no current representation), never a silent create. Tables
with no revision column, and every **batch** endpoint (`If-Match` is a
single-resource precondition), answer **428 PRECONDITION_UNSUPPORTED**. A
supplied precondition is **never silently ignored** anywhere.
| `Idempotency-Key` | The standard write-retry key. Claims are principal-scoped — a replayed key from a different principal is a 422, never a cached-body leak. |
| `Prefer` | `return=representation` (default) or `return=minimal`. |

### Errors

RFC 9457 `application/problem+json`. The failing op is identified by a JSON
Pointer in the `pointer` extension (`/tasks/insert/0/assigneeId`). On
**non-transactional providers** the error also carries an `ops` extension
listing the outcomes of ops applied before the failure — the honest committed
prefix. On **transactional providers** the rollback erased every applied op,
so the error carries **no `ops` extension** (advertising rolled-back ids would
point clients at rows that no longer exist). **Outcome entries carry only
`{ pointer, status, id? }` — never child field values** (v1 deliberately
withholds nested child representation, so errors cannot become an
unmasked-child side channel).

## The security model — "owning a relation is not a backdoor"

The **root op** runs the root table's full single-record gate stack, in order:
auth → cross-tenant guard → the root's own `update` (PATCH) / `create` (POST)
access — **always, including child-only bodies; there is no child-only bypass
lane** → firewall fetch (reveal → 403 / hide → 404, byte-consistent with the
plain handler) → `requireSelf` → root body schema + ownership guard + guards.
The POST variant runs the root's full create pipeline (org validation, FK
existence checks, defaults/computed, ownership stamping).

Every **child op** then pays:

1. **Admission** — declared relation, declared op kind (unknown keys are 400s).
2. **The child's own access** — the pre-record role gate runs **before any row
   fetch** (no 404-vs-403 ID probing under the aggregate); `record:`
   predicates are evaluated against the fetched row on patch/delete and
   against the submitted input on insert.
3. **systemManaged rejection** — client data carrying `fk`, any `inherit`
   column, or an ownership column is **rejected** with the op's pointer, never
   stripped or stamped-over. (The child's *plain-CRUD* guards are unaffected —
   a junction may keep its FK createable for its raw route; the same field in
   a changeset op is rejected because the changeset supplies it structurally.)
4. **The child's column body schema** — q-authored children pay the same Zod
   types/enums/lengths backstop their plain POST/PATCH enforces (strict,
   guards-aware, with the compiler-stamped columns omitted). Drizzle-authored
   children have no Zod on their plain routes either, so none applies here —
   parity, not an omission.
5. **The child's guards** — `createable`/`updatable` validation.
6. **The child's firewall** — every patch/delete WHERE is
   `spine FK = parent id AND child firewall AND id`, same-feature or
   cross-feature alike. Inserts derive tenant columns only from the
   firewall-verified parent row.
7. **Tenant-scoped `refs` checks** — each non-null ref must exist inside the
   referenced table's firewall, on **inserts and patches alike** (re-pointing
   a ref pays the same check; only columns present in the op's data are
   checked).
8. **Server-minted child ids** — a client-supplied child primary key on an
   insert is rejected (like the plain POST's strict body schema), unless the
   project is configured for client-provided ids (`generateId: false`).

Child `delete` ops stamp the **fixed `deletedAt`** audit column — exactly what
the plain DELETE route stamps (the audit wrapper then auto-stamps
`deletedBy`). An `isNull` firewall arm on any other column (e.g.
`archivedAt`) is a read-scoping predicate, never the soft-delete stamp.

### Handler triggers fire — the changeset is a first-party write

Every root and owned-child op writes through the **same audit-wrapped `db`
chokepoint** the plain CRUD routes and actions use — not a raw Drizzle handle.
So a child's declared [`handler:` triggers](/define/triggers#handler-triggers-handler)
fire on the changeset exactly as they do on the child's own route: `beforeInsert`
/ `beforeUpdate` / `beforeDelete` run at the write (a throw becomes the same
`TRIGGER_REJECTED` and — on a transactional provider — rolls the whole aggregate
back), and `afterInsert` / `afterUpdate` run once per affected row. Audit
stamping (`createdBy` / `modifiedBy`) rides the same chokepoint. The only gap is
`afterDelete`, which the soft-delete stamp can't see — identical to the plain
DELETE route's own limitation. (`sql:` triggers fire on every path regardless,
as always.) This is the parity the trigger docs promise: a generated
changeset is a compiler write surface, not one of the
queue/cron/raw-SQL bypasses that skip handler hooks.

Compile-time, fail-closed (everything throws, nothing normalizes silently):

- **Per-arm tenant coverage**: every column arm of the child's *resolved*
  firewall — including **every branch of an `any:` OR** — must be satisfiable
  by `fk`/`inherit`. Covering one arm of an OR is not coverage. This runs over
  the post-expansion firewall, so lanes spliced from
  [`authz.rules`](/define/authz-roles#centrally-defined-access-rules)
  gates and `{ tenant }` anchors are checked too.
- Children firewalled through a **subquery arm** (`via:`, `{ permission }`,
  arrows) cannot be owned in v1 — even when the arm arrives through a gate
  splice.
- `refs` keys must be **disjoint** from `{fk} ∪ inherit ∪ ownership columns` —
  a column is either compiler-stamped or a client ref, never both (blocks
  ownership-column injection through a ref).
- **Relationship / FGA access arms are compile errors** on the changeset lane
  — on the root's `update`/`create` access and on every admitted child op's
  access. Those arms are decided by inline route code the changeset engine
  does not emit in v1; rather than silently widen (or silently 403) the gate,
  the compiler refuses. Use roles/record predicates, or keep the write in an
  authored action. (Function-form access is refused for the same reason.)
- **Envelope-encrypted roots cannot ride the changeset in v1** — the response
  path does not run the decrypt-then-mask prepare helper, so the returned root
  would carry sealed ciphertext. The compiler refuses rather than degrade
  silently. (Sealed-mode columns are unaffected — plain routes return those
  as-is too.)
- **Relation names `insert`, `patch`, `delete`, and `lid` are rejected** —
  the wire parser reserves those payload keys, so such a relation would be
  silently unreachable on the wire.
- One aggregate root per table, acyclic graph, bounded depth, declared child
  ops only, soft-delete children only.

## `afterCommit` — emit a realtime signal after the changeset commits

A changeset that must notify live views declares **`afterCommit`** on the
aggregate root — a declarative broadcast, **not** a hand-rolled
`try { realtime.* } catch {}` tail. It fires **one aggregate-level CloudEvent**
strictly **after** the transaction commits, riding the existing
[named-invalidation / Broadcaster egress](/platform/realtime/named-invalidations)
(so it inherits the CloudEvents envelope + AsyncAPI description for free).

{/* doc-compile: skip — project-config prerequisite this fence cannot carry: the `broadcast` event `attendRealtime.mobileBundleChanged.travelUpdated` plus its `thisEvent` delivery profile must be declared in a realtime registry under `services/realtime/`. Columns/firewall/read/crud are elided to `// ...` and the owned child table is not repeated. */}
```ts title="quickback/features/travel/lodging-reservations.ts"
export default defineTable(lodgingReservations, {
  // ...firewall / read / crud ...
  owns: { occupants: { table: "lodgingReservationGuests", fk: "reservationId",
                       inherit: ["eventId", "organizationId"] } },
  afterCommit: {
    broadcast: {
      travelChanged: {
        event: "attendRealtime.mobileBundleChanged.travelUpdated", // registry.event.variant
        delivery: "thisEvent",          // MUST be in the event's `deliveries` allowlist
        payload: {
          eventId: "root.eventId",       // a committed root column
          reservationId: "rootId",       // the root pk
          occupantIds: "affectedIds",    // ids of child rows touched this changeset
        },
      },
    },
  },
});
```

- **One event per aggregate**, not one per touched child. The payload carries
  an **affected-ids / changed-paths** roll-up derived from the applied ops
  (`affectedIds` = child ids, `changedPaths` = JSON pointers) — **ids and
  pointers only, never child field values**.
- **`delivery` is required and fail-closed.** The audience lives on a named
  [delivery profile](/platform/realtime/named-invalidations); it must appear in the
  referenced event's `deliveries` allowlist. There is **no default selection**,
  so an event permitting a narrow AND a wide room can never silently fan a
  changeset frame to the wider one.
- **Payload sources:** `root.<col>` (a committed root column), `rootId`,
  `affectedIds`, `changedPaths`. Every payload key the variant declares must be
  mapped; unknown sources / undeclared keys are compile errors.
- **Best-effort:** a broadcast failure never rolls back the committed changeset
  (`console.warn`, dispatched on `waitUntil`).
- **Root-row frame:** `afterCommit: { realtime: true }` additionally emits the
  root row (postgres_changes-style) on the table's own `realtime` room. The row
  rides unmasked **with the table's `MASKING_CONFIG`** so the Broadcaster masks
  per-subscriber-role at fanout — the same discipline plain CRUD realtime uses.

### Fail-closed disclosure gates

Because a broadcast payload is **not** masked at fanout (only the root-row frame
is), afterCommit fails the build rather than leak:

- A **masked `root.<col>`** may ride a broadcast only to a role-only delivery
  whose roles ⊆ the column's unmask roles (same app-role namespace). A
  **scope-keyed** delivery or an **empty unmask set** (`show: { or: 'owner' }`)
  **always** throws.
- **`affectedIds` / `rootId` / `changedPaths`** carry firewalled row IDs
  (existence/enumeration). They may ride a scope-keyed delivery (bounded to one
  scope-instance room); a role-only / org-wide delivery throws unless you
  acknowledge with `disclosesChildIds: true`.

### `q.scope` verified vs `q.stamp` proven — the "stamp then emit" rule

When a changeset [stamps a proven identity](/define/schema#qstamp--proven-principal-identity-columns)
and then emits it, the two features compose on one write path:

| | `q.scope('<kind>')` | `q.stamp({ fromScope, claim })` |
|---|---|---|
| Purpose | tenant isolation column | proven-principal **identity** column |
| Firewall arm? | **yes** — it is the tenant WHERE | **never** |
| Verified how? | firewall re-verifies on read/patch/delete | token provenance (un-spoofable) |
| Phrase | "**stamp AND verify**" | "stamp AND **prove**" |
| Safe to broadcast? | to the matching scope-keyed room | **only** to an audience ⊆ the stamping scope kind |

A `q.stamp` identity column (e.g. `guestId`) sourced via `root.<col>` is
**audience-gated like `affectedIds`** (05×06 integration): the delivery must be
scope-keyed to the **same scope kind** that stamped it, else the build fails —
unless you acknowledge with **`disclosesStampedIdentity: true`**. A stamp is
un-spoofable, not un-*sensitive*: provenance is not audience clearance.

### Idempotency — at-least-once

afterCommit is **at-least-once**, exactly as idempotent as the write it rides.
A retry carrying the **same `Idempotency-Key`** neither re-writes nor re-emits
(the middleware returns the cached response before the handler runs). A
**keyless** retry re-runs the whole handler → re-writes **and** re-emits. Callers
that need single-delivery must send an `Idempotency-Key`.

### No-tx: "row written ⇏ event fired"

On a no-tx provider (D1 / neon-http), a mid-changeset failure **throws** before
the success path, so **no frames emit** for the partially-applied prefix —
`meta.transactional: false` is the only guarantee signal. A **fully-successful**
no-tx changeset **does** emit, still with `transactional: false`. Never assume
"row written ⇒ event fired"; `meta.transactional` is the contract.

### `reindex` — re-embed the committed root

```ts
afterCommit: {
  reindex: true,   // requires the root's `embeddings` block
}
```

`reindex: true` re-enqueues the **committed root row** for embedding
regeneration on the same at-least-once `EMBEDDINGS_QUEUE` lane the plain and
batch routes use — enqueued only **after** the changeset commits, so a
rolled-back changeset produces no orphan embedding jobs. Fail-closed rules:

- The root **must** declare an `embeddings` block — `reindex: true` without
  one is a compile error (there is nothing to re-embed).
- `embeddings.onInsert` / `onUpdate: false` gate create/update changesets
  exactly like the plain routes; a field-watch `onUpdate: [...]` array is
  treated as **always** (changeset ops are aggregate-level, not a field diff).
- Reindex covers the **root row only**. Owned children with `embeddings` still
  warn at compile time — keep child writes on the plain routes, or enqueue
  from an authored action.

### `notify` — device push after commit

```ts
afterCommit: {
  notify: {
    bookingChanged: {
      delivery: "thisEvent",              // defineRealtimeDelivery — the audience
      title: "Booking updated",
      body: "Reservation {reservationId} changed",
      tag: "res-{reservationId}",         // collapse key (templated)
      payload: { eventId: "root.eventId", reservationId: "rootId" },
    },
  },
}
```

Requires `push: { provider: 'web-push' }` — declaring `notify` without the
provider is a compile error. The audience is a **delivery profile**, resolved
with the same fail-closed lookup and the **same disclosure gates** as
`broadcast` (masked columns, `q.stamp` proven identity, child-id enumeration
— the gates are shared code, not a parallel set). `{key}` template
placeholders must be mapped `payload` keys; an unresolvable placeholder is a
compile error, and a null placeholder value at runtime skips the push
(fail-closed — nobody gets a blank notification).

Fan-out: a scope-keyed delivery targets subscriptions tagged
`scope:<room key>`; an `organizationId` + `userIdFrom` delivery targets that
user's devices; profile roles AND-compose as a `role:` tag filter. Delivery
rides the `PUSH_FANOUT_QUEUE` after commit — at-least-once, best-effort,
never rolling back the changeset. A delivery that resolves to no audience
pushes to nobody, mirroring the ws broadcast lane exactly.

> **Deferred out of v1 (still):** lifting `afterCommit` onto plain
> tables/actions. The compiler still **warns** when an `owns` root/child
> declares `realtime` **without** `afterCommit`, or when an owned **child**
> declares `embeddings` (root embeddings are covered by `reindex`), so a
> silent asymmetry can't be enabled unknowingly.

## Atomicity — honest, per provider

The whole changeset runs inside `db.transaction()` when the provider supports
it; `meta.transactional` reports the actual guarantee (same contract as
[batch operations](/api/batch-operations)):

| Provider | transactional | Behavior |
|---|---|---|
| postgres / supabase / neon (websocket / hyperdrive) / libsql / better-sqlite3 / bun-sqlite | `true` | One transaction; any op failure rolls back the root op and every child op |
| cloudflare-d1, neon over http (the Cloudflare default) | `false` | Deterministic parent-first **fail-fast**: the first failure stops the changeset; ops already applied stay committed (a consistent prefix — each was independently authorized) |

## Calling it from authored code

The engine is exported per root as `apply<Singular>Changeset` — the seam
authored actions delegate through when they still need residue logic
(preconditions, server-side transforms, notifications) around the aggregate
write:

{/* doc-compile: skip — the fence imports `../projects.changeset`, a module the compiler only generates for a root that declares `owns`, which this fence does not carry, so the import cannot resolve. `description` / `input` / `access` are elided to `// ...` on purpose — the point is the delegation seam. */}
```ts title="quickback/features/projects/actions/importBoard.ts"
// Action files are compiled into the feature directory, next to the
// generated module — the relative import resolves at runtime.
import { defineAction } from "../.quickback/define-action";
import { applyProjectChangeset } from "../projects.changeset";

export default defineAction({
  // ...description, input, access...
  execute: async ({ db, ctx, input }) => {
    // precondition / transform residue …
    const result = await applyProjectChangeset(db, ctx, input.projectId, input.changes);
    // notify / respond …
    return result;
  },
});
```

**There is no trusted mode.** The auth assertion, cross-tenant guard, and the
root's own pre-record access gate are the helper's first statements — an
authored action whose action-level access is looser than the root's crud
access still pays the root's declared authorization when it delegates. The
HTTP handler is a thin wrapper over the same helper (parse + Problem-Details
rendering only).

## Standards mapping

| Changeset concept | Grounded in |
|---|---|
| Media-type dispatch on PATCH | RFC 5789 (the media type selects patch semantics) |
| `lid` correlation + op list | JMAP `/set` creation ids, JSON:API `lid` |
| Root-revision `If-Match` | JMAP state tokens, RFC 9110 preconditions |
| Pointer-carrying errors | RFC 9457 Problem Details + RFC 6901 JSON Pointer |
| Nested relation ops | SCIM PATCH (typed sub-resources) |

## What stays phase 2

Full-document `replace` diff sugar (and its `key:` reconciliation field), the
`application/json-patch+json` representation, cross-branch `$lid` references,
nested `Prefer: return=representation`, and find-or-create roots by business
key (`conflictTarget`). `?include=<ownsRelation>` aggregate reads have
**landed** — see [read → Aggregate Reads](/define/read#aggregate-reads).
