Found while implementing #7036 (examples/app-todo's completion stamp). Filed standalone and unassigned: #7036 repairs only app-todo's own hook, and the same mistake is live in the other two example apps under a different owner.
The defect
HookContext.input is an envelope, never the record. The contract table on HookContextSchema.input (packages/spec/src/data/hook.zod.ts), pinned against the real engine in packages/objectql/src/hook-input-shape-contract.test.ts, says:
insert (one context per row): { data, options }
update (single id): { id, data, options }
So the record is at ctx.input.data. Assigning to ctx.input.<field> sets a key on the envelope that no write path reads — the engine dispatches the driver write from hookContext.input.data.
Three shipped hooks do exactly that, and all three are registered (hooks: allHooks), so they run and have no effect:
-
examples/app-crm/src/hooks/opportunity.hook.ts — OpportunityStageHook, events beforeInsert + beforeUpdate:
const input = ctx.input as { stage?: string; probability?: number };
if (input.stage === 'closed_won') input.probability = 100;
input.stage is undefined on every call, so probability is never pinned.
-
examples/app-showcase/src/data/hooks/index.ts — StampInquiryDefaultsHook, beforeInsert on showcase_inquiry:
if (!ctx.input.status) ctx.input.status = 'new'; if (!ctx.input.source) ctx.input.source = 'web';
The web-to-lead defaults are never stamped.
-
Same file — NormalizeTaskTitleHook: if (ctx.input.title) ctx.input.title = ctx.input.title.trim(); — never trims.
Script bodies get the same envelope — no flattening anywhere
Worth stating because 2 and 3 are sandboxed body hooks rather than code handlers, so one might expect the runner to hand them the record. It does not. buildSandboxContext (packages/runtime/src/sandbox/body-runner.ts) sets input: unwrapProxyToPlain(engineCtx?.input) ?? {} — a snapshot of the envelope, unchanged. Its own comment is explicit that input is "the engine's own spelling, and the only one", and #5906 removed the alias limbs precisely so there would not be a second de-facto contract.
The after* hooks in that file (AuditTaskCompletionHook, WarnOverBudgetHook) read ctx.result || ctx.input and are not obviously affected — ctx.result is bound on after events. They are worth a look but are not the claim here.
Why it matters more than the three behaviours
These are reference-corpus apps. An AI author asked to write a hook copies the shape it sees, and every shape it can see in this repo's examples is the broken one. #7036 is the same mistake in app-todo, where it had been invisible because that hook was never registered at all.
Not verified
I did not run either app — the reading is from the contract table, the engine's dispatch site, and buildSandboxContext. A one-line probe per app (assert the persisted row) would confirm before fixing.
Found while implementing #7036 (
examples/app-todo's completion stamp). Filed standalone and unassigned: #7036 repairs only app-todo's own hook, and the same mistake is live in the other two example apps under a different owner.The defect
HookContext.inputis an envelope, never the record. The contract table onHookContextSchema.input(packages/spec/src/data/hook.zod.ts), pinned against the real engine inpackages/objectql/src/hook-input-shape-contract.test.ts, says:So the record is at
ctx.input.data. Assigning toctx.input.<field>sets a key on the envelope that no write path reads — the engine dispatches the driver write fromhookContext.input.data.Three shipped hooks do exactly that, and all three are registered (
hooks: allHooks), so they run and have no effect:examples/app-crm/src/hooks/opportunity.hook.ts—OpportunityStageHook, eventsbeforeInsert+beforeUpdate:input.stageisundefinedon every call, so probability is never pinned.examples/app-showcase/src/data/hooks/index.ts—StampInquiryDefaultsHook,beforeInsertonshowcase_inquiry:The web-to-lead defaults are never stamped.
Same file —
NormalizeTaskTitleHook:if (ctx.input.title) ctx.input.title = ctx.input.title.trim();— never trims.Script bodies get the same envelope — no flattening anywhere
Worth stating because 2 and 3 are sandboxed
bodyhooks rather than code handlers, so one might expect the runner to hand them the record. It does not.buildSandboxContext(packages/runtime/src/sandbox/body-runner.ts) setsinput: unwrapProxyToPlain(engineCtx?.input) ?? {}— a snapshot of the envelope, unchanged. Its own comment is explicit thatinputis "the engine's own spelling, and the only one", and #5906 removed the alias limbs precisely so there would not be a second de-facto contract.The
after*hooks in that file (AuditTaskCompletionHook,WarnOverBudgetHook) readctx.result || ctx.inputand are not obviously affected —ctx.resultis bound on after events. They are worth a look but are not the claim here.Why it matters more than the three behaviours
These are reference-corpus apps. An AI author asked to write a hook copies the shape it sees, and every shape it can see in this repo's examples is the broken one. #7036 is the same mistake in app-todo, where it had been invisible because that hook was never registered at all.
Not verified
I did not run either app — the reading is from the contract table, the engine's dispatch site, and
buildSandboxContext. A one-line probe per app (assert the persisted row) would confirm before fixing.