feat(objectql)!: refuse a text operator over a field whose DECLARED type can never store a string (#15773) - #17381
Conversation
…ype can never store a string A text operator ($contains / $notContains / $startsWith / $endsWith / $icontains / $like / $ilike) aimed at a numeric, boolean, temporal or structured-JSON field reached the driver verbatim and was answered by #14079's option-A row — no row for a positive operator, EVERY row for $notContains — with no signal that the field can never hold a string. Measured on 59db8a0 with a real ObjectQL, the lane-1 fixture and a recording driver: f_number/$contains, f_summary/$contains and f_json/$contains all reached the driver; f_date/$startsWith was refused only by the #8690 temporal door, about the comparand. The declared-type door now sits third in the engine's filter-collection ladder — comparand shape (#5869), materializable field (#8296/#8371), THIS, temporal comparand (#8690) — and refuses with INVALID_FILTER 400 naming the field, its declared type and the operator. It runs before the temporal gate deliberately: same wire envelope either way, but the message is about the declaration rather than a comparand that could never have made the filter runnable. The refused classes are derived, never re-listed: the verdict is @objectstack/spec/data's textOperatorDoorVerdict over the published value classes, so a type added to one of those sets is refused with no change here. Both filter forms, every verb that collects a filter, and the per-aggregation filter position are covered. A dotted key, an unknown field and a registry-less host stay unjudged; formula is refused one door earlier by #8296 with INVALID_FIELD, and that divergence from lane (1)'s formula rows is pinned by name rather than dropped. Beneath the door a direct driver call and `having` still answer FILTER_TEXT_CASES' option-A row, both pinned. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6a36600249e23d5e13d546b203d2f96cd7836ccd && git checkout 6a36600249e23d5e13d546b203d2f96cd7836ccd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2bafbfdca92fa0bf9a08e6f306be435c97ce6505 cc83d5c74101ce574c685c5b5395db3b07302e16 && git checkout -B drift-repro 2bafbfdca92fa0bf9a08e6f306be435c97ce6505 && git merge --no-ff cc83d5c74101ce574c685c5b5395db3b07302e16
node scripts/docs-audit/affected-docs.mjs --json 2bafbfdca92fa0bf9a08e6f306be435c97ce6505
|
…nd register the ADR-0087 semantic entry Two follow-ups on the declared-type door, neither of which changes the door itself. 1. content/docs/protocol/objectql/query-syntax.mdx stated the #15683 compile-time no-match as what a caller sees. After the engine door that is only true BENEATH it: through the engine there is no result set at all, the call is refused with 400 INVALID_FILTER before any driver dispatch. The callout now names which caller gets which answer and keeps the driver-memory divergence (#17348) and the FILTER_TEXT_CASES sentence intact; the case-axis half is untouched. 2. The changeset declares a breaking change, so ADR-0087 requires a disposition in writing. It is `registered`, not `not-required`, because the refused shape has an authored, STORED surface — measured, not assumed: no schema changed, so a stored filter still parses and still loads, and the first sign of it is a 400 on the read that executes it. Carriers measured to reach this seam: sys_saved_report.query_json.filter (report-service.ts runs engine.find(object, { where: q.filter }) verbatim), FieldSchema.summaryOperations[].filter (summary-aggregate.ts ANDs it with the parent-FK match and calls engine.aggregate), ListView /tab filters through ViewFilterRuleSchema's contains family (AST_OPERATOR_MAP lowers 'contains' to '$contains'), and the FilterConditionSchema carriers on dashboards, datasets and reports. RLS / sharing predicates are NOT on that list: the middleware chain composes them after this door, which is why the door judges the caller's own where only. No rewrite is mechanical — { amount: { $contains: '5' } } may have meant $eq: 5, a range, or a different column — so it is a semantic entry carrying the repair procedure and its acceptance criteria, not a graduated conversion. registry.ts is regenerated by gen:migration-registry; check:migration-registry, check:spec-changes and check:upgrade-guide are green. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
…door suite type-checks `pnpm --filter @objectstack/objectql typecheck` exited 1 at 95d5575 — check:test-typecheck reported three errors in a file the debt ledger does not cover: src/engine-text-operator-declared-type-door.test.ts(168,82): error TS2339: Property 'code' does not exist on type 'TextOperatorDoorCase'. src/engine-text-operator-declared-type-door.test.ts(168,98): error TS2339: Property 'status' does not exist on type 'TextOperatorDoorCase'. src/engine-text-operator-declared-type-door.test.ts(171,33): error TS2339: Property 'mustMention' does not exist on type 'TextOperatorDoorCase'. `TextOperatorDoorCase` is a union of three verdict shapes and only the door-refusal arm carries `code` / `status` / `mustMention`. The REFUSALS partition filtered on `c.verdict === 'door-refusal'` with a plain boolean predicate, which does not narrow the array's element type, so the three envelope assertions read members off the union. Fixed at the type, not at the checker: the predicate is now a user-defined type guard returning `c is TextOperatorDoorRefusalCase` — the discriminant it already tested, stated in the signature. No @ts-expect-error, no `any`, no cast, and the debt ledger is untouched (a new entry there would have turned one red into an accepted regression). Measured, redirect-then-$?: typecheck exit 1 before, exit 0 after (`check:test-typecheck: OK — 44 file(s) / 242 error(s) / 69 pinned signature(s)`, unchanged from the ledger's own numbers), and the suite still passes 22/22. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
`pnpm check:doc-authoring` exited 1 on 438d918. The gate's own site list, verbatim: ✗ NEW internal issue-id reference(s) in sibling-package string prose: packages/objectql/src/text-operator-declared-type-door.ts #14079 (0 pinned, 1 measured) :265 'this door the predicate is answered by the declared no-match rule (#14079) — a positive ' One site, one id, in the refusal message this door hands the caller. The message reaches authors, operators and generated surfaces, none of whom can resolve a tracker id; the rule's own words are 「处理 issue 时犯的错应 该总结成经验,保留 issue id 没有意义」. The sentence already carries the lesson — a positive text operator matches no row and $notContains matches every row — so the id is dropped from the string and moved to an adjacent `//` comment for the source reader who CAN resolve it. The module docblock keeps its references, which the gate does not read as runtime prose. The baseline file is untouched: an entry there is maintainer-only and would have weakened a ratchet to buy green. Measured, redirect-then-$?: exit 1 before, exit 0 after. The door suite still passes 22/22 — no pin asserted that id (`mustMention` carries the key, the declared type and the operator, none of which moved). Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15773
Execution lane (2) of the maintainer ruling on #15661 (
5548768100, decision batch #43, option C-deny). Lane (1) — the contract this consumes — landed in6f1ce7d267(#15804); this PR is its only consumer.The ruling, verbatim (lane 2)
What was wrong, measured before the door
origin/main59db8a02cb, a realObjectQL, lane (1)'s fixture object registered, a recording driver beneath:{ f_number: { $contains: '5' } }400 INVALID_FILTER{ f_summary: { $contains: '5' } }400 INVALID_FILTER{ f_json: { $contains: 'a' } }400 INVALID_FILTER{ f_date: { $startsWith: '2026' } }400 INVALID_FILTER— the #8690 TEMPORAL door, about the COMPARAND400 INVALID_FILTER, naming the declared type{ f_text: { $contains: 'a' } }Beneath the door the driver answered #14079's option-A row: no row for a positive operator, EVERY row for
$notContains. That is the declared answer and it is not wrong — it simply carries no signal that the field can never hold a string.What this adds
packages/objectql/src/text-operator-declared-type-door.ts, bound at the engine's single filter collection point (lowerWhereFilterArray, both branches) and ataggregate's per-aggregationfilterslot. Ladder position 3 of 4: comparand shape (数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869) → materializable field (The FILTER axis has no unmaterializable verdict: awhereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296 / [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371) → declared type → temporal comparand (An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown{placeholder}is correctly rejected 400 (17.0.0 GA) #8690).@objectstack/spec/data'stextOperatorDoorVerdict; the refused set is the union of the six published value classes. A type added toNUMERIC_VALUE_TYPEStomorrow is refused with no edit in this package. ⛔ No new set, no new error code, no new export.INVALID_FILTER/ 400 /httpStatus400 through this package's existinginvalidFilterError— the message names the field, its declared type and the operator, per the ruling.Why it runs BEFORE the temporal gate
A text operator over a
datefield was already refused — by #8690, with the same wire envelope but a message reporting that the comparand is not a date value. True, and beside the point: no comparand would have made the filter runnable, and an author who "fixes" it to$startsWith: '2026-01-01'gets the silent cell back. Same envelope, the ruling's message. No pin in the tree asserted the old wording for that cell (checked by grep overpackages/objectql/src/*.test.ts).formulaassertFilterIsMaterializable(#8296) refuses EVERY filter over aformulafield one door above this one, withINVALID_FIELD/ 400, for the broader reason that no driver materialises a column for it. Measured on the same tree, for all three return-type shapes:So lane (1)'s formula rows —
returnType: number⇒door-refusalwithINVALID_FILTER,text⇒passeswith a driver read — cannot be consumed as written at this seam. The ladder was not reordered around them: doing so would answer ONE condition ("a formula field cannot be filtered") with TWO wire codes chosen byreturnType, which is the split every door on this seam records its reasoning against, and #8296's own note argues that code assignment. The rows are pinned in the direction they actually answer, under a test namedNAMED DIVERGENCE, so the day formula fields become filterable this file goes red and the question is re-judged rather than silently inherited. The door's own verdict function is still exercised through itsformulabranch.Zone 2 — reported, not blocked
Whether objectui's filter builder emits such conditions today cannot be measured from this repo.
objectuiis a sibling repository and the host apps that consume it are not in this checkout; this seat has no reading of them. What can be said from here: the refusal isINVALID_FILTER/ 400 with a message naming the field and its declared type, so a builder that does emit one gets a diagnostic rather than an empty list — and the string-valued classes a filter builder most often targets (select/radiocodes,multiselect/checkboxes/tags, lookup anduserids,autonumber, the file classes) all pass unchanged, which is what C-allow was refused for. A follow-up measurement on theobjectuitree belongs to a seat that can read it.Verification
Head
cc83d5c741. Local scope per the repo's rule: the objectql dependency closure build, the affected packages' own tests and typechecks, and the gate families derived from the diff. Repo-wide scans are CI's. Exit codes captured by redirect-then-$?, never across a pipe.pnpm --filter '@objectstack/objectql^...' build --concurrency=2VERDICT command-exit 0(shared verify lock)pnpm --filter @objectstack/objectql testatcc83d5c741pnpm --filter @objectstack/objectql typecheckcheck:test-typecheck: OK … 44 file(s) / 242 error(s) / 69 pinned signature(s), the ledger's own numbers, unchangedpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:migration-registry·check:spec-changes·check:upgrade-guidenode scripts/pm/dispatch-gates.mjs --rannode scripts/pm/check-clause2-carriers.mjs --pair 17381The one NOT-MEASURED family is
pnpm check:dual-build-cjs-loads, exit 3 — PREREQUISITE NOT MET: it reads built output and 33 packages have nodist/in this worktree ("Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured"). A repo-wide build is CI's, not a local scope. ⛔ Not recorded as a pass.Two gates were red on the way and are fixed in their own commits, both proved red-before / green-after by exit code:
pnpm --filter @objectstack/objectql typecheck(threeTS2339s — the REFUSALS partition filtered on the discriminant with a plain boolean predicate, which does not narrow the union; fixed with ac is TextOperatorDoorRefusalCasetype guard, no@ts-expect-error, noany, no debt-ledger edit) andpnpm check:doc-authoring(one site: the refusal message carried(#14079); the id moved to an adjacent//comment, baseline untouched).check:type-check-debtfirst exited 3 under--max-old-space-size=4096(the re-measure tsc OOMed); re-run at 8192 it is exit 0 — 5 ledger entries re-measured, 55 raw errors, none above its recorded number.Red before green, in the real direction
Ablation on the committed tree: the three
assertTextOperatorTargetsAreStringCapable(...)call sites deleted fromengine.ts(proved on disk — 4 occurrences before, 1 after,git hash-objectmoved fromd14c0561…to996ca53f…), suite re-run, then restored fromHEADunder atrap.The 13 that stay green under ablation are the guards and the beneath-the-door pins — exactly the ones that must NOT depend on the door. Restore verified by blob hash (
d14c0561…again) and an emptygit diff HEAD.Two zero-hit readings, each with a control that fires
main:git grep -c -E "textOperatorDoorVerdict|TEXT_OPERATOR_DOOR" 59db8a02cb -- packages/objectql packages/rest packages/core→ 0 files; controlassertFilterIsMaterializableon the same three trees → 5 files.{placeholder}is correctly rejected 400 (17.0.0 GA) #8690 wording for a text operator over a temporal field (the cell whose message this PR changes): the text-operator grep overpackages/objectql/src/*.test.tsat59db8a02cb, filtered to temporal field names → 0 lines; control,$gte/$ltinengine-temporal-comparand-door.test.ts→ 26 hits.Acceptance notes
Clause-②: yes— a published filter surface narrows (a call that answered[], every row, or a dialect accident now answers400), the same criterion the maintainer used on text operator over a temporal column: SQLite matches the stored ISO text, live Postgres refuses with SQLSTATE 42883, and the JS faces answer by stored representation — no face declares the cell #15683.needs:contract-reviewis on this PR;@objectstack/objectqlminor with a BREAKING banner in the changeset, per the ruling's grade.INVALID_FILTERalready exists atpackages/spec/src/api/errors.zod.ts. No export added or removed from either package entry (index.ts/core.tsre-export neither this module nor its two sibling door modules). No member added to any published closed set.packages/spec/src/data/field-value.zod.tsis read-only in this PR, per the round's serial-queue fence (driver-sql: store the file family (file/image/avatar/video/audio) as the baresys_fileid in a string column — dropFILE_REFERENCE_TYPESfromJSON_COLUMN_TYPES, per-deployment switch on theadr-0104-file-referencesflag (ruling on #15041, step 2) #15989 holds it). This PR reads its sets through lane (1)'s module and edits nothing there.filterslot (engine.aggregate: add per-aggregation filter to the contract — ruled half of #10413 (measure-level filters on the ObjectQL analytics path) #10576) is covered as well aswhere. That position is not named in the ruling; it is the same door, the same seam and the same verb, and coveringwherealone would answer one mistake two ways insideaggregate. Called out here for the reviewer rather than left implicit.registered, and the question that decided it was measured rather than asserted.not-required (no-migration-prescription)would have been wrong twice over: this body carries a FROM → TO prescription, and — the half that actually decides it — a filter body CAN be persisted as metadata. Measured on the tree:sys_saved_report.query_json.filteris executed verbatim (report-service.ts→engine.find(object, { where: q.filter })),FieldSchema.summaryOperations[].filteris ANDed with the parent-FK match and handed toengine.aggregate(summary-aggregate.ts),ViewFilterRuleSchematakesfield: z.string()withcontains/not_contains/icontains/starts_with/ends_within its operator enum andAST_OPERATOR_MAPlowers'contains'to'$contains', andFilterConditionSchemais embedded by dashboards, datasets, reports andrelatedListFilter. No schema changed, so those stored bodies still parse and still load — the first sign is a 400 on the read. Hence one new semantic entry,filter-text-operator-declared-type-refused(protocol 18), carrying the repair procedure and its acceptance criteria; no mechanical rewrite exists, which is what asemanticentry is for.sys_sharing_rulewas checked and is deliberately NOT on that list: its authored surface is CEL, and RLS / sharing predicates are composed onto the AST AFTER this door, so the door never judges one.content/docs/protocol/objectql/query-syntax.mdxstated text operator over a temporal column: SQLite matches the stored ISO text, live Postgres refuses with SQLSTATE 42883, and the JS faces answer by stored representation — no face declares the cell #15683's compile-time no-match as what a caller sees; after this door that holds only BENEATH it. The callout now says which caller gets which answer — through the engine,400 INVALID_FILTERbefore dispatch; beneath it, the driver's own answer — and keeps thedriver-memorydivergence (driver-memory answers a text operator over a DECLARED temporal column by matching its canonical ISO text — the opposite of #15683's declared-type answer, and the existing pin passes for the wrong reason (a millisecond mismatch) #17348) and theFILTER_TEXT_CASESsentence intact. The case-axis half is untouched. ⛔ Nothing undercontent/docs/releases/**was touched.assertTemporalComparandsInterpretableis not wired into the per-aggregationfilterslot (only 数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 and The FILTER axis has no unmaterializable verdict: awhereon a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296 are, plus this door now), so an uninterpretable temporal comparand inside one aggregation's filter is still unjudged. Untouched here — it is An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown{placeholder}is correctly rejected 400 (17.0.0 GA) #8690's card, not this one — and the carrier that would trip over it is a dashboard measure filter. Successor: none identified; recorded so the next seat on that slot sees it.Generated by Claude Code
Generated by Claude Code