Filed from the reference app while slimming src/objects/ comments (objectstack-ai/hotcrm#1184). This constraint is written out, by hand, in twelve files of that one directory. It is the single most-repeated platform note in the app, which is the signal that it wants to be a platform affordance rather than prose.
The constraint, as app authors have to state it
An L2 hook handler is lowered to a metadata-only body.source and evaluated inside QuickJS with no module scope. A handler body that references an import, a top-level const, or even the factory function's own parameter is a ReferenceError at runtime — not a closure.
That much is documented and defensible. The part that costs is what happens when an author gets it wrong:
extractHookBodythrows, naming the free identifiers:
[hook-body-extract] hook 'x': handler references identifier(s) not in
scope at runtime: refuse. Module-scope helpers/imports aren't shipped
with a metadata-only body, so this handler will be BUNDLED instead …
- …and the CLI build catches that throw and silently bundles the closure instead. Nothing goes red. The hook keeps working locally. It has simply stopped being shippable as pure metadata, and the build says so in one line nobody reads.
So the failure is invisible at exactly the moment it is cheapest to fix.
What the app pays, today
Because the degradation is silent, the reference app cannot rely on the build to catch it, and instead hand-copies every shared value into every handler body and then writes a bespoke test to pin each copy:
| inlined copy | its pin |
|---|
the priority-rank map, twice (case.hook.ts, task.hook.ts) | test/priority-rank-parity.test.ts |
the 16-cell SLA matrix (case.hook.ts, duplicating _case-sla.ts) | test/case-sla-matrix.test.ts |
the country-to-territory table (account.hook.ts, duplicating _territory.ts) | test/territory-single-source.test.ts — parses the table out of the LOWERED body |
the refuse() refusal envelope, in every guard (duplicating _refusal.ts) | test/refusal-envelope.test.ts |
| the campaign metric recompute, four times | test/campaign-member-lifecycle.test.ts — asserts all four are character-identical |
the position-pool names, twice (_case-assignment.ts) | test/case-assignment.test.ts |
the claimable-status set (_case-assignment.ts) | test/unassigned-case-triage-reach.test.ts |
| the reference-cleanup shape predicate, three times | test/freeze-guard-reference-cleanup.test.ts |
Every row is the same shape: a constant declared once, stored N times, and a test whose only job is to notice when the copies drift. That is a platform gap being paid for in app-side test infrastructure.
And the app has to say so every time, because the alternative reads as gratuitous duplication to the next author. The comment mass is a direct function of the missing affordance.
What would make the mistake impossible
Two asks, either of which retires most of the prose. They are independent.
Fail loudly instead of degrading. The build should refuse — or at minimum emit a lint diagnostic that a gate can fail on — when a registered hook's handler cannot be lowered. Silently swapping a metadata hook for a bundled closure changes the deployment shape of the app; that is not a warning-in-a-log event. (extractHookBody already computes the exact answer; only the catch site drops it.)
Give a hook body a declared constant surface. Anything that lets a handler name a value the platform ships into the sandbox with it — a constants: key on the hook whose object is serialised into the lowered body, or an author-time inliner that resolves a whitelisted module-scope const at lowering time — collapses every row of the table above into one declaration and deletes its pin.
Ask 1 alone turns a silent class into a compile-time one, which is the bigger win. Ask 2 removes the duplication that ask 1 makes visible.
Not asking for
Reaching module scope at runtime. The body-only sandbox is a deliberate boundary and the app is not trying to cross it — the ask is that the boundary be enforced at author time instead of discovered at review time, and that a legitimate constant not have to be smuggled across it by hand.
Refs objectstack-ai/hotcrm#1184.
Filed from the reference app while slimming
src/objects/comments (objectstack-ai/hotcrm#1184). This constraint is written out, by hand, in twelve files of that one directory. It is the single most-repeated platform note in the app, which is the signal that it wants to be a platform affordance rather than prose.The constraint, as app authors have to state it
An L2 hook handler is lowered to a metadata-only
body.sourceand evaluated inside QuickJS with no module scope. A handler body that references an import, a top-levelconst, or even the factory function's own parameter is aReferenceErrorat runtime — not a closure.That much is documented and defensible. The part that costs is what happens when an author gets it wrong:
extractHookBodythrows, naming the free identifiers:So the failure is invisible at exactly the moment it is cheapest to fix.
What the app pays, today
Because the degradation is silent, the reference app cannot rely on the build to catch it, and instead hand-copies every shared value into every handler body and then writes a bespoke test to pin each copy:
case.hook.ts,task.hook.ts)test/priority-rank-parity.test.tscase.hook.ts, duplicating_case-sla.ts)test/case-sla-matrix.test.tsaccount.hook.ts, duplicating_territory.ts)test/territory-single-source.test.ts— parses the table out of the LOWERED bodyrefuse()refusal envelope, in every guard (duplicating_refusal.ts)test/refusal-envelope.test.tstest/campaign-member-lifecycle.test.ts— asserts all four are character-identical_case-assignment.ts)test/case-assignment.test.ts_case-assignment.ts)test/unassigned-case-triage-reach.test.tstest/freeze-guard-reference-cleanup.test.tsEvery row is the same shape: a constant declared once, stored N times, and a test whose only job is to notice when the copies drift. That is a platform gap being paid for in app-side test infrastructure.
And the app has to say so every time, because the alternative reads as gratuitous duplication to the next author. The comment mass is a direct function of the missing affordance.
What would make the mistake impossible
Two asks, either of which retires most of the prose. They are independent.
Fail loudly instead of degrading. The build should refuse — or at minimum emit a
lintdiagnostic that a gate can fail on — when a registered hook's handler cannot be lowered. Silently swapping a metadata hook for a bundled closure changes the deployment shape of the app; that is not a warning-in-a-log event. (extractHookBodyalready computes the exact answer; only the catch site drops it.)Give a hook body a declared constant surface. Anything that lets a handler name a value the platform ships into the sandbox with it — a
constants:key on the hook whose object is serialised into the lowered body, or an author-time inliner that resolves a whitelisted module-scopeconstat lowering time — collapses every row of the table above into one declaration and deletes its pin.Ask 1 alone turns a silent class into a compile-time one, which is the bigger win. Ask 2 removes the duplication that ask 1 makes visible.
Not asking for
Reaching module scope at runtime. The body-only sandbox is a deliberate boundary and the app is not trying to cross it — the ask is that the boundary be enforced at author time instead of discovered at review time, and that a legitimate constant not have to be smuggled across it by hand.
Refs objectstack-ai/hotcrm#1184.