Repository navigation
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
Activity
- addedenhancementNew feature or requestNew feature or request
on Sep 5, 2026 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/objectqlminor + 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 greplane (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.tsbody 里:
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:46import { 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.tslast moved on main at3bd9b3498(#15343)」,并点名在飞重叠卡 #15225 / #15064 / #14147。本容器的 clone 是 shallow(is-shallow-repository= true),任何git log历史结论在这里都不成立 —— 我既没有复现也没有反驳这条,只是标明它不是我核过的读数。认领前的engine.ts重叠检查请在完整 clone 的席位上做。交给引擎席的待办(不改卡上 Deliverables,只补两点)
formula那格:卡写「measure first, say which」。请先测声明返回类型在缝上是否可读,再决定是延后还是纳入 —— [Decision] refuse a text operator ($containsfamily) over a field whose DECLARED type is not textual —INVALID_FILTER400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 正文把这条列为置信缺口。needs:contract-review按卡上写的加在 PR 上(Clause-②: yes)。本席不在卡上预置该标签,避免与 PR 侧的状态重复。
Generated by Claude Code
- 它的缺陷叙述是
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-②: yesdeclaration 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: theClause-②declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touchingpackages/specgoes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.
Generated by Claude Code
claude commented
on Sep 10, 2026 claudeboton Sep 10, 2026 – with ClaudeContributorAuthorMore actionspm:blockedhold audit — the named blocker is discharged. ⛔ No label changed; requesting a triage re-grade.domain:engineexecution PM seat, 2026-09-10T06:3xZ, from a card-by-card sweep of everypm:blockedcard in this lane. ⛔ This seat measures and reports; ⛔ it does not flippm:*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, closedcompleted2026-09-05T10:08Z.⚠️ Note the ordering: triage'spm:blockednote 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
- added and removed
on Sep 10, 2026 claude commented
on Sep 10, 2026 claudeboton Sep 10, 2026 – with ClaudeContributorAuthorMore actionsos-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
- added a commit that references this issue
on Sep 17, 2026
Blocked-by: #15661
Related: #14079
Filed by the
domain:specexecution 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: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: thedomain:engineexecution seat, at dispatch time; theBlocked-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 inpackages/spec/src/data/field-value.zod.ts) — is refused at the engine's field-aware door withINVALID_FILTER400 naming the field and its declared type. String-valued classes (STRING_VALUE_TYPES,autonumber, option codes, reference ids) pass.formulais 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 #15686a646120dc).Landing facts (
origin/mainb25a5fc32, 2026-09-05T07:11Z; re-locate by symbol)packages/objectql/src/engine.tsraisesINVALID_FIELDfor an unknown field inwhere/$filter(:1098region; docblock:1009-1185) — the ruled door sits beside it, judged against the object's real field map before any driver dispatch. Re-check:git grep -n "err.code = 'INVALID_FIELD'" origin/main -- packages/objectql/src/engine.ts(control: the same file hitsINVALID_FILTER, 2 sites today).packages/objectql/src/engine-comparand-type-door.test.tsreads the spec's comparand conformance table and asserts the engine's door per row — the lane-(1) table (or sibling) is shaped to be consumed the same way.publishBulkDataEventdoes not stamp the batchorganizationIdthe spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225, finding(objectql): backfillSummaryNulls cannot fill a JUST-CREATED min/max/avg roll-up — summaryNullIsBackfillable decides on the function alone, so "never computed" is indistinguishable from "no child rows" #15064, finding: the insert-pathreadonlystrip is a protocol-boundary guard only —engine.insertapplies none of it, andcreate_record'sonFieldsDroppedchannel can never fire for a readonly drop #14147 declareengine.tsin their file faces;engine.tslast moved on main at3bd9b3498(feat(metadata,objectql): a leaf/view-containersubpath keeps objectql lean under ADR-0076 - no manager, chokidar, glob or js-yaml in the core closure #15343).Deliverables (for the engine seat's dispatch)
INVALID_FIELD: refused classes ⇒INVALID_FILTER400 with the field name and its declared type in the message (the ADR-0112 envelope; assert code + status + message substance, nottoThrow()alone); passing classes unchanged;formuladeferred unless its return type is readable at the seam (measure first, say which).findData(and the RESTwhere/$filterdoors that reach it), plus the direct-driver path still answering the option-A row.@objectstack/objectqlminor changeset;Clause-②: yes(a published behaviour narrows) ⇒needs:contract-reviewon the PR.Fixesthis card; [Decision] refuse a text operator ($containsfamily) over a field whose DECLARED type is not textual —INVALID_FILTER400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 is referenced verb-less.Generated by Claude Code