You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
POST /packages/com.example.ops/publish-drafts → then PATCH a showcase_task record → assignee was not set to HOOKED (hook didn't fire).
Restarted the server → PATCH again → still notHOOKED. GET /meta/hook DOES list rebind_probe_hook (it's loaded, its body just never runs).
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 hooks — packages/runtime/src/app-plugin.ts:402 binds them with a bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), {...}). Bodies run.
authored / metadata-service hooks — packages/objectql/src/plugin.ts:978 binds them with nobodyRunner: 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)
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.
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.
Summary
A hook authored in the Studio (i.e. saved as
hookmetadata viaprotocol.saveMetaItem/publish-drafts, notdefineStack) never executes itsbody— on publish, and even after a full restart. The hook loads into the registry (it shows up inGET /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)
com.example.ops:{ "name":"rebind_probe_hook", "object":"showcase_task", "events":["beforeUpdate"], "body":{ "language":"js", "source":"ctx.input.assignee = 'HOOKED';" }, "priority":10 }POST /packages/com.example.ops/publish-drafts→ thenPATCHashowcase_taskrecord →assigneewas not set toHOOKED(hook didn't fire).PATCHagain → still notHOOKED.GET /meta/hookDOES listrebind_probe_hook(it's loaded, its body just never runs).defineStackhookshowcase_normalize_task_title(trims the title onbeforeUpdate) 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:
packages/runtime/src/app-plugin.ts:402binds them with abodyRunner:hookBodyRunnerFactory(new QuickJSScriptRunner(), {...}). Bodies run.packages/objectql/src/plugin.ts:978binds them with nobodyRunner:bindHooks(items, { packageId: 'metadata-service' }).bindHooksToEngineskips any hook whosebodyis set when it has no runner (packages/objectql/src/hook-binder.ts:144— "hook body present but no bodyRunner supplied"). There is a fallbackengine._defaultBodyRunner(engine.ts:516-517) and a setterengine.setDefaultBodyRunner()(engine.ts:537) — butsetDefaultBodyRunneris 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 abeforeInsert/beforeUpdatenormalization or a gatedafter*side-effect in the Studio, sees it saved, and it never runs — no error, no log.hookis a first-class authorable type (hook.form.tsStudio form exists;HookSchemafull-featured). Part of the "Studio authoring dead-ends" family.Proposed fix (two parts)
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_defaultBodyRunnerfallback. Also thread thefunctionsmap for string-handlerhooks.metadata:reloaded(fired on publish since fix(automation): bind a flow published while the server runs, without a restart #2576) and re-runbindHooks(freshHooks, { packageId: 'metadata-service' })(idempotent:bindHooksToEnginecallsunregisterHooksByPackagefirst, so removed hooks tear down). Mirrorsservice-automation'sresyncFlowsFromProtocol.Should runtime-authored (DB-stored, non-code-reviewed) hooks be allowed to run sandboxed JS at all? The missing
setDefaultBodyRunnercall may be a deliberate gate rather than an oversight. QuickJS is capability-gated andhook.form.tsexists (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.