Repository navigation
Commit 98eb3b9
Fixes #21604
Clause-②: yes (narrowing)
Executes the maintainer's ruling on #21604 (comment 5974477722, letter
B, 「同意」 2026-10-03T23:11Z): **a hook's `handler` name resolves inside
the hook's own package only.** The functions the package's own runtime
module registers keep resolving; a name the package does not hold is
refused at registration, with a refusal that names it;
`HookSchema.handler`'s declaration changes in the same PR. #21585's
landed install-local refusal (#21615, `045b946256`) is untouched: no
file of `packages/runtime` or `packages/cloud-connection` source changes
here.
## Census first (the ruling's first step): zero dependents
Every composition that could rely on cross-package resolution by name,
read before any refusal was written:
| Composition | Tree | What was read | Dependents |
|:--|:--|:--|:--|
| objectstack `examples/**` | objectstack `15fe567c9c` | string
`handler:` values, `registerFunction` calls, `functions:` declarations,
`composeStacks`, each app's hook forms | **0.** The only string
`handler` is a job's (app-showcase `sweepProjectHealth`), which the job
half resolves against its own bundle. app-showcase's 5 hooks all carry
`body`; app-crm and app-todo each have 1 inline-function hook, which `os
build` lowers to the hook's own name inside that app's own runtime
module (same owner). app-multi-package and embed-objectql declare no
hooks or functions. |
| this repository's `--artifact` runtime modules | objectstack
`15fe567c9c` | tracked `objectstack-runtime*.mjs`; tracked non-TS files
naming `runtimeModule` | **0 committed.** A runtime module is a build
output of an app's own config. |
| hotcrm | `f24c196588` | the same greps | **0.** No string `handler:`,
no `registerFunction`. Its 19 hook files are inline functions inside one
`composeStacks` app (one owner), each lowered to its own name. |
| objectos | `7612ffebd1` | the same greps | **0.** No hit for any of
them. |
| cloud | none | | **NOT MEASURED.** Unreachable from this session: a
shallow clone has no credentials, a REST read answers 403 "not enabled
for this session", and `add_repo` answers no access. |
No stop condition fired: no real dependent was found, and owner-scoped
resolution needed no new authorable spelling (see boundary flag 9).
## What changed, and where
- `packages/objectql/src/hook-binder.ts`: `resolveHandler` resolves a
string `handler` against the functions handed to the bind (the package's
`functions`, which an `--artifact` runtime module supplies), then
against the engine entry of that name **only if the entry's `packageId`
equals the bind's `packageId`** (`ownPackageFunction`, reading the owner
through the existing `resolveFunctionEntry`). A string handler that
resolves to neither is refused at registration: an `Error` carrying
`code: 'INVALID_REFERENCE'`, `status: 400`, `hook`, `handler` and
`packageId`, recorded on `BindHooksResult.errors[]` (which gains
optional `code` and `status`), logged at `error` with the `Error` in the
logger's error slot, and thrown under `strict`. The hook is not bound.
- `packages/objectql/src/engine.ts`: doc comments only (the registry and
`registerFunction`). The lookup itself is unchanged: the entry already
carried its owner.
- `packages/spec/src/data/hook.zod.ts`: `HookSchema.handler`'s TSDoc
stops declaring the engine-wide fallback ("anything
`engine.registerFunction(name, fn)` added") and states the own-package
rule, the refusal, and the route for a runtime-authored hook. The schema
and its `.describe()` are unchanged, so no generated artifact moves
(`check:generated`: all 15 up to date).
- The landing matches the dispatch's expected surface; no producer
elsewhere needed the fix.
## ① The accept set, before and after
A hook whose `handler` is a function name and which has no `body` (a
hook with a `body` binds exactly as before, body first):
| Door | Before | After |
|:--|:--|:--|
| Boot of a code package (`AppPlugin`, a `defineStack` config, `os start
--artifact`) | its own `functions` (runtime module included), then any
function any package registered | its own `functions` (runtime module
included), then functions its own package registered earlier; **another
app's function is refused** |
| Install-local (`os package install`) | handler-only hooks already
refused at install and withheld on rehydrate | unchanged |
| Metadata door (`PUT /api/v1/meta/hook/NAME`, bound under owner
`metadata-service`) | any function any package registered | **none**:
the owner registers no functions, so every handler-only authored hook is
refused when the door binds it |
| Multi-app composition (several apps on one engine) | app Y's hook
could bind app X's function | **refused** |
| Direct `bindHooksToEngine` with no `packageId` | any engine function |
only the functions handed to that bind |
| A name no package holds (typo) | skipped, `warn`, reason `unknown
function 'NAME'` | refused: same envelope as above, `error` |
## ② Semver
`minor` for `@objectstack/objectql` and `@objectstack/spec`,
**BREAKING**, `!` in the title, `Clause-②: yes (narrowing)`, under the
launch-window convention for narrowings of an accept set. The changeset
(`.changeset/21604-hook-handler-package-scope.md`) carries the ADR-0087
disposition `not-required (no-migration-prescription)`, written from the
census facts above: no authorable key, spelling, export of a published
release or stored shape moves, so `objectstack migrate meta` has nothing
to rewrite. (The marker sits in the changeset as the gate's comment-form
marker; `check-adr-0087-registration --base origin/main` reads it
green.)
## ③ Boundary flags
1. **Log level and text.** An unresolved string handler used to log
`warn` with reason `unknown function 'NAME'`; it now logs `error` with
the coded refusal's sentence, beside the binder's other coded
registration refusal (the stored-metadata body boundary, also logged at
`error` by this binder). The ruling asks for a loud refusal at
registration.
2. **Result type.** `BindHooksResult.errors[]` gains optional `code` and
`status` (additive output). `HOOK_HANDLER_NOT_IN_PACKAGE_CODE` and
`HOOK_HANDLER_NOT_IN_PACKAGE_STATUS` are exported from `hook-binder.ts`
only; neither `index.ts` nor `core.ts` re-exports them, so the package's
public entry gains no symbol.
3. **The envelope (H4).** No coded refusal existed for this condition
(the binder's unresolved branch carried a bare reason string).
`INVALID_REFERENCE` / 400 is the standard catalog's member for a
reference that does not resolve where it must. The ledger's admission
rule sends a generic condition to the standard catalog, so no code is
registered; `plugin-auth` already answers `INVALID_REFERENCE` for both a
missing and a cross-scope reference. The sibling registration refusal's
`PERMISSION_DENIED` / 403 was not reused: that refusal is about a
permission on a table; this one is about a name that does not resolve,
and a typo is no permission question.
4. **`strict`** (`OBJECTQL_STRICT_HOOKS=1`) throws the refusal. A strict
runtime whose hook bound across packages now fails that bind, exactly as
it already failed an unknown name.
5. **The metadata door (H2).** A runtime-authored hook is bound under
the synthetic owner `metadata-service`, which registers no function, so
a handler-only authored hook is always refused at bind. The save itself
still answers as before: the pin records `200` for that `PUT`. That is
the same posture as the stored-metadata body boundary, which refuses at
bind and leaves the save door's answer unchanged. A `sys_metadata` hook
row stamped with a `package_id` (the Studio package authoring workspace)
is still bound under `metadata-service`, so it does not reach that
package's runtime-module functions; before, it reached every function.
Measured pull: zero string handlers anywhere in the census. Resolving by
a row's own `package_id` would let any metadata author claim a package's
code, which is the channel the ruling closes.
6. **A bind that names no package (H2).** With no `packageId`, a hook
resolves only the functions handed to that bind; an engine entry
registered without an owner is resolvable by no hook. Measured: every
first-party door stamps an owner (`app:APPID`, `metadata-service`,
`sys:audit`). After a platform boot (ObjectQL, sqlite-wasm, Hono, one
app, platform objects, auth, security, sharing, REST, dispatcher), the
engine's function registry holds exactly one entry, the app's own
(`h3_fn`, owner `app:com.h3.probe`). I read "a name the package does not
hold is refused" as covering a bind with no package; the reviewer may
weigh that reading.
7. **The platform's own functions (H3).** None reach the engine
registry. The formula stdlib's `registerFunction` registers into a
`cel-js` `Environment`, not the engine
(`packages/formula/src/stdlib.ts`). So no platform function's resolution
changes; H3's "formula stdlib" leg is falsified.
8. **One artifact, one owner.** A multi-package artifact (`packages[]`,
`composeStacks`) is bound under one owner `app:APPID`, with its
functions flattened, so a hook in one composed package can still name a
sibling package's function inside the same artifact. Census:
app-multi-package declares no hooks or functions, and hotcrm's
composition lowers each hook to its own name. Scoping inside one
artifact would need per-package attribution in the bundle collectors,
beyond "only as far as owner-scoped resolution needs it".
9. **No new spelling.** "Cross-package reuse must name the owning
package explicitly" is met by an existing spelling: import the function
from the package that owns it and declare it in your own `functions`.
The refusal and the changeset prescribe exactly that, and no `pkg/fn` or
`{ package, name }` form was minted.
10. **Install-local** is untouched. Its CLI integration pin
(`packages/cli/test/package-install-local-hooks.integration.test.ts`,
whose host hook names its own runtime-module function) is in the CLI
integration tier and is declared to CI; this diff touches no CLI file.
## Pins
- `packages/objectql/src/hook-binder-package-scope.test.ts`. Refusals,
each asserting `code` `INVALID_REFERENCE`, `status` 400 and that the
hook did not bind (the other package's function never runs): another
package's function; the same under `strict` (thrown, with `hook`,
`handler` and `packageId`); a name nobody holds; the metadata-door
owner, read off the engine logger's `error` call; a bind with no package
naming an unowned entry. Controls: a function handed to the hook's own
bind; a function its own package registered in an earlier bind.
- `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, a
composed kernel. ① Multi-app composition: app Y's hook naming app X's
`x_stamp` is refused, and Y's insert is not stamped by X. ② Metadata
door: `PUT /api/v1/meta/hook/scope_authored_cross` naming `x_stamp` is
refused when the door binds it, while an authored `body` hook (the
re-sync witness) fires. Controls: X's own hook binds and runs; app Z,
loaded through `loadArtifactBundle` from an artifact whose runtime
module exports `z_stamp`, binds and runs.
- Re-triaged fixtures in `hook-binder.test.ts`: the two cases that
pinned the text `unknown function` (the refused branch) now assert the
envelope.
## Reverse verification (committed first, at `1eb671bac6`)
The owner check was ablated through `scripts/ablation-replace.mjs` in
WRAP mode, with an absolute-path `git checkout HEAD -- PATH` trap. The
ablated `ownPackageFunction` resolves any entry by name, which is the
old fallback. On-disk proof: anchor 1 → 0, replacement 0 → 1, blob
`9301e0130c` → `49bf4c96cc`. `pnpm --filter @objectstack/objectql build`
exited 0, and `ablation-dist-preflight` found the marker in all 4 JS
files the runtime suite consumes.
- objectql pins: **4 red** (another package's function, `strict`,
metadata-door owner, unowned bind) and **30 green** (the typo refusal,
both controls, the existing binder suite).
- runtime composed pin: **2 red**, with the defect itself as the reason:
Y's insert came back `|x-fn`, and the authored row came back
`|x-fn|authored-body|x-fn`. **2 controls green.**
- Restore: blob back to the `HEAD` blob `9301e0130c`, `git diff HEAD`
empty, whole-tree `git status --porcelain` empty. After the rebuild, the
marker is absent from all 14 `dist/` files and the pins are green again
(34/34 and 4/4).
- A first ablation run read the same red and green split, but its DTS
step failed on the then-unused `packageId` parameter (the JS bundles
still carried the marker). It was rerun with `void packageId;` so the
build leg exits 0, and the figures above are from that clean run.
## Tests
Suites at `1eb671bac6`; the later merges of `origin/main` (`b43c6fe76f`,
`308ae946b9`) bring only service-analytics and CLI files, with no
overlap. Build order: `turbo build --filter='@objectstack/runtime^...'`,
then `--filter='@objectstack/dogfood^...' --filter=@objectstack/rest
--filter=@objectstack/service-automation`, after the objectql change.
- `@objectstack/objectql`: `local` project 370 files / 7441 passed;
`repo` 1 / 5 passed; `typecheck` green (test layer within its pinned
debt).
- `@objectstack/runtime` (reads objectql's `dist/`): `local` 319 files /
4534 passed, 19 skipped; `repo` 3 / 751 passed; `typecheck` green.
- `@objectstack/rest`: `local` 260 files / 4897 passed, 326 skipped;
`repo` 5 / 177 passed, 1 skipped.
- `@objectstack/service-automation`: 168 files / 2078 passed.
- dogfood hook files (`hook-error-format`,
`hook-refusal-user-facing-marking`, `hook-runas-fls`,
`webhook-materialization`): 4 files / 13 passed.
- `@objectstack/spec`: `check:generated`, all 15 artifacts up to date
against a `dist/` whose declaration stamp matches.
Direction: these are downstream consumers of objectql (runtime, rest,
service-automation, dogfood); the spec edit is TSDoc only.
## Gates (at `308ae946b9`)
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, with no paths, derived 91 commands. That is
the dispatch list plus `check-empty-changeset` (both),
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:engine-double-contract`,
`check:objectql-double-limit`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census`, `check:query-options-erasure`,
`check:stack-collection-maps`, `check:swallow-census-controls`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. All 91 ran, each exit code captured before any
pipe, and all 91 exited 0. `--ran` reconciliation: 91 derived, 91 run, 0
NOT-MEASURED, 0 UNRUN. On the first pass, `check:dual-build-cjs-loads`
answered PREREQUISITE NOT MET: 8 packages unrelated to this diff had no
`dist/` in this worktree. Those were built, and it measured green.
Lint, narrowed and proven: `eslint --no-inline-config --format json`
over the 6 touched TS files reports 6 files linted, 0 errors and 0
warnings. That covers every TS file in the diff under the config's
`**/*.ts` and `packages/**` globs. `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, no typed rules), so the
diff cannot move any untouched file's verdict. The repo-wide `pnpm lint`
is CI's.
## NOT MEASURED
- The cloud census: unreachable, as above.
- CI-only families the derivation names, which have no local invocation:
Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build
Core, Temporal Conformance, and the workspace type-check lanes.
- The CLI integration tier: declared to CI.
- `check:objectui-pin-citations`: its self-test's live objectui round
trip was skipped, because there is no objectui checkout here; the gate
itself passed.
## Acceptance notes (observed, not filed)
- `Action.target` and a flow `script` node's `config.function` still
resolve through the engine's function registry by bare name
(`service-automation` bridges `objectql.resolveFunction`). The ruling
covers a hook's `handler` only. This is the same family on other
surfaces, recorded from a code-read with no measured reach.
- The registry stays keyed by bare name: two packages registering one
name leave the later one's entry. A hook bound in the same call resolves
its own bundle first, so boot binding is unaffected.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent be55fd2 commit 98eb3b9
8 files changed
Lines changed: 606 additions & 24 deletions
File tree
- .changeset
- packages
- objectql/src
- runtime/src
- spec/src/data
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3657 | 3657 | | |
3658 | 3658 | | |
3659 | 3659 | | |
3660 | | - | |
3661 | | - | |
3662 | | - | |
3663 | | - | |
| 3660 | + | |
| 3661 | + | |
| 3662 | + | |
| 3663 | + | |
| 3664 | + | |
3664 | 3665 | | |
3665 | 3666 | | |
3666 | 3667 | | |
| |||
3867 | 3868 | | |
3868 | 3869 | | |
3869 | 3870 | | |
3870 | | - | |
| 3871 | + | |
| 3872 | + | |
3871 | 3873 | | |
3872 | 3874 | | |
3873 | 3875 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
65 | | - | |
| 65 | + | |
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | | - | |
| 77 | + | |
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
167 | | - | |
| 167 | + | |
168 | 168 | | |
169 | 169 | | |
170 | 170 | | |
| |||
0 commit comments