fix(objectql,spec)!: a hook's handler name resolves inside the hook's own package only (#21604) - #21653
Conversation
…package only The binder resolved a string `handler` against the bundle's functions and then against the engine-wide function registry, keyed by bare name, so a hook could bind to a function another package registered and run that package's code on its own events. Resolution now stays inside the hook's own package: the functions handed to its bind (the package's `functions`, its runtime module's among them), then the entries the same `packageId` registered. A name the package does not hold is refused at registration with the ADR-0112 envelope (INVALID_REFERENCE, 400), recorded on the bind result and logged at error; fatal under strict. HookSchema.handler's doc stops declaring the engine-wide fallback. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…p composition and the metadata door Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
… (minor, Clause-② yes) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…ok-handler-package-scope
…ok-handler-package-scope
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 55ac8c38061b1d8844063c3cfdf506725bd02b78 && git checkout 55ac8c38061b1d8844063c3cfdf506725bd02b78
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eea82af67779f504bd5d53fccda3151066cdeaf6 b8c4e5edc4ec9b4ac87bdea3608d60a8d07c7bdf && git checkout -B drift-repro eea82af67779f504bd5d53fccda3151066cdeaf6 && git merge --no-ff b8c4e5edc4ec9b4ac87bdea3608d60a8d07c7bdf
node scripts/docs-audit/affected-docs.mjs --json eea82af67779f504bd5d53fccda3151066cdeaf6
|
ACCEPT (seat's review) — PR #21653 at head
|
|
CI red on
Generated by Claude Code |
…LID_REFERENCE now has a producer that declares its status Regenerated with `node scripts/check-error-status-conformance.mjs --update` and nothing else. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #21604's body and its three comments (ruling 5974477722, claim 5974619950, os-dev-report 5975213073); PR #21653's body and file list (7 files, +606 / −23); the net diff of the head against origin/main (merge-base 759dbe9); the check-runs on the head, polled at 2026-10-04T01:05:57Z, 01:11:59Z and 01:16:50Z; and, to attribute one red, the gate sources at the head (scripts/check-error-status-conformance.mjs, .github/workflows/lint.yml, .github/workflows/ci.yml). No PR comment was read. Nothing was checked out, built or run. Gate verdicts on this head (final poll 01:16:50Z). Required contexts: The one red that belongs to this diff. ① Derived judgmentsDoors the binder serves at this head (every
Public surface:
Census, re-read on the head tree with ② Semver level
③ Boundary flagsThe ten dev flags in the PR body and the os-dev-report's
Carried for triage, not blocking: On this head's standing. The branch moved to b8c4e5e at 01:14:41Z; its only change over this head is the one-row baseline shrink, which is the right remedy. A ratchet baseline carries no Implemented-by: VERDICT: FAIL — the contract judgments in ①, ② and ③ stand, but this head carries a diff-caused red on the required Adopted by Generated by Claude Code |
Contract reviewServed-tier: Inputs read: card #21604's body and its four comments (ruling 5974477722, claim 5974619950, os-dev-report 5975213073, os-dev-report patch round 1 5975354465); PR #21653's body (unchanged since the previous head; the declaration is its line 2) and file list (8 files, +606 / −24); the net diff of the head against origin/main (merge-base 759dbe9); the check-runs on the head, polled at 2026-10-04T01:49:28Z; the Delta over the previous head, verified from git. One commit, b8c4e5e, parent 308ae94 (no merge of main): Gate verdicts on this head (polled 01:49:28Z). 35 check-runs: 32 success, 3 skipped ( ① Derived judgmentsThe code diff is byte-identical to the previous head's; the judgments below are re-affirmed on this head, with item 12 now resolved. Doors the binder serves (every
Public surface:
Census, re-read on the head tree with ② Semver level
③ Boundary flagsThe ten dev flags in the PR body, the first report's
Carried for triage, not blocking: Implemented-by: VERDICT: PASS Adopted by Generated by Claude Code |
Fixes #21604
Clause-②: yes (narrowing)
Executes the maintainer's ruling on #21604 (comment 5974477722, letter B, 「同意」 2026-10-03T23:11Z): a hook's
handlername 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 ofpackages/runtimeorpackages/cloud-connectionsource 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:
examples/**15fe567c9chandler:values,registerFunctioncalls,functions:declarations,composeStacks, each app's hook formshandleris a job's (app-showcasesweepProjectHealth), which the job half resolves against its own bundle. app-showcase's 5 hooks all carrybody; app-crm and app-todo each have 1 inline-function hook, whichos buildlowers 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.--artifactruntime modules15fe567c9cobjectstack-runtime*.mjs; tracked non-TS files namingruntimeModulef24c196588handler:, noregisterFunction. Its 19 hook files are inline functions inside onecomposeStacksapp (one owner), each lowered to its own name.7612ffebd1add_repoanswers 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:resolveHandlerresolves a stringhandleragainst the functions handed to the bind (the package'sfunctions, which an--artifactruntime module supplies), then against the engine entry of that name only if the entry'spackageIdequals the bind'spackageId(ownPackageFunction, reading the owner through the existingresolveFunctionEntry). A string handler that resolves to neither is refused at registration: anErrorcarryingcode: 'INVALID_REFERENCE',status: 400,hook,handlerandpackageId, recorded onBindHooksResult.errors[](which gains optionalcodeandstatus), logged aterrorwith theErrorin the logger's error slot, and thrown understrict. The hook is not bound.packages/objectql/src/engine.ts: doc comments only (the registry andregisterFunction). 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 ("anythingengine.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 accept set, before and after
A hook whose
handleris a function name and which has nobody(a hook with abodybinds exactly as before, body first):AppPlugin, adefineStackconfig,os start --artifact)functions(runtime module included), then any function any package registeredfunctions(runtime module included), then functions its own package registered earlier; another app's function is refusedos package install)PUT /api/v1/meta/hook/NAME, bound under ownermetadata-service)bindHooksToEnginewith nopackageIdwarn, reasonunknown function 'NAME'error② Semver
minorfor@objectstack/objectqland@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 dispositionnot-required (no-migration-prescription), written from the census facts above: no authorable key, spelling, export of a published release or stored shape moves, soobjectstack migrate metahas nothing to rewrite. (The marker sits in the changeset as the gate's comment-form marker;check-adr-0087-registration --base origin/mainreads it green.)③ Boundary flags
warnwith reasonunknown function 'NAME'; it now logserrorwith the coded refusal's sentence, beside the binder's other coded registration refusal (the stored-metadata body boundary, also logged aterrorby this binder). The ruling asks for a loud refusal at registration.BindHooksResult.errors[]gains optionalcodeandstatus(additive output).HOOK_HANDLER_NOT_IN_PACKAGE_CODEandHOOK_HANDLER_NOT_IN_PACKAGE_STATUSare exported fromhook-binder.tsonly; neitherindex.tsnorcore.tsre-exports them, so the package's public entry gains no symbol.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-authalready answersINVALID_REFERENCEfor both a missing and a cross-scope reference. The sibling registration refusal'sPERMISSION_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.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.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 records200for thatPUT. That is the same posture as the stored-metadata body boundary, which refuses at bind and leaves the save door's answer unchanged. Asys_metadatahook row stamped with apackage_id(the Studio package authoring workspace) is still bound undermetadata-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 ownpackage_idwould let any metadata author claim a package's code, which is the channel the ruling closes.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, ownerapp: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.registerFunctionregisters into acel-jsEnvironment, not the engine (packages/formula/src/stdlib.ts). So no platform function's resolution changes; H3's "formula stdlib" leg is falsified.packages[],composeStacks) is bound under one ownerapp: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".functions. The refusal and the changeset prescribe exactly that, and nopkg/fnor{ package, name }form was minted.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 assertingcodeINVALID_REFERENCE,status400 and that the hook did not bind (the other package's function never runs): another package's function; the same understrict(thrown, withhook,handlerandpackageId); a name nobody holds; the metadata-door owner, read off the engine logger'serrorcall; 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'sx_stampis refused, and Y's insert is not stamped by X. ② Metadata door:PUT /api/v1/meta/hook/scope_authored_crossnamingx_stampis refused when the door binds it, while an authoredbodyhook (the re-sync witness) fires. Controls: X's own hook binds and runs; app Z, loaded throughloadArtifactBundlefrom an artifact whose runtime module exportsz_stamp, binds and runs.hook-binder.test.ts: the two cases that pinned the textunknown function(the refused branch) now assert the envelope.Reverse verification (committed first, at
1eb671bac6)The owner check was ablated through
scripts/ablation-replace.mjsin WRAP mode, with an absolute-pathgit checkout HEAD -- PATHtrap. The ablatedownPackageFunctionresolves any entry by name, which is the old fallback. On-disk proof: anchor 1 → 0, replacement 0 → 1, blob9301e0130c→49bf4c96cc.pnpm --filter @objectstack/objectql buildexited 0, andablation-dist-preflightfound the marker in all 4 JS files the runtime suite consumes.strict, metadata-door owner, unowned bind) and 30 green (the typo refusal, both controls, the existing binder suite).|x-fn, and the authored row came back|x-fn|authored-body|x-fn. 2 controls green.HEADblob9301e0130c,git diff HEADempty, whole-treegit status --porcelainempty. After the rebuild, the marker is absent from all 14dist/files and the pins are green again (34/34 and 4/4).packageIdparameter (the JS bundles still carried the marker). It was rerun withvoid packageId;so the build leg exits 0, and the figures above are from that clean run.Tests
Suites at
1eb671bac6; the later merges oforigin/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:localproject 370 files / 7441 passed;repo1 / 5 passed;typecheckgreen (test layer within its pinned debt).@objectstack/runtime(reads objectql'sdist/):local319 files / 4534 passed, 19 skipped;repo3 / 751 passed;typecheckgreen.@objectstack/rest:local260 files / 4897 passed, 326 skipped;repo5 / 177 passed, 1 skipped.@objectstack/service-automation: 168 files / 2078 passed.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 adist/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 pluscheck-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-debtandcheck:where-matcher. All 91 ran, each exit code captured before any pipe, and all 91 exited 0.--ranreconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass,check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET: 8 packages unrelated to this diff had nodist/in this worktree. Those were built, and it measured green.Lint, narrowed and proven:
eslint --no-inline-config --format jsonover 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**/*.tsandpackages/**globs.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, no typed rules), so the diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.NOT MEASURED
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.targetand a flowscriptnode'sconfig.functionstill resolve through the engine's function registry by bare name (service-automationbridgesobjectql.resolveFunction). The ruling covers a hook'shandleronly. This is the same family on other surfaces, recorded from a code-read with no measured reach.Generated by Claude Code