Skip to content

[P2] A direct engine call silently drops sort/select/skip/populate — declared query contract, zero enforcement #4371

Closed

Description

@os-zhuang

Found while doing the #4346 follow-up sweep of in-repo engine call sites (#4370).

#4346 made ObjectQL fold filter→where and top→limit on all six methods. The other four pairs in RPC_QUERY_ALIAS_SLOTS — select→fields, sort→orderBy, skip→offset, populate→expand — fold at the RPC/wire layer only, because their values need shape lowering (sort's {field: 'asc'} record form, populate's name list → the expand record) that legitimately belongs to those layers.

That is a defensible split. What is not defensible is the failure mode when a direct engine.find() — which never crosses that layer — is handed one of the four:

// packages/objectql/src/engine.ts (find)
const ast: QueryAST = { object, ...query };   // `sort` rides along verbatim

The key lands on the AST untouched, drivers read only the canonical name, and the request succeeds with the parameter silently discarded. Measured against a real engine + recording driver:

>>> AST keys: object,sort,limit
>>> ast.orderBy = undefined
>>> ast.sort    = [{"field":"created_at","order":"asc"}]

This is not hypothetical — it already shipped three times

#4370 fixes three call sites that hit exactly this, all pairing the dropped sort with a limit (the "latest N" shape #4226 called out):

call site asked for got
plugin-sharing/src/share-link-routes.ts shared AI conversation messages, created_at asc arbitrary order
runtime/src/domains/share-links.ts the same route, runtime-domain copy arbitrary order
plugin-sharing/src/share-link-service.ts listLinks the 200 most recent share links an arbitrary 200

Nothing caught them: EngineQueryOptions does not declare sort, but every engine facade types its options bag any (and the sites carried as any), so TS said nothing; the engine ignored the key; the drivers ignored the key; the responses were ordinary.

This is the #4226 diagnosis one layer down. #4226's own words on why an unapplied sort is the worst kind of silent failure — "the rows are all there and all real — which is exactly why nobody notices" — apply verbatim here, except #4226 fixed the normalizer and these calls sit below it.

The gap in one line

The engine declares a query contract (EngineQueryOptionsSchema) and then enforces none of it: an undeclared key is neither folded, nor rejected, nor logged. That is the declared ≠ enforced shape of AGENTS.md PD #10, and the remedy the repo has used before (#1475, #3106) is to make the runtime answer for what the contract states.

Suggested fix

Make an engine option bag that carries a key the engine cannot honour fail loudly instead of silently dropping it. Roughly, in the shared entry-point helper #4346 already added (foldEngineOptionAliases):

  1. Reject the four wire-only aliases at the engine boundary with a message naming the canonical key and the layer that folds it — e.g. sort → "sort is a wire spelling folded by the protocol layer; a direct engine call must pass orderBy (SortNode[])." Cheap, exact, and it converts every future instance of this bug into an immediate throw. Note the shape lowering is the reason NOT to fold them here; rejecting costs nothing and keeps the layering.
  2. Consider rejecting genuinely unknown keys too (anything outside QUERY_AST_KEYS + the engine's own option names). This is the stronger version and matches what metadata-protocol already does at the wire boundary (UNSUPPORTED_QUERY_PARAM, app-showcase 权限模型复测清单(07-15 对照 main 重评):1 项回归级重大缺陷(#2930 锚点写护栏被 A4 词表打穿)+ 2 项部分修复 + 3 项未修 + 1 项建议降级 + 2 项观察 #2926/REST 列表:未知查询参数被静默当作字段过滤器 —— ?pageSize=5 返回 200 + 空列表 #4134) — the engine is the one ingress with no such gate. Needs a survey of internal callers first; several pass extra bookkeeping keys today.
  3. Pin each: {sort} alone on a direct engine call is refused; {orderBy} works; the RPC/wire path still folds sort as it does now (the fold must NOT move).

(1) alone closes the reachable hole and is small. (2) is the complete answer and deserves its own scoping.

Why not "just fold them at the engine too"

Tempting, but it makes the engine a second place that lowers sort's record spelling and populate's list spelling into canonical shapes — the exact per-reader re-implementation #3795 existed to delete, and the reason #4346 deliberately scoped the engine fold to the two slots that need no lowering. Rejecting keeps one fold, one owner.

Refs #4346, #4370, #4226, #3795, #3713, ADR-0049, AGENTS.md PD #10 / #12.

Activity

  1. self-assigned this
    on Jul 31, 2026
  2. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    认领:正在按方案 (1) 实现 —— 在 engine 边界拒绝四个 wire-only 别名拼写(find/findOne),pin 测试含 RPC fold 不动。分支 claude/engine-query-contract-enforcement-b44637。

  3. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    方案 (1) 已在 PR #4387 落地:find/findOne 对非 null 的 sort/select/skip/populate 抛错,点名 canonical 键、折叠归属层与目标形状;ENGINE_WIRE_ONLY_SLOTS 从 RPC_QUERY_ALIAS_SLOTS 按补集派生(表仍是唯一声明处)。wire 路径 pin 了真 protocol→真 engine 全链,fold 未动。

    普查(本仓 + cloud 仓,平衡括号扫描 + 间接通道逐一核对)结论:仓内零直调命中——唯一的第四实例是引擎自己的 seedAutonumber(select 投影从未生效,且外层 catch { return 0 } 会把新护栏吞成静默重号),已随 PR 改为 fields。

    留给方案 (2) scoping 时一并考虑的两个残留面:

    1. expand 嵌套子 AST 不在护栏内:expand: { owner: { sort: [...] } } 的子层方言键仍被静默丢弃(引擎的 expand 递归只读子 AST 的 canonical 键,护栏只查顶层 bag)。协议路径上 QuerySchema 声明的是 canonical 键,但直调路径的嵌套方言无人拦。
    2. driver.zod.ts 的 JSDoc 示例过时(driver.find 示例写着 filters/sort/top——driver 契约收的是 QueryAST canonical 键),文档尾巴,改动会触发 spec 生成物 regen,不值得搭在 fix(objectql): reject wire-only alias spellings (sort/select/skip/populate) on direct engine calls #4387 上。

    普查顺带发现的既有 bug 已单独立案:#4386(executeDataAction 的 query 兜底分支把整个查询丢弃返回全表)。

  4. added a commit that references this issue on Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions