Skip to content

feat(objectql)!: refuse a text operator over a field whose DECLARED type can never store a string (#15773) - #17381

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-15773-text-operator-declared-type-door
Sep 10, 2026
Merged

feat(objectql)!: refuse a text operator over a field whose DECLARED type can never store a string (#15773)#17381
os-sam merged 4 commits into
mainfrom
claude/issue-15773-text-operator-declared-type-door

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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 in 6f1ce7d267 (#15804); this PR is its only consumer.

The ruling, verbatim (lane 2)

(2) domain:engine — the door in packages/objectql/src/engine.ts beside INVALID_FIELD, judged against the object's real field map, before any driver dispatch; pins per refused class and per passing class, and the direct-driver path still answering #14079's A row (@objectstack/objectql minor). Sequencing: after PR(s) for #14079 land. Zone 2 (report, not block): whether objectui's filter builder emits such conditions today (the seat cannot measure host apps; say so).

What was wrong, measured before the door

origin/main 59db8a02cb, a real ObjectQL, lane (1)'s fixture object registered, a recording driver beneath:

filter before after
{ f_number: { $contains: '5' } } driver read, filter passed through 400 INVALID_FILTER
{ f_summary: { $contains: '5' } } driver read, filter passed through 400 INVALID_FILTER
{ f_json: { $contains: 'a' } } driver read, filter passed through 400 INVALID_FILTER
{ f_date: { $startsWith: '2026' } } 400 INVALID_FILTER — the #8690 TEMPORAL door, about the COMPARAND 400 INVALID_FILTER, naming the declared type
{ f_text: { $contains: 'a' } } driver read unchanged

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

Why it runs BEFORE the temporal gate

A text operator over a date field 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 over packages/objectql/src/*.test.ts).

⚠️ One named divergence from lane (1)'s case table: formula

assertFilterIsMaterializable (#8296) refuses EVERY filter over a formula field one door above this one, with INVALID_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:

find(o, { where: { f_formula_number:  { $contains: '5' } } })  -> INVALID_FIELD 400 (#8296)
find(o, { where: { f_formula_text:    { $contains: '5' } } })  -> INVALID_FIELD 400 (#8296)
find(o, { where: { f_formula_untyped: { $contains: '5' } } })  -> INVALID_FIELD 400 (#8296)

So lane (1)'s formula rows — returnType: numberdoor-refusal with INVALID_FILTER, textpasses with 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 by returnType, 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 named NAMED 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 its formula branch.

Zone 2 — reported, not blocked

Whether objectui's filter builder emits such conditions today cannot be measured from this repo. objectui is 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 is INVALID_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 / radio codes, multiselect / checkboxes / tags, lookup and user ids, autonumber, the file classes) all pass unchanged, which is what C-allow was refused for. A follow-up measurement on the objectui tree 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.

run result
pnpm --filter '@objectstack/objectql^...' build --concurrency=2 VERDICT command-exit 0 (shared verify lock)
pnpm --filter @objectstack/objectql test at cc83d5c741 295 files / 4947 tests passed, exit 0
pnpm --filter @objectstack/objectql typecheck exit 0 — check:test-typecheck: OK … 44 file(s) / 242 error(s) / 69 pinned signature(s), the ledger's own numbers, unchanged
pnpm --filter @objectstack/spec test 471 files / 13266 tests passed, exit 0
pnpm --filter @objectstack/spec typecheck exit 0
check:migration-registry · check:spec-changes · check:upgrade-guide exit 0, exit 0, exit 0
node scripts/pm/dispatch-gates.mjs --ran 110 derived, 109 run green, 0 UNRUN, 1 NOT-MEASURED
node scripts/pm/check-clause2-carriers.mjs --pair 17381 exit 0 — both carriers agree

The one NOT-MEASURED family is pnpm check:dual-build-cjs-loads, exit 3 — PREREQUISITE NOT MET: it reads built output and 33 packages have no dist/ in this worktree ("Run pnpm build first. ⛔ 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 (three TS2339s — the REFUSALS partition filtered on the discriminant with a plain boolean predicate, which does not narrow the union; fixed with a c is TextOperatorDoorRefusalCase type guard, no @ts-expect-error, no any, no debt-ledger edit) and pnpm check:doc-authoring (one site: the refusal message carried (#14079); the id moved to an adjacent // comment, baseline untouched). check:type-check-debt first 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 from engine.ts (proved on disk — 4 occurrences before, 1 after, git hash-object moved from d14c0561… to 996ca53f…), suite re-run, then restored from HEAD under a trap.

RED   : 9 failed | 13 passed (22)   exit 1
        × refuses every door-refusal case … × pins EVERY class of the verdict matrix
        × covers every engine verb … × FilterArray sugar … × $and / $or / $not
        × the per-aggregation filter … × where object / $filter string / filter AST
        AssertionError: $contains over f_number (number) — door-refusal: expected a refusal
GREEN : 22 passed (22)               exit 0

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 empty git diff HEAD.

Two zero-hit readings, each with a control that fires

Acceptance notes


Generated by Claude Code


Generated by Claude Code

…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>
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 10 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/field-types.mdx (via returnType (symbol, a field of interface TextOperatorOverNonTextField))
  • content/docs/data-modeling/formulas.mdx (via returnType (symbol, a field of interface TextOperatorOverNonTextField))
  • content/docs/data-modeling/validation-rules.mdx (via returnType (symbol, a field of interface TextOperatorOverNonTextField))
  • content/docs/getting-started/common-patterns.mdx (via returnType (symbol, a field of interface TextOperatorOverNonTextField))
What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2bafbfdca92fa0bf9a08e6f306be435c97ce6505packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a36600249e23d5e13d546b203d2f96cd7836ccd — the merge of head cc83d5c74101ce574c685c5b5395db3b07302e16 into base 2bafbfdca92fa0bf9a08e6f306be435c97ce6505, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2bafbfdca92fa0bf9a08e6f306be435c97ce6505 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants