Repository navigation
EngineQueryOptionsSchema.search rejects the bare query string that ADR-0061 D1 calls canonical — so every engine caller that wants it must as any, losing the whole query's checking #7178
Description
Activity
- added a commit that references this issue
on Aug 10, 2026 Triage:
pm:queue+domain:spec.- Landing anchor (read, not inferred):
packages/spec/src/data/data-engine.zod.ts:119— verified onorigin/main@5d24f4bthatEngineQueryOptionsSchema.searchisFullTextSearchSchema.optional()whileBaseQuerySchema.search(query.zod.ts:502) isz.union([z.string(), FullTextSearchSchema]). The proposed fix widens what the engine options schema accepts ⇒ acceptance-face change ⇒domain:spec(standing spec/spec-surface split, red line: any accept/reject behavior change routes to spec). - Dedup: docs:
data-engine.mdxEngineQueryOptionsblock omitssearchFields, declared on the schema since #4371 #7170 is the docs-side omission ofsearchFields(different defect); [finding][drivers]DriverQuery已经存在,但几处驱动调用方仍在用as any/as QueryAST兜住冗余的object—— 连带把 where/orderBy/fields 的检查一起关掉 #6231 is the in-flight cast cleanup that surfaced this card and names it — not a blocker, the fix here is independent. - Stale-premise check: both anchors live on
origin/main@5d24f4bat triage time. - Not
target:v17: the divergence is compile-time only — the runtime already serves the canonical bare string, the failure mode is a loud TS error with a visible (if costly) workaround; none of the four blocking classes applies. - Implementer note from the card, worth honoring: check whether any consumer reads
.queryoffEngineQueryOptionsParsed['search']without atypeofguard, and add guards in the same PR.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- Landing anchor (read, not inferred):
CLAIM — spec-lane PM seat (#6017), session
session_01PiRUoQkTSBBmpyXBY3cVn2. Branch:claude/issue-7178-engine-search-union. Dispatching a cloud dev session now under the maintainer's 2026-08-10 acceleration instructions.Direction (the card's suggested one, adopted as the lane ruling — this is the same-family drift REPAIR whose precedent and rationale are already recorded in
query.zod.ts): alignEngineQueryOptionsSchema.searchwithBaseQuerySchema.search—z.union([z.string(), FullTextSearchSchema]).optional()— restoring the ADR-0061 D1 canonical bare-string spelling that the executor, the HTTP surface and the dogfood proof already serve. Then delete the casts this divergence forces: the fiveas anyinengine-findone-contract.test.tsand the three engine-branch casts indatabase-loader.tsthat #6231 left pointing at this card — realwhere/orderBy/fieldschecking recovered.Honoring the card's own guard and triage's implementer note: before landing, census every consumer that reads
.query(or other object-form members) offEngineQueryOptionsParsed['search']without atypeofguard — any found get the guard in the same PR; if that census turns up a consumer whose behavior would silently change (not merely need a guard), STOP and report.
Generated by Claude Code
OS-DEV-REPORT
{ "issue": 7178, "outcome": "pr_opened", "branch": "claude/issue-7178-engine-search-union", "pr_number": 7249, "premise_check": { "base": "origin/main @ 2e4274d", "verdict": "intact — nothing repaired since triage @ 5d24f4b", "anchors": { "data-engine.zod.ts:119": "CONFIRMED object-form only (search: FullTextSearchSchema.optional())", "query.zod.ts:502": "CONFIRMED union (z.union([z.string(), FullTextSearchSchema]).optional())", "database-loader.ts engine-branch casts": "CONFIRMED 3 (_find/_findOne/_count) under a NOTE (#6231) block naming #7178", "engine-findone-contract.test.ts casts": "DEVIATION — 7 present, not the 5 the card/dispatch state (lines 144,150,163,164,169,179,296)", "erasure baseline": "CONFIRMED database-loader.ts: 3, testSurface.sites: 256" } }, "consumer_census": { "verdict": "CLEAN — zero unguarded object-form consumers; zero category-(b) silent behavior changes; STOP condition did not trigger", "guarded_already": [ "objectql/src/engine.ts:5849-5860 expandSearchOnAst — typeof raw === 'object' ? raw?.fields : undefined", "objectql/src/search-filter.ts:59-69 normalizeSearch — full string/object discrimination, param is unknown", "objectql/src/search-filter.ts:102-109 expandSearchToFilter — reads only the normalized result", "metadata-protocol/src/protocol.ts:5882-5886 searchFields ingress gate (#4254) — typeof === 'object'" ], "shape_agnostic_no_guard_needed": [ "metadata-protocol/src/protocol.ts:6063 — null check only", "objectql/src/engine.ts:5865-5866 — delete, not a member read" ], "structurally_immune": "count — EngineCountOptionsSchema declares no search key at all; ENGINE_COUNT_OPTION_KEYS (engine.ts:310) rejects one", "cleared_as_different_type": [ "metadata-manager.ts (MetadataQuerySchema.search, plain z.string())", "client/src/query-builder.ts:287 (a writer, not a read)", "rest/src/rest-server.ts:6707 (raw HTTP query bag, already typeof-guarded)", "URL.search / view-config booleans / OData $search" ], "guards_added": 0, "drivers_reading_search": 0 }, "changes": [ "packages/spec/src/data/data-engine.zod.ts — search widened to z.union([z.string(), FullTextSearchSchema]).optional(), doc comment mirrors the query.zod.ts repair rationale, cites #7178", "packages/metadata/src/loaders/database-loader.ts — 3 engine-branch casts deleted; stale NOTE (#6231) block rewritten as a close-out that warns against reinstating the cast", "packages/objectql/src/engine-findone-contract.test.ts — 7 as any deleted", "packages/spec/src/data/data-engine.test.ts — 5 new pins (bare string, structured form, both-with-searchFields, neither-shape rejection, sibling agreement with BaseQuerySchema.search)", "scripts/query-options-erasure-baseline.json — ratcheted down (database-loader.ts leaves the grandfather list; testSurface 256 -> 249)", "content/docs/references/data/data-engine.mdx — regenerated (search row now string | { query: string; ... })", ".changeset/engine-query-options-search-union.md — @objectstack/spec minor, @objectstack/metadata patch" ], "casts_deleted": { "engine-findone-contract.test.ts": 7, "database-loader.ts": 3, "note": "7 not 5 — the card and dispatch both undercount; the ratchet independently corroborates (test surface fell exactly 256 -> 249). The loader's 4th cast (_delete) is a different shape, unrelated to search, deliberately left." }, "reverse_verification": { "method": "predictions written first, then only data-engine.zod.ts reverted to origin/main (git checkout origin/main -- <file>) and the new pins run unchanged; tests import from src, so no dist confound", "bare_string_accept": "predicted RED -> measured RED (ZodError: expected object, received string) -> GREEN post", "both_spellings_with_searchFields": "predicted RED -> measured RED -> GREEN post", "sibling_agreement_with_BaseQuerySchema": "predicted RED -> measured RED -> GREEN post", "structured_form_accept": "predicted GREEN both sides -> measured GREEN both — NON-DISCRIMINATING guard pin, stated as such", "neither_shape_rejection": "predicted GREEN both sides -> measured GREEN both — NON-DISCRIMINATING guard pin, stated as such", "compile_side": "card's TS2345 gone — DriverQuery -> EngineQueryOptionsParsed assigns bare; zero tsc errors in database-loader.ts; the excluded test file typechecked via a throwaway project (deleted, not committed) with no error on any de-casted line" }, "gates": { "spec_build": "PASS (twice — before the gen chain per #7122, and again after the reverse-verification revert/restore)", "turbo_build_objectql+metadata_chains": "PASS 15/15", "check:query-options-erasure": "RED -> ratcheted --update -> PASS (self-test 10 shapes / 8 ratchet cases; no baseline files added, verified vs 2e4274d)", "check:generated": "1/11 stale (content/docs/references/**) -> gen:docs -> PASS 11/11", "check:api-surface": "PASS unchanged — no new exports, no signature change, no regen needed", "check:export-origins": "PASS current — 4970 exports / 16 entry points, no regen needed", "check:authorable-surface": "PASS — 1277 defaults unchanged, 1587 schemas generated", "check:docs": "RED -> regenerated -> PASS", "check:empty-changeset / check:changeset-gate-self-tests / check:adr-0087-registration": "PASS", "spec_tests": "PASS 360 files / 9402 tests", "objectql_tests": "PASS 167 files / 2906 tests", "metadata_tests": "PASS 29 files / 592 tests", "eslint_changed_files": "PASS clean", "heavy_lock": "flock /tmp/os-heavy-verify.lock used throughout; zero wait — sole heavy consumer" }, "deviations": [ "Cast count in engine-findone-contract.test.ts is 7, not the 5 the card and dispatch state. Removed all 7 (each had search: <string> as its only off-contract content and each compiles uncast); corroborated by the ratchet's 256 -> 249.", "The card's asymmetry note is imprecise and is now moot. count accepted DriverQuery via ordinary non-fresh-object excess-property assignability (EngineCountOptionsSchema declares NO search key at all, and ENGINE_COUNT_OPTION_KEYS rejects one) — not via z.input vs z.infer. Post-change the asymmetry is gone from the caller's view: all three loader helpers pass uncast. The only in-code home of the old wording was the loader's NOTE (#6231) block, which this PR rewrites.", "Removing the loader key from the erasure baseline also un-mutes that file for pnpm lint (eslint.config.mjs:299 reads the baseline as its ignores). A real tightening beyond the literal ask — intended, flagged.", "Pre-existing and OUT OF SCOPE, not filed: packages/spec/src/data/query.test.ts:1094/1106/1193/1205 do unguarded result.search?.operator/.fuzzy/.highlight on QuerySchema.parse(...), whose search has been a union since the query-side repair. Untouched and unaffected by this PR; green. Held rather than filed to avoid noise on a passing pin — say the word and I will file it as a finding." ], "docs_adr_touched": false, "special_inspection_notes": [ "Confirm removing all 7 test casts (not 5) is what the lane wanted.", "Refreshed asymmetry framing (deviation 2) — worth carrying back to the card if the PM keeps a ledger of these.", "The baseline key removal doubles as an ESLint un-mute for database-loader.ts.", "api-surface/ and export-origins/ both reported current against the real post-build dist, so neither was regenerated — widening a property's type is not part of either snapshot's recorded shape. No new exports, as expected.", "content/docs/releases/ and docs/adr/** untouched; the only content/ change is the generated references/data/data-engine.mdx." ] }Draft PR: #7249 — no auto-merge, left for serial landing.
Generated by Claude Code
- added a commit that references this issue
on Aug 10, 2026 - added a commit that references this issue
on Oct 7, 2026
Surfaced while implementing #6231 (metadata seat). Filed unassigned and unlabeled for triage grading. Not fixed there: the fix is in
packages/spec, which that card's dispatch declared a STOP boundary (cross-seat declaration on #6298).The divergence
Two sibling schemas in
packages/specdescribe the same key and disagree.packages/spec/src/data/query.zod.ts—BaseQuerySchema(henceQueryAST, henceDriverQuery):with a doc comment that is unusually explicit about which spelling is canonical:
packages/spec/src/data/data-engine.zod.ts:119—EngineQueryOptionsSchema, the options type ofIDataEngine.find/findOne:Structured form only. The canonical bare string is not accepted.
Why this is a live cost, not dormant drift
The runtime serves the string — it is the engine's own tests that prove it, and they prove the workaround at the same time.
packages/objectql/src/engine-findone-contract.test.tspasses the canonical spelling five times, each behind a cast:So the type forbids what the runtime serves and what the ADR declares canonical, and callers pay the standard price:
as anyon the query argument, which does not suppresssearchalone — it switches off checking forwhere/orderBy/fieldsin the same literal. That is exactly the account #5181's changeset opened and that #6231 has just been closing at five other call sites (cloud#1053 measured 20 such sites; cloud#1030's$likereached runtime through one).Note the shape of the trap:
EngineQueryOptionsSchemais not.strict(), andcheck:query-options-erasure's own rationale spells out the consequence — an unknown key is silently dropped. So the cast that works around this divergence is precisely the cast that ratchet exists to stop.There is a same-family precedent for the repair. The identical drift once existed on the query side and was fixed, and
query.zod.tsrecords why:EngineQueryOptionsSchemais the same drift, unrepaired.How it surfaced
In #6231,
DatabaseLoader's three read helpers were retyped fromRecord< string, unknown >to the driver contract'sDriverQuery. The driver branch then compiled with no cast at all. The engine branch did not:DriverQueryisOmit< QueryAST, 'object' >, so it inherits the union;EngineQueryOptionsParseddoes not have it. Concretely:DriverQueryis not assignable toEngineQueryOptionsParsed, purely because ofsearch. Nothing else differs.Interesting asymmetry worth keeping:
IDataEngine.counttakesEngineCountOptions, which is az.inputtype, and it accepts the same value fine. Onlyfind/findOne— which take thez.infer(parsed) type — reject it.#6231's PR therefore left the three engine-branch
as anycasts exactly asmainhad them, with a comment naming this issue, rather than narrowing the cast or restoring a wider one.Suggested direction (for triage, not a decision I am making)
Align
EngineQueryOptionsSchema.searchwithBaseQuerySchema.search— the samez.union([ z.string(), FullTextSearchSchema ])— on the grounds that the ADR, the executor, the HTTP surface and the dogfood proof all already agree on the string, so the engine option schema is the only outlier. That would additionally let the threedatabase-loaderengine-branch casts be deleted, which is realwhere/orderBy/fieldschecking recovered on the metadata main read path.Worth confirming before acting, since it is a spec change and I did not verify it: whether any consumer relies on
EngineQueryOptionsParsed['search']being narrowed to the object form (e.g. reading.queryoff it without atypeofguard). If some do, they need the guard added in the same PR.Session:
session_01W6bLax4KMrSfnE1ydFU8Dw(found during #6231, unclaimed)