Skip to content

Authored (Studio) hooks never execute their body — no bodyRunner on the metadata-service bind path #2588

Description

@os-zhuang

Summary

A hook authored in the Studio (i.e. saved as hook metadata via protocol.saveMetaItem / publish-drafts, not defineStack) never executes its body — on publish, and even after a full restart. The hook loads into the registry (it shows up in GET /meta/hook), but its logic silently never runs. This is a "declared-but-not-wired" runtime-authoring gap, not a stale-cache/rebind bug.

Found while generalizing the flow publish-rebind fix (#2576, follow-up #2560). Only flows was a true rebind bug; hooks is deeper.

Runtime evidence (clean-instance showcase, verified)

  1. Authored an L2 hook into a writable base package com.example.ops:
    { "name":"rebind_probe_hook", "object":"showcase_task", "events":["beforeUpdate"],
      "body":{ "language":"js", "source":"ctx.input.assignee = 'HOOKED';" }, "priority":10 }
  2. POST /packages/com.example.ops/publish-drafts → then PATCH a showcase_task record → assignee was not set to HOOKED (hook didn't fire).
  3. Restarted the serverPATCH again → still not HOOKED. GET /meta/hook DOES list rebind_probe_hook (it's loaded, its body just never runs).
  4. Control: the showcase's own defineStack hook showcase_normalize_task_title (trims the title on beforeUpdate) does fire (" spaced ""spaced"). So event/targeting works — the failure is isolated to the authored-hook bind path.

Root cause

Two hook-bind paths get different treatment:

  • defineStack / bundle hookspackages/runtime/src/app-plugin.ts:402 binds them with a bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), {...}). Bodies run.
  • authored / metadata-service hookspackages/objectql/src/plugin.ts:978 binds them with no bodyRunner: bindHooks(items, { packageId: 'metadata-service' }).

bindHooksToEngine skips any hook whose body is set when it has no runner (packages/objectql/src/hook-binder.ts:144 — "hook body present but no bodyRunner supplied"). There is a fallback engine._defaultBodyRunner (engine.ts:516-517) and a setter engine.setDefaultBodyRunner() (engine.ts:537) — but setDefaultBodyRunner is never called anywhere in the repo, so the fallback is always undefined and authored hook bodies are silently dropped.

Impact

priority:p1. Silent data-integrity failure: a user authors a beforeInsert/beforeUpdate normalization or a gated after* side-effect in the Studio, sees it saved, and it never runs — no error, no log. hook is a first-class authorable type (hook.form.ts Studio form exists; HookSchema full-featured). Part of the "Studio authoring dead-ends" family.

Proposed fix (two parts)

  1. Wire a default bodyRunner at boot so authored hooks execute at all. The runtime already builds one for defineStack hooks — call ql.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, ... })) once at boot (runtime/AppPlugin), so the metadata-service bind path (which passes no explicit runner) picks it up via the existing _defaultBodyRunner fallback. Also thread the functions map for string-handler hooks.
  2. Rebind on publish — have the objectql plugin subscribe to metadata:reloaded (fired on publish since fix(automation): bind a flow published while the server runs, without a restart #2576) and re-run bindHooks(freshHooks, { packageId: 'metadata-service' }) (idempotent: bindHooksToEngine calls unregisterHooksByPackage first, so removed hooks tear down). Mirrors service-automation's resyncFlowsFromProtocol.

⚠️ Design decision needed first

Should runtime-authored (DB-stored, non-code-reviewed) hooks be allowed to run sandboxed JS at all? The missing setDefaultBodyRunner call may be a deliberate gate rather than an oversight. QuickJS is capability-gated and hook.form.ts exists (suggesting intent = yes), but this is a privilege/security boundary the platform owner should confirm before part (1) lands. If the answer is "no, hooks are code-only", the fix is instead to stop the Studio from letting users author a hook that can never run.

Context: post-#2576 boot-cached-consumer audit. Companion issues: authored translations (i18n) and runtime-created sharing rules have related runtime-authoring gaps.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p1High: required for production / M2

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions