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/spec describe the same key and disagree.
packages/spec/src/data/query.zod.ts — BaseQuerySchema (hence QueryAST, hence DriverQuery):
search: z.union([z.string(), FullTextSearchSchema]).optional()
with a doc comment that is unusually explicit about which spelling is canonical:
The bare string IS the canonical Tier-1 contract (ADR-0061 D1: "the client sends only the query text; the server resolves which fields to search from object metadata") — it is what every surface sends and what the dogfood HTTP proof (showcase-search.dogfood.test.ts) pins.
packages/spec/src/data/data-engine.zod.ts:119 — EngineQueryOptionsSchema, the options type of IDataEngine.find / findOne:
/** Full-text search configuration */
search: FullTextSearchSchema.optional(),
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.ts passes the canonical spelling five times, each behind a cast:
const row = await engine.findOne('crm_account', { search: 'Two' } as any);
const rows = await engine.find('crm_account', { search: 'Two' } as any);
So the type forbids what the runtime serves and what the ADR declares canonical, and callers pay the standard price: as any on the query argument, which does not suppress search alone — it switches off checking for where / orderBy / fields in 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 $like reached runtime through one).
Note the shape of the trap: EngineQueryOptionsSchema is not .strict(), and check: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.ts records why:
The union is schema-side drift REPAIR, not a new dialect: the schema declared only the object form while the executor and the ADR's own conformance ledger served the string — surfaced the moment #3899 started validating request bodies against this schema.
EngineQueryOptionsSchema is the same drift, unrepaired.
How it surfaced
In #6231, DatabaseLoader's three read helpers were retyped from Record< string, unknown > to the driver contract's DriverQuery. The driver branch then compiled with no cast at all. The engine branch did not:
src/loaders/database-loader.ts(230,38): error TS2345: Argument of type 'DriverQuery' is not
assignable to parameter of type '{ ... }'.
Types of property 'search' are incompatible.
Type 'string | { query: string; ... } | undefined' is not assignable to
type '{ query: string; ... } | undefined'.
Type 'string' is not assignable to type '{ query: string; ... }'.
DriverQuery is Omit< QueryAST, 'object' >, so it inherits the union; EngineQueryOptionsParsed does not have it. Concretely: DriverQuery is not assignable to EngineQueryOptionsParsed, purely because of search. Nothing else differs.
Interesting asymmetry worth keeping: IDataEngine.count takes EngineCountOptions, which is a z.input type, and it accepts the same value fine. Only find / findOne — which take the z.infer (parsed) type — reject it.
#6231's PR therefore left the three engine-branch as any casts exactly as main had 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.search with BaseQuerySchema.search — the same z.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 three database-loader engine-branch casts be deleted, which is real where/orderBy/fields checking 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 .query off it without a typeof guard). If some do, they need the guard added in the same PR.
Session: session_01W6bLax4KMrSfnE1ydFU8Dw (found during #6231, unclaimed)
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)