Skip to content

examples/app-crm + app-showcase: every authored hook reads the record off ctx.input instead of ctx.input.data, so each one silently does nothing at runtime #7225

Description

@os-help

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:

  1. examples/app-crm/src/hooks/opportunity.hook.tsOpportunityStageHook, 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.

  2. examples/app-showcase/src/data/hooks/index.tsStampInquiryDefaultsHook, 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions