Skip to content

fix(objectql): reject wire-only alias spellings (sort/select/skip/populate) on direct engine calls - #4387

Merged
os-zhuang merged 1 commit into
mainfrom
claude/engine-query-contract-enforcement-b44637
Jul 31, 2026
Merged

fix(objectql): reject wire-only alias spellings (sort/select/skip/populate) on direct engine calls#4387
os-zhuang merged 1 commit into
mainfrom
claude/engine-query-contract-enforcement-b44637

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Implements option (1) of #4371 — the small, exact fix that closes the reachable hole. Refs #4371 (kept open for option (2): unknown-key enforcement, which needs its own caller survey).

The bug

#4346 made the engine fold filterwhere and toplimit on all six methods. The other four pairs in RPC_QUERY_ALIAS_SLOTSselectfields, sortorderBy, skipoffset, populateexpand — fold at the RPC/protocol layer only, because their value shapes need lowering (sort's {field: 'asc'} record form, populate's name list) that belongs there. A direct engine.find() never crosses that layer: the key rode the AST verbatim, drivers read only the canonical name, and the request succeeded with the parameter silently discarded — sort + limit ("the latest N") returning an arbitrary N. Three shipped instances were fixed in #4370.

The fix

find/findOne now reject a non-null wire-only spelling:

find('task') does not accept 'sort': 'sort' is a wire spelling of 'orderBy', folded by the RPC/protocol layer — a direct engine call bypasses that fold, so the value would be silently dropped, not applied. Pass 'orderBy' (SortNode[]: [{ field, order: 'asc' | 'desc' }]) instead.

The fourth in-repo instance, found in the engine's own house

seedAutonumber passed select: ['id', field] — the projection never applied (the scan worked only because an unprojected row still carries the field). Worse, its catch { return 0 } would have swallowed the new rejection into "seed from 0", i.e. duplicate autonumbers. Now passes fields.

Call-site survey (why this is safe to throw)

Balanced-paren scan of every .find(/.findOne( argument text across this repo and the cloud repo, plus the indirect channels (ObjectRepository/ScopedContext, sandbox body facade, plugin-auth adapter, settings/CLI engine wrappers, metadata database-loader, flow get_records executor): zero remaining callers pass these keys to a direct engine call. Every wire ingress (REST, RPC, http-dispatcher) folds at the protocol layer. Driver-contract tests asserting driver.find ignores non-QueryAST keys are a different layer and untouched.

Survey leftovers filed/noted: #4386 (pre-existing executeDataAction query-fallback bug found while surveying); expand-nested alias keys (expand: {rel: {sort}}) are still silently dropped — noted on #4371 for option (2)'s scoping.

Tests

12 new pins in engine-wire-alias-reject.test.ts: each of the four spellings refused on find (driver untouched), findOne({sort}) refused, SortNode[]-shaped sort still refused (the KEY is the contract), multi-alias single report, canonical bag reaches the driver, null withdrawal, {filter, top} fold unregressed, and wire sort/select/skip through the real protocol layer folding to canonical and passing the guard.

Local: objectql 1462, runtime 995, rest 539, cli 629, client 204, metadata-protocol 136, plugin-sharing 226, plugin-approvals 330, plugin-dev 24, plugin-pinyin-search 14, verify 7 — all green; objectql typecheck clean.

🤖 Generated with Claude Code

…e now throws instead of silently dropping it (#4371)

The engine folds filter→where and top→limit itself (#4346); the other four
pairs in RPC_QUERY_ALIAS_SLOTS fold at the RPC/protocol layer only, because
their value shapes need lowering that belongs there. A direct engine.find()
never crosses that layer, so the alias key rode the AST verbatim, drivers
read only the canonical name, and the request succeeded with the parameter
silently discarded — three shipped instances fixed in #4370, and a fourth
in the engine's own seedAutonumber (select — its projection never applied,
and the surrounding catch would have swallowed the new rejection into
'seed from 0', i.e. duplicate autonumbers; now passes fields).

find/findOne now reject a non-null wire-only spelling with an error naming
the canonical key, the layer that owns the fold, and the target shape.
Folding them here instead would re-implement the RPC layer's shape lowering
per reader — the #3795 condition — so rejecting keeps one fold, one owner.
The where-only methods (update/delete/count/aggregate) are deliberately
unchanged: their contracts honour no sort/projection/pagination in either
spelling, so 'pass orderBy instead' would redirect to a key they silently
ignore too — that is #4371 option (2), scoped separately.

Call-site survey (this repo + cloud): zero direct callers pass these keys
today; every wire ingress folds at the protocol layer (pinned end-to-end).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 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 Jul 31, 2026 10:19am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

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.

@os-zhuang
os-zhuang merged commit c39d713 into main Jul 31, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/engine-query-contract-enforcement-b44637 branch July 31, 2026 10:33
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…ys throw instead of silently doing nothing (#4400)

* docs(spec): driver.find example shows canonical QueryAST keys, not wire spellings

The @example taught filters/sort/top — keys a driver can never receive:
the engine folds/rejects wire spellings before this layer (#4346, #4371),
and QuerySchema declares where/orderBy/limit. Also dedupes the doubled
@returns tag. Comment-only; no generated artifact reads @example (all 8
gates verified up to date).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(objectql,spec,metadata-protocol,service-queue): engine option bags are a closed contract — unknown keys throw (#4371 option 2)

All six engine methods now reject non-null option keys they do not execute,
naming the legal set per method; cursor/distinct quote their #4286 tombstone;
null stays a withdrawal. Legal sets = schema keys + documented extras
(searchFields — now DECLARED on EngineQueryOptionsSchema, it was read and
sent all along; onFieldsDropped on update; driver pass-through keys on the
methods whose bag reaches driver options — count/aggregate forward nothing,
so they reject those too). A drift pin holds the sets equal to the schemas.

Same sweep also closes what the option-2 caller survey turned up:
- bag object no longer overrides the resolved AST object ({...query, object}
  order fix); protocol refuses a contradicting POST-body object (400
  QUERY_OBJECT_MISMATCH) and strips its own vocabulary (object/count/
  tombstones/non-aggregate having) off the engine bag
- nested expand ASTs reject the four wire-only spellings like the top level
- $search/$searchFields engine reads removed (protocol normalizes to bare)
- DbQueueAdapter purge/purgeFailed passed {id} — purge deleted NOTHING
  (throw swallowed into warn), purgeFailed always threw; both now pass
  {where: {id}}, and the test fake stops accepting the signature the real
  engine rejects
- 19 test bags carrying the dead 'filters' key cleaned up (two were
  intent-carrying: a locale multi-update and an oidc dogfood assert that
  both silently scanned unfiltered)
- driver.find JSDoc example shows canonical QueryAST keys

Closes #4371 (option 1 landed in #4387).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant