Found while verifying #7225's premise (which the measurement below falsifies — see my report there). Filed standalone and unassigned: the examples are correct; the prose that describes them is not.
The gap
The contract table on HookContextSchema.input (packages/spec/src/data/hook.zod.ts) documents exactly one shape — the raw engine envelope:
insert (one context per row): { data, options }
update (single id): { id, data, options }
That is true of the surface it was written against: a handler registered with engine.registerHook, which is what packages/objectql/src/hook-input-shape-contract.test.ts pins. It is not the surface an app author writes on, and the table never says so.
Every declarative hook — a metadata Hook bound through bindHooksToEngine, i.e. everything from defineStack({ hooks }) — is wrapped by wrapDeclarativeHook, which calls installFlatInput (packages/objectql/src/hook-wrappers.ts:446, helper at :502). That swaps ctx.input for a Proxy presenting a flat record view over the envelope: reads of any non-wrapper key resolve against data, and writes always land in data. Its own doc comment says so explicitly — "so the engine's downstream input.data read picks up mutations made by user code as input.field = value".
Sandboxed body hooks go one step further. buildSandboxContext (packages/runtime/src/sandbox/body-runner.ts:314) hands the script unwrapProxyToPlain(engineCtx.input), and that unwrap runs through the proxy's ownKeys trap — which deliberately enumerates only the flat record fields. So inside a body, ctx.input is the record, and ctx.input.data is undefined.
Measured
Real kernel, real objects, real QuickJS runner, persisted-row assertions (the same wiring AppPlugin uses in production — ql.bindHooks(...) with bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), …), packages/runtime/src/app-plugin.ts:717-742):
| surface |
ctx.input.field |
ctx.input.data.field |
| code handler on a declarative Hook |
works |
works (the proxy passes data through) |
sandboxed body hook |
works |
TypeError — data is undefined |
Shape probe from inside a body, printed by writing the answer to a persisted column:
KEYS[name,email,message] hasData=undefined
And the damage, measuring the documented spelling in a real body whose onError is abort (the setting the shipped showcase hook actually carries):
insert threw = "hook '…' threw: TypeError: cannot read property 'status' of undefined"
rows persisted = 0
Why this is worth fixing
The table is the only thing an author — human or AI — can read to learn what to reach for; the file's own pin test says exactly that about an earlier drift in the same table. Today it teaches a spelling that is redundant on one declarative surface and fatal on the other, and it is silent about the spelling that works on both.
The evidence that this misleads a careful reader is #7225 itself: an author holding the contract table, the engine dispatch site and buildSandboxContext concluded that three working, shipped hooks were dead no-ops, and prescribed a "fix" that would have converted a working public web-to-lead insert into a hard refusal of every submission.
Suggested shape (not a decision)
Document the declarative surface in the same table, as its own rows rather than a footnote: what bindHooksToEngine installs, and that a body sees the flat record with no data key at all. The engine-vs-declarative split is real and deliberate; what is missing is that the table names only one half of it while addressing readers who only ever meet the other.
Whether the two surfaces should instead be converged is a separate and bigger question, and not one this card should decide.
Generated by Claude Code
Found while verifying #7225's premise (which the measurement below falsifies — see my report there). Filed standalone and unassigned: the examples are correct; the prose that describes them is not.
The gap
The contract table on
HookContextSchema.input(packages/spec/src/data/hook.zod.ts) documents exactly one shape — the raw engine envelope:That is true of the surface it was written against: a handler registered with
engine.registerHook, which is whatpackages/objectql/src/hook-input-shape-contract.test.tspins. It is not the surface an app author writes on, and the table never says so.Every declarative hook — a metadata
Hookbound throughbindHooksToEngine, i.e. everything fromdefineStack({ hooks })— is wrapped bywrapDeclarativeHook, which callsinstallFlatInput(packages/objectql/src/hook-wrappers.ts:446, helper at:502). That swapsctx.inputfor a Proxy presenting a flat record view over the envelope: reads of any non-wrapper key resolve againstdata, and writes always land indata. Its own doc comment says so explicitly — "so the engine's downstreaminput.dataread picks up mutations made by user code asinput.field = value".Sandboxed
bodyhooks go one step further.buildSandboxContext(packages/runtime/src/sandbox/body-runner.ts:314) hands the scriptunwrapProxyToPlain(engineCtx.input), and that unwrap runs through the proxy'sownKeystrap — which deliberately enumerates only the flat record fields. So inside a body,ctx.inputis the record, andctx.input.dataisundefined.Measured
Real kernel, real objects, real QuickJS runner, persisted-row assertions (the same wiring
AppPluginuses in production —ql.bindHooks(...)withbodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), …),packages/runtime/src/app-plugin.ts:717-742):ctx.input.fieldctx.input.data.fielddatathrough)bodyhookdatais undefinedShape probe from inside a body, printed by writing the answer to a persisted column:
And the damage, measuring the documented spelling in a real
bodywhoseonErrorisabort(the setting the shipped showcase hook actually carries):Why this is worth fixing
The table is the only thing an author — human or AI — can read to learn what to reach for; the file's own pin test says exactly that about an earlier drift in the same table. Today it teaches a spelling that is redundant on one declarative surface and fatal on the other, and it is silent about the spelling that works on both.
The evidence that this misleads a careful reader is #7225 itself: an author holding the contract table, the engine dispatch site and
buildSandboxContextconcluded that three working, shipped hooks were dead no-ops, and prescribed a "fix" that would have converted a working public web-to-lead insert into a hard refusal of every submission.Suggested shape (not a decision)
Document the declarative surface in the same table, as its own rows rather than a footnote: what
bindHooksToEngineinstalls, and that abodysees the flat record with nodatakey at all. The engine-vs-declarative split is real and deliberate; what is missing is that the table names only one half of it while addressing readers who only ever meet the other.Whether the two surfaces should instead be converged is a separate and bigger question, and not one this card should decide.
Generated by Claude Code