@@ -310,6 +310,15 @@ export const HookContextSchema = lazySchema(() => z.object({
310310 * Input Parameters (Mutable)
311311 * Modify this to change the behavior of the operation.
312312 *
313+ * TWO SURFACES, and they do NOT hand a handler the same `input`. The rows
314+ * immediately below are the RAW ENVELOPE the engine builds — what a handler
315+ * registered through `engine.registerHook` receives, and only that. Every
316+ * DECLARATIVE hook — a metadata `Hook`, i.e. everything an app author writes
317+ * in `defineStack({ hooks })` — is handed a FLAT RECORD VIEW of that
318+ * envelope instead. If you are authoring a `Hook`, the rows you need are the
319+ * DECLARATIVE SURFACE block that follows the envelope rows; read the
320+ * envelope rows as the shape being viewed, not as what you will see.
321+ *
313322 * These shapes are exactly what the engine BUILDS — `packages/objectql`'s
314323 * `engine.ts` is the only producer of a `HookContext`. A key this table
315324 * lists but no producer sets reads back `undefined` at every call site, and
@@ -330,8 +339,61 @@ export const HookContextSchema = lazySchema(() => z.object({
330339 * - delete (bulk, multi:true) — before, PER MATCHED ROW: { id: ID, options: EngineDeleteOptions }
331340 * - delete (bulk, multi:true) — after, PER MATCHED ROW: { id: ID, options: DriverOptions }
332341 *
342+ * DECLARATIVE SURFACE — what an app author is actually handed
343+ *
344+ * `bindHooksToEngine` wraps every metadata `Hook` in `wrapDeclarativeHook`,
345+ * which calls `installFlatInput` (`packages/objectql/src/hook-wrappers.ts`)
346+ * for the duration of the handler call: `ctx.input` is swapped for a Proxy
347+ * presenting a flat record view over the envelope above, then restored in a
348+ * `finally`, so the engine's own downstream `input.data` read still picks up
349+ * whatever the handler wrote. That produces TWO shapes, not one — measured
350+ * (#7254) on a real kernel and the real QuickJS runner, with persisted-row
351+ * assertions:
352+ *
353+ * - declarative `handler` (code): `input.<field>` IS the record field. Reads
354+ * of any non-wrapper key resolve against `data`; writes ALWAYS land in
355+ * `data` (created if missing), which is how `input.field = value` reaches
356+ * the driver — and on a bulk write that IS the one batch-scoped payload
357+ * (D3 below), so the flat spelling scopes a rewrite no better than
358+ * `input.data.field` did. The wrapper keys `data` / `id` / `options` / `ast` still
359+ * pass THROUGH to the envelope, so `input.data.<field>` also happens to
360+ * work here — redundantly, and it is the spelling that breaks on the other
361+ * surface. Enumeration is flat-only: `Object.keys(input)`, spread and
362+ * `for…in` list the record fields and hide the wrapper keys (the proxy's
363+ * `ownKeys` trap), so a diff written as
364+ * `Object.keys(input).filter(k => input[k] !== previous[k])` sees fields.
365+ * - declarative `body` (L2 sandboxed JS): `ctx.input` IS the flat record —
366+ * a plain snapshot the runner takes as `unwrapProxyToPlain(engineCtx.input)`
367+ * (`packages/runtime/src/sandbox/body-runner.ts`), i.e. `Object.entries`
368+ * over that proxy, so it materialises exactly what `ownKeys` exposes and
369+ * nothing else. There is NO `data` key at all: `input.data` is `undefined`,
370+ * and the envelope spelling `input.data.<field>` is a **TypeError** — which
371+ * ABORTS the caller's write on a hook whose `onError` is `abort` — which is
372+ * this schema's DEFAULT, and what shipped showcase body hooks declare
373+ * explicitly. `id`, `options` and `ast` are
374+ * absent for the same reason; on `find` and `delete`, whose envelopes carry
375+ * no `data`, the whole snapshot is `{}`. A body that needs the row reads
376+ * `ctx.previous` — the pre-image, `id` included, bound on update and delete.
377+ * Writes the script makes to `ctx.input` are copied back onto the live proxy
378+ * after it returns (`applyMutationsToInput`), so `input.field = value` still
379+ * lands in `data` through the same `set` trap.
380+ *
381+ * There is therefore no single spelling that works everywhere:
382+ * `input.<field>` is correct on BOTH declarative surfaces, and
383+ * `input.data.<field>` only off the raw `registerHook` envelope. #7225 is
384+ * what naming just one half costs: a careful reader holding only the envelope
385+ * rows concluded that three shipped, working example hooks were silent
386+ * no-ops, and prescribed re-spelling them to `input.data` — which would have
387+ * converted the showcase's public web-to-lead insert into a hard refusal of
388+ * every submission. The two declarative shapes are pinned against a real
389+ * kernel in `examples/app-showcase/test/hook-body-persisted-writes.test.ts`
390+ * (#7258); the envelope rows stay pinned in objectql's
391+ * `hook-input-shape-contract.test.ts`. Whether the two surfaces should
392+ * CONVERGE is a separate question, deliberately not decided here (#7254) —
393+ * the split is stated so it cannot be mistaken for an oversight.
394+ *
333395 * PHASE — `input.options` is the one slot whose TYPE depends on when you read
334- * it, on every path above. The engine builds the context with the CALLER's
396+ * it, on every envelope path above. The engine builds the context with the CALLER's
335397 * own options bag (`EngineQueryOptions` / `DataEngineInsertOptions` /
336398 * `EngineUpdateOptions` / `EngineDeleteOptions`) and only AFTER the `before*`
337399 * handlers return — before the driver call — merges the driver-facing keys
@@ -340,7 +402,7 @@ export const HookContextSchema = lazySchema(() => z.object({
340402 * `after*` handler and the driver read the `DriverOptions` view. The merge is
341403 * ADDITIVE — it spreads the caller's bag and adds keys, never strips one — so
342404 * the widening is one-way and nothing a `before*` handler saw disappears.
343- * #5997 corrected the two `before` rows above, which had said `DriverOptions`
405+ * #5997 corrected the two `before` envelope rows above, which had said `DriverOptions`
344406 * (a type that declares no `where` and no `multi`): that is not what the
345407 * engine builds there, and not what the two consumers named below read.
346408 * Measured and pinned in the same contract test as the rest of this table.
@@ -354,7 +416,7 @@ export const HookContextSchema = lazySchema(() => z.object({
354416 * (D1–D7) with its budget ceiling is `data/bulk-write-hook-conformance.ts`
355417 * (ADR-0058 Addendum II).
356418 *
357- * Two things a reader of the rows above still has to know:
419+ * Two things a reader of the envelope rows above still has to know:
358420 *
359421 * - The PAYLOAD stays BATCH-scoped (D3). Every per-row `beforeUpdate`
360422 * context carries THE one payload, not a copy — `driver.updateMany` takes
0 commit comments