Skip to content

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

@os-zhuang

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)

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue + domain:spec.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 10, 2026
  3. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    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): align EngineQueryOptionsSchema.search with BaseQuerySchema.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 five as any in engine-findone-contract.test.ts and the three engine-branch casts in database-loader.ts that #6231 left pointing at this card — real where/orderBy/fields checking 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) off EngineQueryOptionsParsed['search'] without a typeof guard — 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

  4. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions