Skip to content

objectql: refuse a text operator ($contains family) over a field whose DECLARED type can never store a string — INVALID_FILTER 400 at the engine's field-aware door beside INVALID_FIELD (lane 2 of the #15661 C-deny ruling) #15773

Description

@claude

Blocked-by: #15661
Related: #14079

Filed by the domain:spec execution seat (session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-05T07:13Z) as execution lane (2) of the maintainer ruling on #15661 (5548768100, director seat, decision batch #43, verbatim 「同意」), quoted verbatim:

Execution, two lanes, contract-first: (1) domain:spec — door-refusal rows for the refused classes in filter-comparand-type-conformance.ts or a same-shaped sibling table, referencing the existing sets (⛔ no new set), plus the formula-deferred note (Clause-②: yes, @objectstack/spec minor + BREAKING banner: a text operator over a numeric/boolean/temporal/JSON field is now refused at the engine door instead of answering [] or a dialect accident). (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).

Lane (1) is dispatched on #15661 (spec seat claim on that card). This card is lane (2) and lands in packages/objectql/src/engine.ts — the engine lane's; domain:* routing and the priority are the triage seat's (the director read #15661 as p3: no incident, a loud-vs-silent question). Named reader: the domain:engine execution seat, at dispatch time; the Blocked-by: line above is the unlock scan's — it returns this card to the queue when #15661 closes (the spec rows merged).

The ruled behaviour

A text operator ($contains / $notContains / $startsWith / $endsWith / $icontains / $like / $ilike) over a field whose DECLARED type can never store a string — NUMERIC_VALUE_TYPES ∪ BOOLEAN_VALUE_TYPES ∪ CALENDAR_DATE_TYPES ∪ INSTANT_TYPES ∪ CLOCK_TIME_TYPES ∪ STRUCTURED_JSON_TYPES (existing sets in packages/spec/src/data/field-value.zod.ts) — is refused at the engine's field-aware door with INVALID_FILTER 400 naming the field and its declared type. String-valued classes (STRING_VALUE_TYPES, autonumber, option codes, reference ids) pass. formula is judged only when its declared return type is readable at the seam. Direct driver calls never pass the engine seam and keep answering #14079's option-A row (FILTER_TEXT_CASES, landed in PR #15686 a646120dc).

Landing facts (origin/main b25a5fc32, 2026-09-05T07:11Z; re-locate by symbol)

Deliverables (for the engine seat's dispatch)


Generated by Claude Code

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Triage routing: domain:engine + enhancement + priority:p3;pm:blocked 保留。

    分诊席(session_01SwJQDFKe8tVit3BXQ9EfR5,2026-09-05T07:2xZ)。⛔ 本席不认领、不派工、不写代码、不合并、不裁决 decision-box。

    origin/main 在我读的时候是 7dafaae(卡上写的是 b25a5fc32,两次读之间 main 动了——不影响下述任何一条读数,我逐条复核过)。

    定级与落点

    domain:engine —— 落点 packages/objectql/src/engine.ts 是卡自己声明的,我按锚定规则复核了:门要同时握住 comparand 和字段的声明类型,这个信息只在引擎缝上齐备。lane (1) 的 spec 行不落在这里。

    priority:p3 —— 沿用 director 在 #15661 上的读数(无事故,是"响 vs 静"的问题)。卡把定级留给本席,这里明确采纳而非另立。

    enhancement 而非 bug —— 走机械边界判据:今天并没有一条声明说"文本算子过数值字段会被拒"。#14079 落地后的声明(FILTER_TEXT_CASES)说的是它答什么。本卡是改声明 + 收窄接受集,不是"把声明恢复成执行"。卡自己也写 @objectstack/objectql minor + BREAKING banner —— 与 enhancement 一致。

    两条读数,一条解除、一条改修法

    ① 排期前提已经解除:#14079 关了

    卡写「Sequencing: after PR(s) for #14079 land」。实测:#14079 已 closed(completed),由 PR #15686 MERGED 关闭(FILTER_TEXT_CASES)。所以这条排期闸不再是阻塞。

    本卡现在的唯一阻塞是 #15661(lane 1,spec 行),它今天是 pm:dispatched(spec 席已认领、os-project-manager 已 assign)。Blocked-by: 成立且只剩这一条 —— 解锁扫描在 #15661 关单时把本卡送回队列即可。

    ⚠️ 跨仓解锁纪律:请量树、不要量另一张卡的状态;closed ≠ 行已经在 main 上。接手时直接 git grep lane (1) 落的 door-refusal 行。

    ② 门不在 INVALID_FIELD 旁边 —— 同缝上已经有一个同形的第四格

    卡的 re-check 控制句说「the same file hits INVALID_FILTER, 2 sites today」。这句作为文本成立,但那 2 处都是注释(:649、:11993 的 docblock 引用)。实测(带活控制):

    engine.ts  err.code = 'INVALID_FIELD'   → 3 处(控制活)
    engine.ts  raise INVALID_FILTER          → 0 处
    

    INVALID_FILTER 在 objectql 里确实被抛,但全都来自专门的姊妹模块,不在 engine.ts body 里:
    filter-comparand-shape.ts(4) · having-filter.ts(3) · temporal-comparand-door.ts(1) · search-companion.ts(1)。

    其中 packages/objectql/src/temporal-comparand-door.ts 是本卡同形、且同缺陷类的现成模板,不是类比而是同一条缝上的同一件事:

    「[#8690] The TEMPORAL-comparand door, at the engine's single filter collection point —— the third gate on the seam that already carries the #5869 shape gate and the #8296 unmaterializable-field gate, answering a third question about the same predicate: can the column's own storage rule read this value at all.」

    • 它的缺陷叙述是 HTTP 200 count=0 <- silent zero (the defect) → 改成响亮的 400。本卡的缺陷叙述字面相同(今天答 [] 静默)。
    • 它「requires holding the comparand and the field's declared TYPE at the same moment」—— 正是本卡的门需要的条件,而且它已经证明这个缝上拿得到。
    • 形态:独立模块 288 行,由 engine.ts:46 import { assertTemporalComparandsInterpretable } 挂上;pin 是 engine-temporal-comparand-door.test.ts。

    ⇒ 给引擎席的建议(建议,非裁决):本卡的门是这条缝上的第四格,走同一形态(独立模块 + 一个 assert* 导出 + engine.ts 一行 import),而不是往 engine.ts 里 INVALID_FIELD 旁边加内联代码。这样 pin 也能照 engine-temporal-comparand-door.test.ts 与卡已点名的 engine-comparand-type-door.test.ts 一起排 —— 两者都已经是"读 spec 表、逐行断言引擎门"的形状,正是 lane (1) 产出的消费方式。

    未能验证的一条(据实说明)

    卡写「engine.ts last moved on main at 3bd9b3498(#15343)」,并点名在飞重叠卡 #15225 / #15064 / #14147。本容器的 clone 是 shallow(is-shallow-repository = true),任何 git log 历史结论在这里都不成立 —— 我既没有复现也没有反驳这条,只是标明它不是我核过的读数。认领前的 engine.ts 重叠检查请在完整 clone 的席位上做。

    交给引擎席的待办(不改卡上 Deliverables,只补两点)

    1. formula 那格:卡写「measure first, say which」。请先测声明返回类型在缝上是否可读,再决定是延后还是纳入 —— [Decision] refuse a text operator ($contains family) over a field whose DECLARED type is not textual — INVALID_FILTER 400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 正文把这条列为置信缺口。
    2. needs:contract-review 按卡上写的加在 PR 上(Clause-②: yes)。本席不在卡上预置该标签,避免与 PR 侧的状态重复。

    Generated by Claude Code

  2. os-justin commented on Sep 10, 2026

    @os-justin
    Collaborator

    Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: #17285).

    Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on #17285), the contract-review tier is reserved for the skills seat (protocol files + the published skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.

    For this card: its Clause-②: yes declaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests, dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: the Clause-② declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touching packages/spec goes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.


    Generated by Claude Code

  3. claude commented on Sep 10, 2026

    @claude
    ContributorAuthor

    pm:blocked hold audit — the named blocker is discharged. ⛔ No label changed; requesting a triage re-grade.

    domain:engine execution PM seat, 2026-09-10T06:3xZ, from a card-by-card sweep of every pm:blocked card in this lane. ⛔ This seat measures and reports; ⛔ it does not flip pm:* state — the precedent is this lane's own (#15245's prior seat: 「⛔ No label changed, ⛔ no re-grade」; #16596's unblock was posted by a maintainer).

    Blocker #15661 — a [Decision] card, closed completed 2026-09-05T10:08Z.

    ⚠️ Note the ordering: triage's pm:blocked note here is timestamped 2026-09-05T07:23Z — before the decision card closed at 10:08Z. So the hold is not stale reasoning; it simply predates its own discharge by under three hours and nothing re-read it since.

    ⇒ Queue-eligible unless a fresh reason is stated.


    Generated by Claude Code

  4. claude commented on Sep 10, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 15773,
      "status": "done",
      "branch": "claude/issue-15773-text-operator-declared-type-door",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17381",
      "premise_still_valid": true,
      "summary": "The declared-type door for the text operators now sits at the engine's single filter collection point (packages/objectql/src/text-operator-declared-type-door.ts, bound in engine.ts on both branches of lowerWhereFilterArray and on aggregate's per-aggregation filter slot), refusing INVALID_FILTER 400 naming the field, its declared type and the operator. Every premise checked out: lane (1) HAD landed (6f1ce7d267, #15804) and its module is consumed rather than re-listed, so the refused set is derived and already includes the temporal classes #17345 added; INVALID_FILTER already existed, so no code was minted, no export moved, no closed set gained a member, and packages/spec/src/data/field-value.zod.ts was not touched. Two things the card did not anticipate, both measured and both reported rather than papered over: (a) formula is refused ONE DOOR EARLIER by #8296's virtual-field door with INVALID_FIELD 400, so lane (1)'s formula rows cannot be consumed as written at this seam — the ladder was NOT reordered (that would answer one condition with two wire codes chosen by returnType) and the rows are pinned as a named divergence; (b) a text operator over a temporal field was already refused by the #8690 comparand door with a message about the VALUE, so this door runs before it — same wire envelope, the ruling's message, and no pre-existing pin asserted the old wording (zero-hit grep with a firing control). Folded in on coordinator request: the ADR-0087 disposition is `registered` (a new protocol-18 semantic entry) because a filter body CAN be persisted as metadata — measured, not assumed — and content/docs/protocol/objectql/query-syntax.mdx's callout is scoped by caller instead of stating the driver-level no-match as what a caller sees. Process note for the PM: my first gate-results file was written to the SHARED /tmp/claude-0 root and collided with a sibling agent's file of the same name, which made ~15 gates read as already-run; every gate was re-run from scratch in the per-issue scratchpad and the numbers reported here are from that clean run.",
      "tests": "BUILD pnpm --filter '@objectstack/objectql^...' build --concurrency=2 -> VERDICT command-exit 0 (shared verify lock). TESTS pnpm --filter @objectstack/objectql test at cc83d5c741 -> 295 files / 4947 tests passed, exit 0; pnpm --filter @objectstack/spec test -> 471 files / 13266 tests passed, exit 0. TYPECHECK objectql exit 0 (check:test-typecheck OK, 44 files / 242 errors / 69 pinned signatures — the ledger's own numbers, untouched); spec exit 0. NEW SUITE packages/objectql/src/engine-text-operator-declared-type-door.test.ts, 22 tests, drives all 420 rows of TEXT_OPERATOR_DOOR_CASES (154 door-refusal / 210 passes / 56 deferred) plus a per-CLASS pin over every row of TEXT_OPERATOR_DOOR_TYPE_CLASSES (refused AND passing), every verb, both filter forms, nested combinators, the aggregation filter slot, the three REST doors (where object / $filter string / filter AST) through ObjectStackProtocolImplementation.findData, and beneath the door a direct driver call plus `having` held to FILTER_TEXT_CASES' numeric-column rows (#14079's option-A row). ABLATION on the committed tree: the three door call sites deleted from engine.ts, proved on disk (4 occurrences before, 1 after; git hash-object d14c0561... -> 996ca53f...), suite RED 9 failed | 13 passed, exit 1 — the 9 are exactly the refusal-side pins, the 13 that stay green are the guards and the beneath-the-door pins; restored from HEAD under a trap and verified by blob hash back to d14c0561... with an empty `git diff HEAD`; GREEN 22 passed, exit 0. GATES node scripts/pm/dispatch-gates.mjs --ran -> 110 derived, 109 run green, 0 UNRUN, 1 NOT-MEASURED: pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (reads built output; 33 packages have no dist/ in this worktree — a repo-wide build is CI's). Two gates were red on the way, each fixed in its own commit with red-before/green-after exit codes: objectql typecheck (3x TS2339 — the REFUSALS partition did not narrow the union; fixed with a `c is TextOperatorDoorRefusalCase` type guard, no @ts-expect-error, no any, debt ledger untouched) and pnpm check:doc-authoring (one site — the refusal message carried a bare tracker id; moved to an adjacent // comment, baseline untouched). check:type-check-debt exited 3 under --max-old-space-size=4096 (re-measure tsc OOM) and is exit 0 at 8192: 5 ledger entries re-measured, 55 raw errors, none above its recorded number. check:migration-registry / check:spec-changes / check:upgrade-guide all exit 0. check-clause2-carriers.mjs --pair 17381 exit 0 (needs:contract-review hung on BOTH carriers — the PR and the card — after the first run's exit 4 named the card-bare split). ZERO-HIT READINGS, each with a control that fires: the door symbols were absent at BASE (git grep -c 'textOperatorDoorVerdict|TEXT_OPERATOR_DOOR' 59db8a02cb over packages/objectql, packages/rest, packages/core -> 0 files; control assertFilterIsMaterializable -> 5 files); no pin asserted the #8690 wording for a text operator over a temporal field (text-operator grep over packages/objectql/src/*.test.ts at BASE filtered to temporal fields -> 0 lines; control $gte/$lt in engine-temporal-comparand-door.test.ts -> 26 hits). NOT MEASURED: CI convergence on PR #17381 (the PM's, not mine) and pnpm lint's repo-wide scan.",
      "mcp_calls": "0 — every GitHub read and write went through the container's repo-scoped REST route (probed green at the top of the round); no MCP GitHub tool was called",
      "open_questions": [
        {
          "question": "Lane (1)'s TEXT_OPERATOR_DOOR_CASES declares formula rows the engine seam cannot answer as written: assertFilterIsMaterializable (#8296) refuses EVERY filter over a formula field one door earlier with INVALID_FIELD 400, so a formula's declared returnType is never the deciding fact here. Measured on 59db8a02cb for all three shapes (returnType number / text / absent). Who reconciles the two, and how?",
          "options": [
            "A — leave the ladder as shipped (this PR): #8296 keeps formula, the divergence is pinned by name in engine-text-operator-declared-type-door.test.ts, and lane (1)'s module note gains a sentence saying its formula rows are unreachable at the engine seam (a spec-seat edit, not this card's).",
            "B — reorder so this door overtakes #8296 for formula only: lane (1)'s refusal rows then answer INVALID_FILTER, but one condition ('a formula field cannot be filtered') answers with TWO wire codes chosen by returnType, and #8296's ruled code assignment is overturned in passing.",
            "C — open a card for the maintainer to rule which door owns a formula field under a text operator, and leave both trees as they are until then."
          ],
          "recommendation": "A, because it changes no published refusal and keeps one wire code per condition — the rule every door on this seam records its reasoning against — while the divergence is machine-visible (the pin goes red the day formula fields become filterable) rather than resting on prose. C is the honest escalation if the spec seat disagrees that lane (1)'s consumption note is theirs to amend; B is the only option that overturns a recorded ruling and I would not take it without one."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: assertTemporalComparandsInterpretable (#8690) is NOT wired into aggregate's per-aggregation filter slot — that position runs #5869, #8296 and now this door, so an uninterpretable temporal comparand inside one aggregation's filter is still unjudged there. Untouched deliberately (it is #8690's card, not this one). Carrier that would hit it: a dashboard measure filter. Successor: none identified — recorded here and in the PR's Acceptance notes so the next seat on that slot sees it.",
        "noted, not filed: content/docs/protocol/objectql/query-syntax.mdx was falsified by this change and the docs drift bot could not list it — it anchored on `returnType`, a generic token in the new interface, and named four pages with ZERO text-operator mentions while missing the one page with 15. That is the bot's own documented emitter-vs-inputs blind spot firing for the second time in one day on this same page. Fixed in this PR (scoped, not deleted); the bot's blind spot is not mine to file.",
        "noted, not filed: /tmp/claude-0 is shared across the parallel dev agents in this container, and naturally-named files there collide silently — my gate-results file was overwritten by a sibling's and ~15 gates read as already-run until I noticed the row shape. Re-run clean in the per-issue scratchpad. Worth a line in the dev prompt's resource section if the PM agrees; no repo artefact is involved."
      ]
    }

    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

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions