# The JSON Engine (Programmable Editor State)

The **JSON engine** is CoreReflex's programmable query + command layer over a
composition: JSON in, structured JSON out, against the exact document the editor
persists (`compositions.manifest`). It is the platform's flagship programmable
surface — the same core powers the in-editor console, the public HTTP API, the
SDK, the MCP server, and the suite-wide agent's tool registry.

> **Status — shipped, on four surfaces.** The engine started life as an additive,
> unwired core; that era is over. Today it is live in the editor (the JSON
> console, flag-gated by `FEATURE_JSON_CONSOLE`, currently on), on the server
> (`POST /api/json`, `GET /api/json/schema`, and the per-composition
> `POST /api/compositions/:id/engine` + `GET /api/compositions/:id/schema`),
> in the SDK (`sdk/index.js`), in the MCP server (`mcp/server.js`), and as the
> live source of the agent tool registry (`src/agent-tools.js`). The command
> registry is ~5,100 lines (`commands.ts`) covering 47 whitelisted mutations.

## On this page

- [Overview](#overview)
- [Architecture: one contract, two engines, four surfaces](#architecture-one-contract-two-engines-four-surfaces)
- [The contract: `evaluate(state, request)`](#the-contract-evaluatestate-request--state-response)
- [Request kinds and flags](#request-kinds-and-flags)
- [Query language](#query-language)
- [Command inventory](#command-inventory)
- [Keyframe animation commands](#keyframe-animation-commands)
- [Discovery: `describeSchema()` and the command catalog](#discovery-describeschema-and-the-command-catalog)
- [Error model](#error-model)
- [Shipped surface 1: the in-editor JSON console](#shipped-surface-1-the-in-editor-json-console)
- [Shipped surface 2: the server HTTP API](#shipped-surface-2-the-server-http-api)
- [Shipped surface 3: the SDK and the MCP server](#shipped-surface-3-the-sdk-and-the-mcp-server)
- [Shipped surface 4: the agent tool registry](#shipped-surface-4-the-agent-tool-registry)
- [Trust story: parity and snapshot tests](#trust-story-parity-and-snapshot-tests)
- [Examples](#examples)
- [Module map](#module-map)
- [Testing](#testing)
- [Gotchas](#gotchas)
- [Related docs](#related-docs)

---

## Overview

The editor's composition state (`UndoableState`) is plain JSON — it persists
verbatim as `compositions.manifest`. The engine treats that document as a
queryable + commandable database: hand it a composition and a JSON request, get
back a structured result (and, for mutations, the new composition). Nothing else
in the platform has a second mutation path — the console, the HTTP API, the MCP
tools, and the agent all speak this one contract.

The engine **never mutates its input**. Every command builds the next state with
spreads; the caller decides how to persist or dispatch it. A command that changes
nothing returns the **same state reference**, so callers (the editor undo stack,
the server persistence layer) detect no-ops by reference equality and skip them.

## Architecture: one contract, two engines, four surfaces

```
                    editor/src/editor/json-api/*.ts   ← canonical authoring copy (TypeScript)
                            │ verbatim port, kept in lockstep
                            ▼
                    src/json-engine.js                ← server mirror (Node 20 can't strip types)
                            │
        ┌───────────────────┼──────────────────────────┐
        ▼                   ▼                          ▼
  in-editor console   src/json-api.js            src/agent-tools.js
  (editor/src/editor/ (load → evaluate →         (engine commands pulled
   console/           persist, row-locked)        LIVE from describeSchema)
   json-console.tsx)        │
                            ▼
                      src/api.js routes:
                      POST /api/json · GET /api/json/schema
                      POST /api/compositions/:id/engine · GET …/schema
                            │
                ┌───────────┴───────────┐
                ▼                       ▼
          sdk/index.js            mcp/server.js
          (schema/evaluate/       (corereflex_schema,
           query/command/batch)    corereflex_json tools)
```

Two copies of the engine exist on purpose:

- **`editor/src/editor/json-api/`** (TypeScript) is the canonical authoring
  copy. Every editor import inside it is `import type` (erased at runtime), so
  it is a pure data transform with no React/DOM/Remotion coupling — importable
  by the editor load path without cost, and unit-testable under `node --test`.
- **`src/json-engine.js`** is the server mirror. Production runs Node 20 (no
  type-stripping), so the server cannot import the editor `.ts` files; the
  mirror is a framework-free port kept in lockstep, guarded by
  `test/json-engine.test.js` (ported from the editor's `engine.test.ts`).

The mirror is deliberately a **superset on the read side** (see
[the server HTTP API](#shipped-surface-2-the-server-http-api)): it adds a
`selection` query target, `safe`/`dryRun` preview flags, extra computed item
fields, and audio-session scoping of query rows. **Commands are identical** in
both engines — 47 ops with the same names, params, and semantics.

## The contract: `evaluate(state, request) → {state, response}`

The single entry point on both engines:

```ts
// Editor (TypeScript)
import {evaluate} from 'editor/src/editor/json-api';
const {state, response} = evaluate(composition, request);
```

```js
// Server (JavaScript mirror)
import { evaluate } from './src/json-engine.js';
const { state, response } = evaluate(composition, request);
```

- **`state`** — the composition after the request. Queries leave it untouched
  (same reference); commands return a **new** object (or the same reference for
  a no-op); batch returns the state after the last step.
- **`response`** — a discriminated union: either
  `{ok: true, kind, result, meta?}` or `{ok: false, error}`.

## Request kinds and flags

The `kind` field selects one of four request shapes (`EngineRequest` in
[`types.ts`](../../editor/src/editor/json-api/types.ts)):

| `kind` | Purpose | Mutates? |
|--------|---------|----------|
| `query` | Read a target with filter / select / order / page / aggregate | No |
| `command` | Run one whitelisted mutation | Yes |
| `describe` | Return the machine-readable schema for discovery | No |
| `batch` | Run an ordered list of requests **atomically** | If any step does |

A `batch` runs its `requests` in order against the evolving state. If any step
fails, the whole batch is rolled back: the **pre-batch state** is returned, and
the error names the failing step (`batch_failed`, `path: "requests[N]"`). Success
returns the per-step response array with `meta.count`.

Request-level flags:

| Flag | Engines | Meaning |
|------|---------|---------|
| `strict: true` | both | Reject unknown top-level request keys (`bad_request` with the offending key path). A strict batch propagates strict mode into its nested requests. |
| `safe: true` | **server only** | Preview mode: run the command/batch and return `changed`, `affectedIds`, and `summary`, but return the **original** state (nothing persists). The response carries `safe: true` (in the command result / batch meta). |
| `dryRun: true` | **server only** | Alias for `safe`. |

## Query language

A query targets a row set and applies a small, JSON-serializable predicate tree,
then projection / ordering / paging / aggregation.

### Targets

| `target` | Rows | Engines |
|----------|------|---------|
| `items` | One row per timeline item, with computed fields (below) | both |
| `selection` | The item rows for `state.selectedItems`, in selection order | **server only** |
| `tracks` | One row per track, plus `index` and `itemCount` | both |
| `assets` | One row per asset | both |
| `composition` | A single summary row (`fps`, `width`, `height`, counts, `hasMasterAudioFx`, `hasProjectColorGrade`) | both |

### Computed item fields

Item rows are enriched so filters can target them without dot-path guessing:

| Field | Derivation | Engines |
|-------|------------|---------|
| `kind` | mirror of the item's `type` (`audio`, `video`, `text`, …) | both |
| `endFrame` | `from + durationInFrames` | both |
| `trackIndex` | 0-based index of the owning track (`-1` if orphaned) | both |
| `trackId` | the owning track's id (`null` if orphaned) | both |
| `assetId` | normalized to `null` when absent | server |
| `hasAudioFx` | `Boolean(item.audioFx || item.audioFxRender)` | server |
| `hasColorGrade` | `Boolean(item.colorGrade || state.projectColorGrade)` | server |
| `isMuted` | the owning track's `muted` flag | server |

**Audio-session scoping (server reads only):** the server's `itemRows`,
`trackRows`, and `compositionRows` filter through `videoTracks()` /
`sessionItemIds()` (`src/audio-sessions.js`), so queries only ever see the video
timeline — a `sessionId`-tagged track that slips into a persisted manifest must
not leak rows (belt-and-suspenders for the Audio pillar's at-rest manifest
split). Commands still operate on the full state.

### Operators

Leaf comparisons use one of 12 `CompareOp`s:

`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `exists`,
`regex`, `between`.

- `gt`/`gte`/`lt`/`lte` are numeric-only (non-numbers never match).
- `in`/`nin` test membership in an array `value`.
- `contains` is a case-insensitive substring on strings, or membership on arrays.
- `exists` checks presence (`!= null`); pair with `value: false` to assert *absence*.
- `regex` compiles `value` as a JavaScript `RegExp` and tests string fields; a
  non-string or uncompilable pattern is an `invalid_filter` at `<path>.value`.
- `between` takes `value: [min, max]` (inclusive, numeric); a malformed range is
  an `invalid_filter` at `<path>.value`.

### Filter tree

A `Filter` is either a leaf `{field, op, value?}` or a boolean node — `{and: […]}`,
`{or: […]}`, `{not: …}` — composed arbitrarily deep. **Field names support dot
paths** into nested objects, e.g. `"audioFx.compress"`. A malformed node (a leaf
missing `field`/`op`, or an `and`/`or` whose value isn't an array) throws and the
engine returns `invalid_filter` with the offending `path` (e.g. `where.and[1]`).

### Options

| Option | Meaning |
|--------|---------|
| `select` | field whitelist (`string[]`); omit or `["*"]` for the whole row |
| `orderBy` | `{field, dir: "asc"\|"desc"}` — numeric when both sides are numbers, else string-collated |
| `limit` / `offset` | paging; `meta.total` reports the match count *before* paging |
| `aggregate` | `{count?, groupBy?, distinct?, having?, sum?, avg?, min?, max?}` — returns an aggregate object (or array of grouped rows) instead of rows |

Aggregation details:

- `count: true` emits `count`; `sum`/`avg`/`min`/`max` take **arrays of field
  names** and emit `sum_<field>`, `avg_<field>`, etc. (numeric values only;
  empty sets yield `null` for avg/min/max, `0` for sum).
- `distinct: ["field"]` emits `distinct_<field>` — the unique values in match
  order (`undefined` normalized to `null`).
- `groupBy: ["field", …]` switches the result to an **array** of rows, one per
  group, each carrying the group-key fields plus that group's aggregates.
- `having` is a normal `Filter` applied to each aggregate row (path
  `aggregate.having`) — e.g. keep only groups with `count > 2`.

### Query cost meta

Every query response includes `meta.cost = {rowsScanned, filterDepth, score}` —
a deterministic work estimate (`score = rowsScanned × max(1, filterDepth)`)
intended for rate-limiting and agent budgeting.

## Command inventory

Commands are a **whitelisted, validated** registry of pure mutations in
[`commands.ts`](../../editor/src/editor/json-api/commands.ts) (~5,100 lines),
mirrored verbatim in `src/json-engine.js`. They reproduce the editor's
`state/actions/*` reducers but are framework-free so they run headless. Each
`apply(state, params)` returns `{state, changed, affectedIds, summary, data?}`,
surfaced as `result: {op, changed, affectedIds, summary, data?}`.

**Do not hand-maintain a command list anywhere else — `describeSchema()` is the
source of truth.** Run `{"kind": "describe"}` (console, HTTP, or MCP) to get
every command with full param specs. The families below are a guided tour, not a
substitute.

### Item properties & geometry

| Command | Does |
|---------|------|
| `updateItem` | Patch whitelisted fields of one item. Patchable: `from`, `durationInFrames`, `top`, `left`, `width`, `height`, `opacity` (0..1), `rotation`, `borderRadius`, `decibelAdjustment`, `playbackRate` (>0), `text`, `color`. Each field has a value validator; anything else (`id`, `type`, unknown keys) is `invalid_params` at `params.patch.<key>`. |
| `moveItem` | Shorthand for `updateItem {from}`. |
| `setPosition` | Set item `left` and/or `top`. |
| `updateItemDimensions` | Set `width`/`height`; for text items also sets `resizeOnEdit=false` unless `stopTextEditing: false`. |
| `setItemCropping` | Set crop fractions (`cropLeft/Top/Right/Bottom`) on image/video/gif items (negatives allowed while editing). |
| `changeBorderRadius` | Set fill border radius, or (with `target: "background"`) a text item's background border radius. |
| `setBackground` | Add (`true` → editor defaults), replace (object with `color` + optional `horizontalPadding`/`borderRadius`), or remove (`false`/`null`) a text item background. |
| `editText` | Edit a text item's value, relayouting resize-on-edit text like the editor. |
| `editAndRelayoutText` | Patch text layout fields (`text`, `fontFamily`, `fontSize`, `lineHeight`, `letterSpacing`, `fontStyle`, `align`, `resizeOnEdit`) and recompute dimensions when `resizeOnEdit` is on. |

### Timeline authoring

| Command | Does |
|---------|------|
| `addItem` | Create a `text \| solid \| image \| video \| audio \| gif \| caption` item and place it with the editor's `addItem` space search (`position: {type: "front"\|"back"\|"directly-above", trackIndex?}`). Media types require `assetId`. Places on untagged (video) tracks only — audio-session tracks are edited via the Audio section. |
| `duplicateItem` | Clone one item to a new start frame **on its source track** (same asset, no re-trim; default lands right after the source). |
| `duplicateItems` | Duplicate multiple items directly above their source tracks, preserving timing; each copy stays within the source track's scope (video vs. audio session). `itemIds` accepted as an alias for `ids`. |
| `deleteItems` | Remove items; drops now-empty tracks and orphaned assets. |
| `cutItems` | Descript-style cut: removes items from tracks/state and returns them in `result.data.clipboard` (the engine holds no clipboard state — the caller does). |
| `pasteItems` | Paste a `{items: […]}` clipboard payload at a new minimum start frame with front-of-timeline placement; optional `position: {x, y}` centers items on a canvas point. Pasted items become the selection. |
| `splitItem` | Split one item at an absolute timeline frame, preserving trim offsets and fades (a split at/outside the item's edges is a no-op). |

### Trims & edit points

| Command | Does |
|---------|------|
| `extendLeft` | Trim/extend an item's left edge by `offsetInFrames`, clamping against the neighboring clip and the source media's available head. |
| `extendRight` | Trim/extend the right edge, clamping against the next clip and the source media duration (via the item's asset). |
| `rollEdit` | Shift the edit point between two **adjacent** items on the same track while preserving their combined duration (classic roll; clamped by both items' media limits; `draggingDirection: "left"\|"right"` controls apply order exactly like the editor). |
| `removeRangesFromItem` | **Ripple-remove** source-time ranges (seconds) from one audio/video item: clamps + merges the ranges, rebuilds the kept segments as new items (with fades redistributed to the outer edges), and ripples later same-track items left by the removed duration. This is the transcript-driven "delete these sentences" primitive. |

> Dedicated `rippleTrim` / `slipEdit` / `slideEdit` engine commands do **not**
> exist yet. The editor reducers (`ripple-trim.ts`, `slip-edit.ts`,
> `slide-edit.ts`, `ripple-delete.ts`, `close-gaps.ts`, …) are real, but they are
> not exposed through the engine — the command catalog tracks them under
> `reducersWithoutCommand` so the gap is visible, not silent.

### Transitions

| Command | Does |
|---------|------|
| `addTransition` | Set or clear a transition on the junction around an item (`kind`: `dissolve \| dip-to-black \| wipe-left \| wipe-right \| wipe-up`; `durationInFrames` rounded, min 2, clamped to the shorter adjacent item). Pass `transition: null` to clear. Ripples later same-track items by the overlap delta. Junction selection: explicit `leftId`, or `id` (used as outgoing when it has a successor, otherwise as incoming). |

### Audio

| Command | Does |
|---------|------|
| `separateAudio` | Detach a video item's audio into a linked audio item on a new track (no-op with a clear summary when the video has no separable audio). |
| `setItemAudioFx` | Set a clip's audio-FX rack (record of `0..100` knob values). |
| `clearItemAudioFx` | Remove a clip's `audioFx` **and** any processed derivative (`audioFxRender`). |
| `setMasterAudioFx` | Set the master (whole-mix) FX rack. |

### Assets

| Command | Does |
|---------|------|
| `addAsset` | Register a remote (already-hosted) media asset — `image \| video \| audio \| gif`, `url` required, `durationInSeconds` required for timed media, optional `objectKey` for platform storage. Built for agent asset flows; the editor itself ingests uploads via the upload pipeline. |
| `replaceItemAsset` | Swap the asset on a video item in place, preserving timeline timing and geometry; optional `generation` metadata stamps provenance on the item. |

### Captions

| Command | Does |
|---------|------|
| `addCaptionAsset` | Create an uploaded-caption asset from a non-empty array of `{text, startMs, endMs}` — no file upload needed (the same shape `/api/captions` and `/api/captions/generate` emit). |
| `setCaptionState` | Add / update / delete / `clearFinalized` captioning-task state used by the editor's task UI. |
| `relayoutCaptions` | Recompute a captions item's height from `fontSize`, `lineHeight`, and `maxLines`. |

### Tracks, z-order & composition scalars

| Command | Does |
|---------|------|
| `bringItemToFrontOrBack` | Move an item toward the visual front/back, creating a track only when the edge is occupied. |
| `addTrack` | Append a new empty track (optional `hidden`/`muted`). |
| `createTrack` | Create an empty track at an optional `index` with an optional deterministic `id`. |
| `deleteTrack` | Delete a track; non-empty tracks require `deleteItems: true` so items are never orphaned by accident. |
| `reorderTracks` / `setTrackOrder` | Apply a complete explicit track order (`trackIds` must contain every current track id exactly once; `setTrackOrder` is an alias). |
| `muteTrack` / `hideTrack` | Set a track's `muted` / `hidden` flag. |
| `setFps` | Set frame rate (validated `1..240`). |
| `setCompositionSize` | Set pixel dimensions (positive integers). |
| `setProjectColorGrade` | Set the composition-wide color grade (CSS-filter grade object). |

## Keyframe animation commands

The MOTION-062 seam — the AI-programmable animation surface. These are thin
wrappers over the pure ops in `editor/src/editor/animation/keyframe-edit.ts`,
the **same functions the editor reducers use**, so headless == editor == render.

| Command | Does |
|---------|------|
| `setKeyframe` | Upsert a keyframe: set `property` to `value` at an item-local `frame` (creates the property track when absent; a brand-new key defaults to `ease-in-out`). Optional `easing` or `bezier`. |
| `setKeyframeEasing` | Set the easing curve **leaving** an existing keyframe — named preset or custom cubic-bezier. |
| `removeKeyframe` | Remove the keyframe at a frame; removing the **last** key de-animates the property and bakes that value into the static field. |
| `enableAnimation` | Start animating a property: seed one keyframe holding the item's current static value (fails clearly if the item has no numeric value for that property). |
| `disableAnimation` | Stop animating: bake the effective value at a frame into the static field — no visual jump at that frame. |
| `setPositionKeyframe` | Author a Position keyframe: keys **both** `left` and `top` at a frame (given values or the current effective position) so the compound stays in lockstep. Omitted axes pin their current value. |

The animation vocabulary is advertised as **closed enums** in
`describeSchema().animation`, sourced from the runtime so they cannot drift:

- `animation.properties` = `ANIMATABLE_PROPS`: `top`, `left`, `width`, `height`,
  `opacity`, `rotation`, `borderRadius`.
- `animation.easings` = `EASING_NAMES`: `linear`, `ease`, `ease-in`, `ease-out`,
  `ease-in-out`, `hold`, `step-start`, `step-end`, `bounce`.
- Custom curves: `params.bezier = [p1x, p1y, p2x, p2y]` (or
  `{p1x, p1y, p2x, p2y}`), x handles in `0..1` — the render-safe `{bezier}`
  shape the curve editor (EDIT-007) also writes.
- **Frames are item-local** (0 = the item's first frame) and **easing is
  source-leaving** (a key's easing governs the segment *after* it).

## Discovery: `describeSchema()` and the command catalog

`evaluate(state, {kind: 'describe'})` (or `GET /api/json/schema`) returns the
machine-readable contract: request kinds and flags, every query target with its
fields, the 12 operators, the filter grammar, the query options and cost meta,
the closed animation enums, and **every command with its param specs**. It
carries a `version` field for forward compatibility. This is how the console,
the SDK, the MCP tools, and the agent registry discover the surface at runtime
instead of hard-coding it.

The server schema is richer than the editor's: it additionally enumerates
`patchableFields` (the `updateItem` whitelist with types + notes),
`query.itemFields` / `query.assetFields` (per-item-type field lists), and
`requestFlags` (`safe`/`dryRun`/`strict`).

The schema also embeds the **command catalog**
([`command-catalog.ts`](../../editor/src/editor/json-api/command-catalog.ts)):
for every command, whether it is `reducer-backed` (and by which
`state/actions/*.ts` files) or `headless-only` (with a documented reason), plus
four drift-detection lists — `commandsWithoutReducer`, `reducersWithoutCommand`
(e.g. `ripple-trim.ts`, `slip-edit.ts`, `slide-edit.ts`, `markers.ts` — editor
capabilities with no engine command yet), `missingReducerFiles`, and
`unclassifiedCommands`. Snapshot tests lock the catalog to the real
`state/actions/` directory listing, so reducer/command drift fails a test
instead of rotting silently.

## Error model

Failures return `{ok: false, error: {code, message, path?}}`. `code` is one of
`bad_request`, `unknown_op`, `unknown_target`, `not_found`, `invalid_params`,
`invalid_filter`, `batch_failed`. The optional **`path`** points at the offending
input — `params.fps`, `params.patch.type`, `where.and[1]`, `requests[2]` — so
callers (console, SDK, agent) can map an error straight back to the field that
caused it.

## Shipped surface 1: the in-editor JSON console

[`editor/src/editor/console/json-console.tsx`](../../editor/src/editor/console/json-console.tsx)
— the first surface on the engine, flag-gated by `FEATURE_JSON_CONSOLE`
([`flags.ts`](../../editor/src/editor/flags.ts), currently `true`) and mounted
inside `ContextProvider` in `editor.tsx` (it reads + writes editor state).

- Toggled by the `{ } Console` button in the StudioBar (a module-level
  `jsonConsoleRef` bridge, same pattern as the event log). Only mounted while
  open — load-path-safe.
- Type a JSON request, hit **Run** (or ⌘/Ctrl+Enter). Queries/`describe` are
  read-only against the live state; the **Schema** button preloads
  `{"kind": "describe"}`. History keeps the last 20 request/response pairs.
- **Commands/batch apply through the editor's real `setState`** with
  `commitToUndoStack: true`, so every engine mutation is exactly **one undo
  step**. The console evaluates once against the current snapshot for the
  displayed result, then **re-evaluates inside the state updater** against the
  freshest state — a commit that landed between render and click (an async
  asset finishing, a debounced effect) is never clobbered. A no-op returns the
  same reference, so no undo entry is added; selection entries pointing at
  deleted items are pruned.

## Shipped surface 2: the server HTTP API

The HTTP bridge is [`src/json-api.js`](../../src/json-api.js); the routes live
in [`src/api.js`](../../src/api.js). This **corrects the previously planned
path**: both a generic and a per-composition shape shipped.

| Route | Method | Purpose |
|-------|--------|---------|
| `/api/json/schema` | GET | The discovery contract (`describeSchema()`), no composition needed |
| `/api/json` | POST | Run any engine request against a **stored composition** (`{compositionId, request}`) or an **inline manifest** (`{state, request}`, stateless) |
| `/api/compositions/:id/engine` | POST | Same as `/api/json` with the composition id in the path — the stable shape for SDKs/agents. Body is `{request}` or the raw request object itself |
| `/api/compositions/:id/schema` | GET | `{kind: "describe"}` against that composition (also proves access) |

Semantics (all four delegate to `evaluateComposition` / `evaluateInline` in
`src/json-api.js`, so they cannot drift from each other):

- **Auth** — `requireApiUser`: a `cr_live_…` bearer authenticates as an API key
  (`src/api-keys.js`); anything else falls back to the normal session/JWT path.
  A presented-but-invalid key is a 401 — it never silently falls through.
- **Workspace scoping** — the composition is loaded via a
  `compositions → campaigns → workspace_id` join against the caller's
  workspace; a foreign composition is a 404.
- **Read-only fast path** — `isReadOnly()` classifies `query`/`describe` (and a
  batch made *only* of those) as reads: plain load + evaluate, no transaction.
- **Mutations are row-locked** — load `SELECT … FOR UPDATE` → evaluate →
  persist, in **one transaction**, so a concurrent save/command can't be lost.
  Persistence is keyed on the engine's reference-equality no-op contract:
  `changed = response.ok && next !== state`, and only a genuine change writes
  `compositions.manifest`. The response envelope reports
  `{response, compositionId, changed, persisted}`.
- **Manifest guard** — a manifest that isn't the editor IR (missing `tracks[]`,
  `items{}`, `assets{}`) is a 422 with a clear message, not a cryptic throw.
- **Rate limit** — mutating/query POSTs share one bucket per user
  (`json:<userId>`, 120 requests/min) across `/api/json` and
  `/api/compositions/:id/engine`.
- **Server-only read extensions** — the `selection` target, `safe`/`dryRun`
  preview flags, the extra computed item fields, and audio-session scoping (see
  [Query language](#query-language)).

The suite-wide agent's `/api/agent/execute` compiles approved engine steps into
**one batch** persisted through this same `evaluateComposition` path — auth,
scoping, row-locking, and persistence stay single-sourced.

## Shipped surface 3: the SDK and the MCP server

**SDK** ([`sdk/index.js`](../../sdk/index.js)) — zero-dependency client:

```js
import { CoreReflex } from '@corereflex/sdk';
const cr = new CoreReflex({ baseUrl, token });

await cr.schema();                                   // GET /api/json/schema
await cr.evaluate({ compositionId, request });       // POST /api/json
await cr.query(compId, 'items', { where: {field: 'kind', op: 'eq', value: 'audio'} });
await cr.command(compId, 'setFps', { fps: 60 });
await cr.batch(compId, [ /* requests */ ]);
```

**MCP server** ([`mcp/server.js`](../../mcp/server.js)) — a zero-dependency
Model Context Protocol server (JSON-RPC 2.0 over newline-delimited stdio) that
lets Claude or any MCP client drive the studio headlessly. It delegates to the
SDK; run it with:

```
COREREFLEX_API_URL=https://corereflex.com COREREFLEX_TOKEN=<jwt-or-cr_live-key> node mcp/server.js
```

Engine-relevant tools: `corereflex_schema` (the discovery contract) and
`corereflex_json` (any engine request against a stored composition or inline
manifest). Tool failures come back as MCP tool results with `isError` — not
protocol errors — so an agent can read the message and recover. See
[platform-api.md](platform-api.md) for the other tools (catalog, generate,
render, upscale).

## Shipped surface 4: the agent tool registry

[`src/agent-tools.js`](../../src/agent-tools.js) builds the suite-wide agent's
single canonical tool registry (`buildToolRegistry()`), and the engine is its
live backbone:

- **Engine commands are pulled from `describeSchema()` at build time, not
  hand-listed** — a new engine command is automatically visible to the agent;
  a parity test (AGENT-021) locks that invariant.
- Every engine command is classified `class: 'mutate'`, `reversible: true`
  (editor undo stack + atomic-batch rollback), `cost: 0` (no model credits).
  The read surface (`query`, `describe`) is declared separately as
  `class: 'read'` — the classification the planner and the approval/safety
  layer key off (a read-only plan runs without approval; `src/json-api.js`'s
  `isReadOnly()` is the matching HTTP-side gate).
- Director / generation / publish verbs live outside the engine in
  `CAPABILITY_TOOLS`, priced from `billing.USAGE_COSTS`.

See [agent-kernel.md](agent-kernel.md) for the plan → approve → execute loop
this registry feeds.

## Trust story: parity and snapshot tests

Three independent test layers keep "one contract, two engines, four surfaces"
honest:

1. **Reducer parity** —
   [`reducer-parity.runner.ts`](../../editor/src/editor/json-api/reducer-parity.runner.ts)
   (launched by `reducer-parity.test.js` with the local TS loader; 27 tests)
   asserts `deepEqual` between each engine command's output and the **real
   editor reducer's** output on the same fixture — `updateItem` vs
   `changeItem`, `setKeyframe` vs `setItemKeyframe`, `deleteItems` vs the
   reducer's deletion + empty-track cleanup, and so on (26 cases). An
   **inventory test** requires every command in `COMMANDS` to either have a
   parity case or be explicitly listed as documented-headless-only — an
   unclassified new command fails the suite.
2. **Catalog snapshots** — tests in
   [`engine.test.ts`](../../editor/src/editor/json-api/engine.test.ts) lock the
   command catalog's `reducerFiles` list to the actual
   `editor/src/editor/state/actions/` directory listing (via `readdirSync`) and
   assert the drift lists (`missingReducerFiles`, `unclassifiedCommands`) are
   empty.
3. **Server-mirror parity** — [`test/json-engine.test.js`](../../test/json-engine.test.js)
   is ported from `engine.test.ts` (same fixtures, same assertions) and guards
   the JS mirror; [`test/json-api.test.js`](../../test/json-api.test.js) covers
   the HTTP bridge semantics (read-only fast path, row-locked persistence,
   no-op non-persistence, manifest guard) with injected `query`/`withClient`
   fakes — no live database needed.

## Examples

### 1. Query: every audio clip, just `id` + `endFrame`, longest first

```json
{
  "kind": "query",
  "target": "items",
  "where": { "field": "kind", "op": "eq", "value": "audio" },
  "select": ["id", "endFrame"],
  "orderBy": { "field": "endFrame", "dir": "desc" }
}
```

```json
{
  "ok": true,
  "kind": "query",
  "result": [{ "id": "a1", "endFrame": 90 }],
  "meta": {
    "total": 1,
    "returned": 1,
    "cost": { "rowsScanned": 3, "filterDepth": 1, "score": 3 }
  }
}
```

### 2. Grouped aggregate: item counts per type, only busy groups

```json
{
  "kind": "query",
  "target": "items",
  "aggregate": {
    "count": true,
    "groupBy": ["kind"],
    "having": { "field": "count", "op": "gt", "value": 2 }
  }
}
```

Returns an array of `{kind, count}` rows, one per surviving group.

### 3. Command: move an item, input untouched

```json
{ "kind": "command", "op": "updateItem", "params": { "id": "v1", "patch": { "from": 100 } } }
```

```json
{
  "ok": true,
  "kind": "command",
  "result": {
    "op": "updateItem",
    "changed": true,
    "affectedIds": ["v1"],
    "summary": "updated item v1 (from)"
  }
}
```

The returned `state` has `items.v1.from === 100`; the composition you passed in
still reads its old value.

### 4. Keyframe: fade an item in with a custom spring-ish curve

```json
{
  "kind": "batch",
  "requests": [
    { "kind": "command", "op": "setKeyframe",
      "params": { "id": "logo1", "property": "opacity", "frame": 0, "value": 0 } },
    { "kind": "command", "op": "setKeyframe",
      "params": { "id": "logo1", "property": "opacity", "frame": 30, "value": 1,
                  "bezier": [0.34, 1.56, 0.64, 1] } }
  ]
}
```

Frames are item-local; the frame-0 key's easing governs the 0→30 segment.

### 5. Batch atomicity

```json
{
  "kind": "batch",
  "requests": [
    { "kind": "command", "op": "setFps", "params": { "fps": 60 } },
    { "kind": "command", "op": "moveItem", "params": { "id": "v1", "from": 5 } }
  ]
}
```

If the second step referenced a missing item, the response would be
`{ok: false, error: {code: "batch_failed", path: "requests[1]", …}}` and the
returned `state` would be the **original** composition — `fps` rolled back.

### 6. HTTP: dry-run a delete against a stored composition

```
POST /api/json
Authorization: Bearer cr_live_…
Content-Type: application/json

{
  "compositionId": "22222222-2222-4222-8222-222222222222",
  "request": { "kind": "command", "op": "deleteItems",
               "params": { "ids": ["v1", "x1"] }, "safe": true }
}
```

```json
{
  "response": {
    "ok": true,
    "kind": "command",
    "result": {
      "op": "deleteItems", "changed": true,
      "affectedIds": ["v1", "x1"],
      "summary": "deleted 2 item(s)", "safe": true
    }
  },
  "compositionId": "22222222-2222-4222-8222-222222222222",
  "changed": false,
  "persisted": false
}
```

`safe` previews the outcome (`affectedIds`, `summary`) without persisting —
drop the flag to commit it in the row-locked transaction.

## Module map

| File | Role |
|------|------|
| [`editor/src/editor/json-api/types.ts`](../../editor/src/editor/json-api/types.ts) | Request / response / filter / error / cost types. The contract, in TypeScript. |
| [`editor/src/editor/json-api/query.ts`](../../editor/src/editor/json-api/query.ts) | Pure query evaluation: dotted-path read, 12 operators, filter tree, sort, page, project, grouped aggregates, cost. No editor coupling. |
| [`editor/src/editor/json-api/commands.ts`](../../editor/src/editor/json-api/commands.ts) | The whitelisted command registry — 47 ops with param specs, validators, and the no-op reference-equality contract (~5,100 lines). |
| [`editor/src/editor/json-api/command-catalog.ts`](../../editor/src/editor/json-api/command-catalog.ts) | `generateCommandCatalog()` — maps every command to its backing reducer files and emits the drift lists. |
| [`editor/src/editor/json-api/schema.ts`](../../editor/src/editor/json-api/schema.ts) | `describeSchema()` — the discovery surface, including the closed animation enums. |
| [`editor/src/editor/json-api/engine.ts`](../../editor/src/editor/json-api/engine.ts) | `evaluate` — the dispatcher: row building, strict-mode validation, atomic batch. |
| [`editor/src/editor/json-api/index.ts`](../../editor/src/editor/json-api/index.ts) | Public surface: `evaluate`, `describeSchema`, `COMMANDS`, catalog, query helpers, types. |
| [`editor/src/editor/json-api/engine.test.ts`](../../editor/src/editor/json-api/engine.test.ts) | 89 node tests over plain-JSON fixtures, incl. the catalog snapshots. |
| [`editor/src/editor/json-api/reducer-parity.runner.ts`](../../editor/src/editor/json-api/reducer-parity.runner.ts) | 27 command↔reducer parity tests + the inventory lock (spawned by `reducer-parity.test.js`). |
| [`editor/src/editor/json-api/ts-extension-loader.mjs`](../../editor/src/editor/json-api/ts-extension-loader.mjs) | Node loader that transpiles the editor's `.ts` on the fly so the suites run under plain `node --test`. |
| [`editor/src/editor/console/json-console.tsx`](../../editor/src/editor/console/json-console.tsx) | The in-editor console panel + StudioBar button. |
| [`src/json-engine.js`](../../src/json-engine.js) | The server mirror (~3,800 lines): same 47 commands, plus the server-only read extensions. |
| [`src/json-api.js`](../../src/json-api.js) | The HTTP bridge: `evaluateComposition` (row-locked persist), `evaluateInline`, `engineSchema`, `isReadOnly`. |
| [`src/api.js`](../../src/api.js) | Routes: `/api/json`, `/api/json/schema`, `/api/compositions/:id/engine`, `/api/compositions/:id/schema`. |
| [`src/agent-tools.js`](../../src/agent-tools.js) | Agent tool registry built live from `describeSchema()`. |
| [`sdk/index.js`](../../sdk/index.js) | SDK: `schema` / `evaluate` / `query` / `command` / `batch`. |
| [`mcp/server.js`](../../mcp/server.js) | MCP server exposing `corereflex_schema` / `corereflex_json` (and the wider platform tools). |

## Testing

All commands below were verified against the working tree.

```bash
# Server engine mirror + HTTP bridge (from the repo root; part of `npm test`)
node --test test/json-engine.test.js test/json-api.test.js       # 120 tests

# Editor engine suite (from editor/ — .ts needs the local loader)
cd editor
node --loader ./src/editor/json-api/ts-extension-loader.mjs \
  --test src/editor/json-api/engine.test.ts                      # 89 tests

# Command ↔ reducer parity (the launcher is plain .js; it spawns the TS runner)
node --test src/editor/json-api/reducer-parity.test.js           # 27 child tests
```

## Gotchas

- **The two engines diverge on reads by design.** The server adds `selection`,
  `safe`/`dryRun`, extra computed fields, and audio-session scoping; the editor
  engine has none of these (the console doesn't need them — it holds live
  state). **Commands must stay in strict lockstep**: change a command in the
  editor `.ts`, mirror it in `src/json-engine.js`, and keep
  `test/json-engine.test.js` in sync — that suite is the tripwire.
- **No-op = same reference.** Scalar/structural setters return the same state
  ref when nothing changed; the console skips the undo entry and the server
  skips the write on exactly that signal. Object-valued setters
  (`setItemAudioFx`, `setMasterAudioFx`, `setProjectColorGrade`) always
  allocate — setting them is treated as intentional, no deep-equality check.
- **`addItem` only places on untagged (video) tracks.** Audio-session tracks
  are edited via the Audio section (the `scoped-track-writes` boundary rule);
  `duplicateItem`/`duplicateItems` keep copies within their source track's
  scope by construction.
- **The clipboard lives with the caller.** `cutItems` returns the removed items
  in `result.data.clipboard`; `pasteItems` takes that payload as a param. The
  engine (and the manifest) never stores clipboard state.
- **`safe: true` still takes the write path on the server.** `isReadOnly()`
  only fast-paths `query`/`describe`, so a dry-run command opens the row-locked
  transaction (it just never persists). Cheap, but don't hammer `safe` requests
  expecting read-path throughput.
- **Frames are item-local; easing is source-leaving.** Both trip people up when
  scripting animation: frame 0 is the item's first frame (not timeline frame 0),
  and a key's easing shapes the segment *after* it.
- **`gt/gte/lt/lte` never match non-numbers**, and `exists` pairs with
  `value: false` for absence — `{op: "ne", value: null}` is not the same test.
- **The editor `.ts` suites are not discovered by a bare `node --test`.** Run
  them with the loader commands above; only the `.js` launcher
  (`reducer-parity.test.js`) is auto-discovered.
- **Stale code comments.** A few headers in `editor/src/editor/json-api/`
  (`types.ts`, `index.ts`) still say "nothing imports this yet / forthcoming
  console" — the console and server surfaces shipped; trust this doc and the
  code paths, not those comments.

## Related docs

- [platform-api.md](platform-api.md) — the full programmatic platform (catalog, API keys, SDK, MCP) around the engine
- [api-reference.md](api-reference.md) — every HTTP endpoint
- [agent-kernel.md](agent-kernel.md) — the suite-wide agent that plans/executes over the engine's tool registry
- [editor.md](editor.md) — the editor the engine's state model comes from
- [data-model.md](data-model.md) — where `compositions.manifest` lives
- [security.md](security.md) — API-key auth and workspace scoping
