Skip to content

HookContextSchema.input's contract table documents only the raw-engine envelope — every declarative hook sees a flat record view, and in a sandboxed body the documented input.data spelling is a TypeError #7254

Description

@os-help

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 TypeErrordata 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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions