Skip to content

fix(objectql)!: registerHook refuses an empty object target and a self-cancelling scope (#6573) - #7210

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6573-registerhook-refusal
Aug 10, 2026
Merged

fix(objectql)!: registerHook refuses an empty object target and a self-cancelling scope (#6573)#7210
os-zhuang merged 3 commits into
mainfrom
claude/issue-6573-registerhook-refusal

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #6573.

The defect

#4281 ("an empty target is not no target") was closed at the two metadata doors — HookSchema.object's refine in packages/spec, and normalizeObjects in hook-binder.ts. engine.registerHook — the code door — goes through neither, so all three spellings still walked in, and the matching read turns each into a defect:

shape today why
object: '' registers a GLOBAL hook '' is falsy, so the allow half is skipped entirely — #4281's headline failure mode (blank intent becoming the broadest possible blast radius), reproduced verbatim on the path that ruling never reached
object: [] registers, never fires truthy but admits no object name — ADR-0078 silently inert declaration
object: [''] registers, never fires same, via a name nothing is called

#5928's exclusion face added a fourth, reached by arithmetic rather than by one bad name:

shape today why
{ object: 'account', excludeObjects: 'account' } registers, never fires the allow face is fully cancelled by the exclusion face. #5928 named only '' / [''] / '*'-in-excludes, so this fell outside its letter and was deliberately left

The fix, and the route deliberately not taken

All four are refused at registration, reusing #4281's wording. The matching read of object: '' is left unchanged on purpose: teaching hookMatchesObject that '' is an unmatchable name would silently turn a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is exactly why #5928 declined to do it in passing. A throw at the door changes no dispatch and leaves nothing to misread.

Only a finite allow face can be decided for cancellation. object: '*' and an absent object admit an open universe — applyObjectRegistryMutation registers objects into a running engine — so no enumerated exclusion list can prove them inert, and the one exclusion that would ('*') is already refused by #5928. Partial cancellation ({ object: ['a','b'], excludeObjects: ['b'] }) is untouched — that is what the exclusion face is for.

Note the deliberate asymmetry with assertValidHookExcludeObjects, which accepts []: on the subtract face an empty list is the honest spelling of "subtract nothing"; on the allow face it says "admit nothing", i.e. a hook that can never fire. [] is also named in #4281's own message, so accepting it here would contradict the ruling this reuses.

⚠️ Correction to the card's premise: this is reachable, not merely latent

The card grades this observation-level on the measured claim that every registerHook caller passes literal constants, so a refusal has zero impact today. That claim is wrong. A full sweep of all 60+ non-test call sites found three that forward a non-literal object:

  • packages/triggers/trigger-record-change/src/record-change-trigger.ts:253object: binding.object, which service-automation's resolveFlowTrigger fills from a flow start node's config.objectName, forwarded verbatim (typeof config.objectName === 'string' ? config.objectName : undefined). A flow authored with objectName: '' therefore reaches registerHook with '' — and today binds a record-change trigger to every object in the tenant. This is authored metadata, not a code constant.
  • packages/objectql/src/engine.ts:8909ObjectQL.create({ hooks }) forwards each hook.object through the on() alias, unvalidated.
  • packages/objectql/src/hook-binder.ts:226 — computed, but provably safe: normalizeObjects filters blank members and the caller skips a hook whose target list is empty.

So the correct grade is reachable via authored flow metadata, which strengthens rather than weakens the disposition: there is a live blast-radius bug behind this door. The consequence of the refusal on that path is good and contained — activateFlowTrigger already wraps trigger.start() in a per-flow try/catch that warns and leaves the flow unbound, and the kernel:bootstrapped binding audit re-reports every unbound triggered flow. A flow with a blank objectName goes from silently global to loudly unbound. Flagged for the maintainer since it makes the change a behaviour change for one authored shape rather than the zero-impact one the card assumed.

ADR-0087 disposition — registered, and why the bump stays major

The changeset registers a semantic entry, hook-register-empty-object-target-refused, in the protocol-17 step of the ADR-0087 ledger.

Why registered and not an exemption. Three of the four dispositions are mechanically false here: @objectstack/objectql is published, so unpublished fails outright; no prior id covers this surface, so already-registered fails; and the changeset ships rewrite instructions, so no-migration-prescription would be the self-contradiction that category exists to refuse. Auditing the whole .changeset stock (--list) confirms the convention — every disposed changeset with prescription=yes uses registered.

Why semantic and not a conversion. registerHook is a runtime registration API: no sys_metadata row can carry the shape, so there is nothing for the D2 chain to rewrite. That is precisely the reasoning hook-context-session-roles-retired carries at this same step — a runtime hook surface with no stored form, registered as semantic. The break is also genuinely non-lossless: object: '' could have meant '*' (what it actually did) or a name the author left blank, and those are opposite registrations, so no transform can choose between them. The closest shape precedent is job-retry-policy-constraints-tightened — a tightened constraint that turns previously-accepted values into parse-time failures.

Why the bump stays major. Repo convention is unambiguous: the #4001 campaign shipped "reject what used to be accepted" as **BREAKING** / feat(spec)!: throughout. Two concrete, observable consequences here — an external registerHook({ object: '' }) caller now throws at boot, and a blank-objectName flow stops binding — and @objectstack/objectql is a published package, so the contract is with external consumers, not in-tree callers. The suggestion to consider minor rested on "no in-tree caller changes behaviour", which is the premise disproved above; even were it true, in-tree callers do not bound a published package's semver. (#5928 shipped its three sibling refusals as minor, but its stated reason was that excludeObjects was brand new with no callers — that reason does not transfer to a pre-existing option.)

Tests

packages/objectql/src/hook-exclude-objects.test.ts — 31 pass. Two existing pins updated in the same change, as required:

Reverse-verification (pre-fix engine.ts restored, new tests kept): predicted 9 RED / 22 GREEN, naming each case. Actual 9 failed / 22 passed, the same nine — the 4 allow-face refusals, the 4 cancel-out refusals, and the rewritten object: '' pin. Zero deviations.

Full suites: @objectstack/objectql 2918/2918, @objectstack/spec 9397/9397 (including the 91 migration-chain replays), plugin-audit 143, plugin-sharing 418, plugin-auth 996, service-automation 885, trigger-record-change 56 — all pass.

Gates

All 64 lint.yml gates pass, plus all 15 @objectstack/spec gates re-run after the ledger entry, plus the full Check Changeset job locally (check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-changeset-fixed — each --self-test and --base <merge-base>). Generated artifacts regenerated with gen:spec-changes / gen:upgrade-guide, outside any merge state.

⚠️ This PR now touches packages/spec, so the #6017 cross-seat declaration is a hard ACCEPT prerequisite (#6532 / #5586). Left in draft for that.

Refs #6573, #4281, #4001, #5928, #6148, ADR-0078, ADR-0087.

…elf-cancelling scope (#6573)

#4281 ("an empty target is not *no* target") was closed at the two metadata
doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`.
`engine.registerHook`, the code door, goes through neither, so all three
spellings still walked in:

  `object: ''`   falsy => allow half skipped => registers GLOBAL  (#4281's
                 headline failure mode, on the path it never covered)
  `object: []`   truthy, admits nothing => can never fire         (ADR-0078)
  `object: ['']` same, via a name nothing is called               (ADR-0078)

#5928's exclusion face added a fourth, reached by arithmetic rather than by one
bad name: an allow face fully cancelled by the exclusion face
(`{ object: 'account', excludeObjects: 'account' }`). #5928 named only
`''` / `['']` / `'*'`-in-excludes, so this fell outside its letter and was
deliberately left registering a dead entry.

All four are refused at REGISTRATION, reusing #4281's wording. The matching read
of `object: ''` is deliberately UNCHANGED: teaching `hookMatchesObject` that
`''` is an unmatchable name would silently turn a hook firing on every object
into one firing on none — the same defect pointing the other way, which is why
#5928 declined to do it in passing.

Only a finite allow face can be decided for cancellation. `'*'` and an absent
`object` admit an open universe (objects register into a running engine), so no
enumerated exclusion list can prove them inert; the one exclusion that would —
`'*'` — is already refused by #5928.

`hook-exclude-objects.test.ts`'s pin of `object: ''` as preserved-not-endorsed
is updated in the same change, as is the property matrix's now-unregistrable
fully-cancelled scope.

Refs #6573, #4281, #4001, #5928, ADR-0078

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rfAjYFr66Mjnz89gD5CL5
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 3:58am

Request Review

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 10, 2026
…ep-17 ledger

The #6573 changeset declares a breaking change, so ADR-0087 requires a written
disposition. Three of the four are mechanically false here: `@objectstack/objectql`
is published (not `unpublished`), no prior id covers this surface (not
`already-registered`), and the changeset ships rewrite instructions, so
`no-migration-prescription` would be a self-contradiction the gate refuses.

So it is registered. The entry is `semantic`, not a conversion, for the reason
`hook-context-session-roles-retired` is at the same step: this is a RUNTIME
registration API, so no `sys_metadata` row exists for the D2 chain to rewrite
and the ledger is the notification channel. It is also genuinely non-lossless —
`object: ''` could have meant `'*'` (what it did) or a name the author left
blank, and those are opposite registrations.

One metadata surface reaches the break indirectly and the entry names it: a
record-change flow start node forwards `config.objectName` into `registerHook`
verbatim, so a flow authored with a blank objectName used to bind a trigger to
every object and now fails to bind loudly instead.

Artifacts regenerated (`gen:spec-changes`, `gen:upgrade-guide`).

Refs #6573, #6148, ADR-0087

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rfAjYFr66Mjnz89gD5CL5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[观察] registerHook 的代码注册面没有 #4281 那道校验:object: '' 静默变全局;allow 与 exclude 完全抵消时静默永不触发

2 participants