Repository navigation
Description
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 verbatimThe 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):
- 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→ "sortis a wire spelling folded by the protocol layer; a direct engine call must passorderBy(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. - Consider rejecting genuinely unknown keys too (anything outside
QUERY_AST_KEYS+ the engine's own option names). This is the stronger version and matches whatmetadata-protocolalready 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. - Pin each:
{sort}alone on a direct engine call is refused;{orderBy}works; the RPC/wire path still foldssortas 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
os-zhuang commentedon Jul 31, 2026
认领:正在按方案 (1) 实现 —— 在 engine 边界拒绝四个 wire-only 别名拼写(find/findOne),pin 测试含 RPC fold 不动。分支 claude/engine-query-contract-enforcement-b44637。
os-zhuang commentedon Jul 31, 2026
方案 (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 时一并考虑的两个残留面:
expand: { owner: { sort: [...] } }的子层方言键仍被静默丢弃(引擎的 expand 递归只读子 AST 的 canonical 键,护栏只查顶层 bag)。协议路径上QuerySchema声明的是 canonical 键,但直调路径的嵌套方言无人拦。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 兜底分支把整个查询丢弃返回全表)。fix(objectql): a direct engine call carrying sort/select/skip/populat…
fix(runtime): callData('query') fallback serves the caller's query in…
feat(objectql): engine option bags are a closed contract — unknown ke…
test(service-queue): keep #4371's top-level `{ id }` rejection explic…
test(service-queue): pin db-queue-adapter's engine double to ObjectQL…
docs(data-engine): add searchFields to EngineQueryOptions block (obje…
test(objectql): move the three level readers the full suite found (#1…
fix(objectql): report a refused write at `warn`, not `error` — the ca…
fix(runtime): refuse the withdrawn bare filter in the facade's `find`…