Repository navigation
The #5869 list-comparand rule is enforced only at the engine seam: a scalar in/nin comparand still reaches a driver, and on mingo >= 7.2.3 it escapes as a raw TypeError #9228
Description
Activity
os-project-manager commented
on Aug 17, 2026 CollaboratorAuthorMore actionsClaim: PM loop round 1
Session:session_01HDA9nN6nXQngoQUAAzRdMb
Branch:claude/issue-9228-list-comparand-door
Worktree:objectstack-issue-9228
Domain:domain:spec
File surface:packages/spec/src/data/**(the door),packages/objectql/src/filter-comparand-shape.ts(delegation only — no second copy of the rule),packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts(test-only),.changeset/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: fable— mandatory, not a judgment call: this card changes contract accept/reject behaviour at thepackages/specfilter door.
Serial constraints cleared:#9205/ PR #9224 (spec lane — automation notify + email templates) and#9198/ PR #9221 (spec lane —packages/spec/src/uielement schemas), both in flight under a different seat (os-zhuang) and both disjoint frompackages/spec/src/data/**; no open PR touches the filter door orfilter-comparand-*. #9212 is the consumer waiting on this card, not a predecessor.Routing authority: routed and dispatched from a PM session under the maintainer's direct instruction (2026-08-10 standing direct-dispatch channel). Quoted verbatim rather than paraphrased, because the
domain:specseat is this lane's normal owner and the bypass is valid only for the cards the maintainer named:先立卡修我们这侧
立一个专项卡,你负责派发
Generated by Claude Code
os-project-manager commented
on Aug 17, 2026 CollaboratorAuthorMore actionsDispatch 1 died on a model quota wall before it produced anything:
You've reached your Fable 5 limit. Verified leftovers, so this is a clean re-dispatch and not a hand-over —git ls-remote origin 'refs/heads/claude/issue-9228*'returns nothing and no worktree exists.Re-dispatched at
model: opus, under the maintainer's standing quota exemption (2026-08-13), quoted verbatim:fable 如果用完了,可以用 opus
Amending the claim's
Container & modelline accordingly:M,mode:subagent,model: opus— the exemption's floor, nothing below it. The clause ② mandate itself is unchanged; only its dispatch tier is exempted, because fable was measured unavailable rather than judged unnecessary.Consequence, recorded now rather than discovered at queue time: this dispatch tier is below
CONTRACT_REVIEW_TIER(claude-fable-5, single-sourced fromscripts/pm/dispatch-gates.mjs). If the delivered diff touchespackages/spec/src/**, this seat will not queue the PR — it will record the finding, applyneeds:contract-review, and stop. The contract-review lane clears the label at the required tier; the quota exemption covers dispatch only and never the review, which exists precisely to compensate for a dispatch below the floor.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026 os-project-manager commented
on Aug 17, 2026 CollaboratorAuthorMore actions{ "issue": 9228, "status": "done", "branch": "claude/issue-9228-list-comparand-door", "pr": "https://github.com/objectstack-ai/objectstack/pull/9235", "premise_still_valid": true, "summary": "Moved the #5869 list-comparand SHAPE rule out of the engine and into packages/spec/src/data/filter-comparand-shape.ts, and called it from parseFilterAST (shape first, then the #7872 type door — the order the engine seam already applied). Exactly one implementation exists: @objectstack/objectql's assertListComparandShapes is now a delegating wrapper whose only remaining job is the engine's find('deal'): caller prefix, which it supplies through a new optional `context` argument on parseFilterAST (additive, defaulted, the same parameter normalizeFilterComparandTypes already takes). That argument is what keeps engine-filter-array-lowering.test.ts's pinned prefix now that the refusal happens during lowering; the engine's array-branch call to the gate became provably unreachable and was deleted, while the object branch keeps its call because that `where` never passes parseFilterAST. driver-memory is TEST-ONLY: valueFor now derives its value from the spec's own lowering instead of a hand-written spelling list (closing the class, not the `notin` instance), plus a new case pinning the refusal envelope. No driver behaviour patched, no mingo/manifest/lockfile change. All three PM assumptions measured and HOLD: A1 (objectql depends on spec, and the engine-only object/operation pair collapses into the existing `context` string — no engine concept dragged in), A2 (every authored membership rule in examples/**, content/**, packages/**, the seeded platform + plugin objects and objectui@375efb4 already carries an array; the only scalars in the tree are tests asserting the refusal), A3 (the shape was refused on no path before an engine — the type door's LIST branch guarded on Array.isArray and fell through in silence).", "tests": "All at HEAD c44f4f29f (the final commit; the gate union below was run on that tree). SUITES, vitest run --maxWorkers=2 on mingo 7.2.2: @objectstack/spec 408 files / 10855 tests passed; @objectstack/objectql 213 files / 3755 passed; @objectstack/driver-memory 25 files / 760 passed; @objectstack/driver-sql 99 files / 1734 passed (4 files, 57 tests skipped); @objectstack/metadata-protocol 77 files / 1722 passed; @objectstack/service-analytics 115 files / 1588 passed. The last three are the CONSUMER packages the dispatch asked for, plus the two other parseFilterAST callers. typecheck green for spec (incl. check:scripts-typecheck + check:test-typecheck), objectql, driver-memory, driver-sql. MINGO 7.2.4 MEASUREMENT: temporary `mingo: '7.2.4'` override in pnpm-workspace.yaml, pnpm install, resolved version confirmed 7.2.4, driver-memory = 25 files / 760 tests passed — escape gone. REVERSE VERIFICATION on that same 7.2.4, direction predicted RED before running and observed RED: with filter.zod.ts and memory-filter-ast-vocabulary.test.ts restored from origin/main and packages/spec rebuilt, `× expresses notin without dropping it / AssertionError: promise rejected \"TypeError: b.filter is not a function\" instead of resolving`, 1 failed | 63 passed — the reported CI failure, reproduced locally. Both files restored from the commit and re-run green. OVERRIDE REVERTED: pnpm-workspace.yaml and pnpm-lock.yaml restored with `git checkout HEAD --` and proved byte-identical by object id (d56d7c98… / 25a37d11… before and after), pnpm install re-run, mingo back at 7.2.2, git status clean, and the PR's 11 changed files contain no manifest or lockfile path. GATES — the dispatch named 8; re-deriving with `node scripts/pm/dispatch-gates.mjs` against the actual diff produced 21 path-matched plus 5 convention-triggered, ALL RUN, ALL GREEN. Named by the dispatch and run: check:test-source-alias, check:type-source-resolution, docs-audit/check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:type-check-debt (--re-measure, 33 ledger entries re-measured, none above its recorded number, run after a full `turbo run build` of packages/* so the refusal-on-unbuilt path was never taken), check:engine-double-contract, check:where-matcher. NEWLY DERIVED and additionally run: check:changeset-gate-self-tests, check:cross-package-test-inputs (both the pnpm and the scripts/ spelling), check:doc-formula-expressions, check:durability-log-level, check:empty-state, check:liveness, check:merge-driver, check:objectui-changeset, check:spec-parsed-alias, check:stack-collection-maps, check:strictness-ledger, check:variant-docs, check:exported-any, check:dual-source-exports, check:nul-bytes, check-adr-0087-registration, check-changeset-no-major, check-dev-prereqs, check-empty-changeset, check-engine-split-ratio. `pnpm --filter @objectstack/spec check:generated` clean after regenerating the two it proved stale — api-surface/data.json and export-origins/data.json each gained exactly one line, for the one new export. FIXTURE TRIAGE: two suites re-judged rather than mass-edited — memory-filter-ast-vocabulary.test.ts (its valueFor named three of four membership spellings; now derived from the spec's lowering) and filter-field-reference-lowering.test.ts (its `{ $field }` vocabulary sweep now records the five list-shaped spellings in a named `refused` list asserted against the exact expected set, not swallowed by a bare catch). No ablation was run — this card has no dogfood/dist leg.", "open_questions": [], "out_of_scope_findings": [ "filed as #9236: [finding][drivers] driver-memory's filter switch carries a `not in` arm for a spelling AST_OPERATOR_MAP does not define — unlabelled for the queue, frozen driver (#5499), no user-visible defect. Measured because the card and the dispatch both list `not in` among the accepted membership spellings and it is not one: the real set is in / nin / not_in / notin, which is what the new test asserts.", "NOT filed, recorded here as a scope boundary the contract reviewer should see: the sentence \"a list operator takes a list\" also exists as two AUTHORING-time Zod refinements — checkViewFilterRuleValueShape (packages/spec/src/ui/view.zod.ts, #6227) and its skill-trigger twin (packages/spec/src/ai/skill.zod.ts, #7113). Both were left untouched. They judge a different input shape (an authoring rule object spelling `not_in`, not a lowered `$nin`), fire at a different moment, and carry a deliberately different message tail, and both are outside the file surface this card declared. Ruling 1's \"exactly one implementation\" is satisfied for the runtime/compile face, which is what the ruling's own wording scopes; folding the publish-time mirrors in as well would be a separate card and a bigger blast radius." ] }
Generated by Claude Code
Generated by Claude Code
os-project-manager commented
on Aug 17, 2026 CollaboratorAuthorMore actionsReview of record — verified against GitHub and the PR head (
c44f4f29f), not against the report.Verdict: ACCEPT on substance. NOT queued — this PR stops at the contract-review gate.
Checked, with the reading:
- Form: draft, base
main, first body lineFixes #9228. 11 changed files, +768/−255.Check Changesetgreen,.changeset/list-comparand-shape-door.mdpresent. - Surface respected: no
package.json, nopnpm-lock.yaml, nopnpm-workspace.yamlanywhere in the diff — ruling 4 held under a measurement that required temporarily breaking it. The two generated files gained one line each, consistent with the one new export. - Ruling 1 ("exactly one implementation") holds on the runtime/compile face:
packages/objectql/src/filter-comparand-shape.tslost 242 lines, andassertListComparandShapesis now a seven-line wrapper delegating toassertListComparandShapesAtwith the engine's caller prefix. Read off the branch, not off the report. - Ruling 3 held:
driver-memoryis test-only in this diff. - The out-of-scope finding is filed as [finding][drivers] driver-memory's filter switch carries a
not inarm for a spelling the spec's AST vocabulary does not define #9236 — unassigned,finding, no domain label, correctly left for the triage seat to grade.
One reading contradicted the report, and it is the one that mattered most. The PR body states the PR "carries
needs:contract-review". It did not: at review time the labels weredocumentation, size/xl, protocol:data, tests, tooling. Whether it was never applied or the Auto Label run at 05:15:58 replaced the set, the effect is the same — the gate would have been invisible to the reviewer it exists for. Applied by this seat and read back.Gate, recorded per the queue rule. The diff touches
packages/spec/src/**(accept/reject behaviour atparseFilterAST, plus one new export) and the dispatch tier wasopus, belowCONTRACT_REVIEW_TIER(claude-fable-5). So this seat does not flip it ready, does not enable auto-merge, and does not queue it. The contract-review lane clearsneeds:contract-reviewat the required tier; only then does the PR return to the landing path. CI was still converging at review time (Test Core, ESLint, TypeScript, Dogfood, Temporal allin_progress) — an honest reading, and no ready/queue decision rests on it either way.For the contract reviewer, two things to read rather than assume:
- The accept/reject delta table in the PR body is the whole claim: newly refused only via a direct
parseFilterASTcall that does not continue to an engine; every other path is asserted to have refused this shape since fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869) #6209. That equivalence is the load-bearing claim of this card. - "A list operator takes a list" also exists as two authoring-time Zod refinements —
checkViewFilterRuleValueShape(ui/view.zod.ts, [finding][spec] ViewFilterRuleSchema.value 不按算子约束形状 —— 集合算子配标量在发布期通过,直到运行时才拿到 400 #6227) and the skill-trigger twin (ai/skill.zod.ts, [finding][spec] SkillTriggerConditionSchema.value is not operator-constrained either — the consumer coerces instead (dormant twin of #6227) #7113) — untouched here. I verified they judge the authoring rule object ({ field, operator, value }, spellingnot_in) at publish time rather than a lowered$nin, so ruling 1 is not breached. Whether those publish-time mirrors should eventually fold into the moved module is a separate card, not a rider on this one.
A correction that is mine to make publicly. This card's body and its dispatch prompt both list
not in(space-spelled) among the membership spellings the spec accepts. That was wrong, and it came from reading acaselabel in the frozen driver instead ofAST_OPERATOR_MAP. The accepted set isin/nin/not_in/notin. The dev measured it, then asserted the set from the spec's own lowering instead of a hand-written list, and filed the driver's stray arm as #9236 — the right handling in all three respects.Still open on this card: CI convergence, then the contract review, then landing. #9212 — the Dependabot group this unblocks — stays untouched and red until then.
Generated by Claude Code
- Form: draft, base
契约复审结论(skills 席代行,fable 档):PR #9235 的契约增量通过,无缺陷——本质是 declared=enforced 的恢复(拒绝对象均为 schema 本就禁止的形状)、窄口四钉齐全、单一实现、信封双半区断言完整;全文见 PR 评论。
needs:contract-review已清,卡可按正常流程入队。
Generated by Claude Code
os-project-manager commented
on Aug 17, 2026 CollaboratorAuthorMore actionsLanded — verified on
origin/main, not from the merge notification alone.- Merge commit:
739fe5b79— fix(spec): enforce the list-comparand rule at the shared compile face (The #5869 list-comparand rule is enforced only at the engine seam: a scalarin/nincomparand still reaches a driver, and on mingo >= 7.2.3 it escapes as a rawTypeError#9228) (fix(spec): enforce the list-comparand rule at the shared compile face, so a scalar in/nin cannot reach a driver #9235). git show origin/main:packages/spec/src/data/filter-comparand-shape.ts— the moved module is there, headed[#5869] The Filter Protocol's comparand-shape door.git show origin/main:packages/objectql/src/filter-comparand-shape.ts—assertListComparandShapesis the seven-line delegating wrapper on main, so the single-implementation ruling holds after the merge, not just in the PR.git show origin/main:packages/drivers/driver-memory/package.jsonstill pinsmingo: ^7.2.2— the surface restriction survived the landing; no manifest or lockfile rode along.
Contract review: passed at the required tier by a seat other than the dispatching one, verdict recorded on PR #9235 (05:31). Queue: entered and merged normally; every check run on
c44f4f29fwascompleted: successbefore it was queued.Consequences, for whoever picks these up:
- chore(deps)(deps): Bump the production-dependencies group with 19 updates #9212 (the Dependabot production group carrying
mingo7.2.2 → 7.2.4) is now unblocked. It is still showing its old red run against the pre-fix base; it needs a rebase to re-run against main. That trigger is deliberately not pulled here — it is the maintainer's call whether that upgrade group is taken at all, and this card only ever owned the blocker. - [finding][drivers] driver-memory's filter switch carries a
not inarm for a spelling the spec's AST vocabulary does not define #9236 — thenot inarm in the frozen driver — remains open, unassigned, awaiting a first grading from triage. - [finding] A gate carried only by a PR label is not durable:
needs:contract-reviewwas stripped from #9235 twice inside 40 minutes, with no push in between #9251 — the gate-carrier finding — remains open with its headline measurement publicly corrected; the class it names is still live percloud#1367and pending PR docs(pm): label writes re-read current labels in the same action — stale snapshots are invalid snapshots (#9226) #9238.
Generated by Claude Code
- Merge commit:
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 24, 2026 - added a commit that references this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Surfaced by the weekly Dependabot production group #9212 (
mingo7.2.2 -> 7.2.4), which is red on this and nothing else. The bump did not break us — it removed the third-party coercion that was hiding a coverage hole in an already declared rule.Measurement (verified on GitHub, not recalled)
PR #9212 @
45eaf8db3a03fe30d8798069439aad74e4c219c5, two independent jobs, same single failure:mainCI was green in the same window (runs 31987144246 / 31989563215 / 31991738619, allsuccess), so the red is introduced by the PR, not inherited from the base.mingo's own changelog for 7.2.4 names the change: "make
$elemMatchwith empty field and$in/$ninwith regex behave correctly (#613)". 7.2.2 tolerated a non-array$in/$ninoperand; 7.2.4 does not.Why this is our defect, not mingo's
The contract is already declared, and its gate already exists — it just is not on every path in.
FieldOperatorsSchema(packages/spec/src/data/filter.zod.ts) declares$in: z.array(z.any()),$nin: z.array(z.any()),$between: z.tuple([min, max]).packages/objectql/src/filter-comparand-shape.ts(数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 / PR fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869) #6209) enforces it —assertListComparandShapes, called fromengine.ts, answers400 INVALID_FILTER. Its header states the rule verbatim: "parseFilterASTlowers['status', 'not_in', 'done']to{ status: { $nin: 'done' } }without complaint — so a scalar reached the driver", and it explains why the gate went to the engine seam rather than into each driver (both driver families are under the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 investment freeze).BigIntcrashes with a raw mingoTypeError, and four other types silently answer zero rows #7872) sits at exactly the seam where a shape check would belong, and deliberately routes past it —packages/spec/src/data/filter-comparand-type.ts:So:
parseFilterAST()lowers a scalar membership comparand without complaint, and only a query that goes through the objectql engine meets the gate. A caller that reaches a driver directly —InMemoryDriver.find(), which is what this driver's own conformance suite does and what an embedder does — meets nothing. Until now that path was carried by mingo's coercion; from mingo 7.2.3 on it is an unhandled third-partyTypeErrorwith nocodeand nostatus, reaching the caller.That is the same class of escape #7872 closed for comparand types, one branch over: types are judged at the shared door, shape is not.
packages/drivers/driver-memory/src/memory-driver.ts:820-821is where it lands ({ [field]: { $nin: store(value) } }, no normalization, no refusal). The test only exercises it becausevalueFor(memory-filter-ast-vocabulary.test.ts:80-87) hands an array toin/nin/not_inbut a scalar to the equally-acceptednotinandnot inspellings — an accident that turned out to be the one probe covering this hole.Ruling (not open for re-adjudication by the dev)
driver-memoryanddriver-mongodbare under the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 investment freeze, and PR feat(spec): comparand-type door — the accepted literal comparand set, enforced once at the shared compile face for all five drivers (#7872) #8234 already set the precedent for this exact question ("enforced once at the shared compile face for all five drivers").assertListComparandShapeseither becomes the single implementation the door calls, or delegates to the one that moved.driver-memorychanges are test-only here (construct valid input for all membership spellings). No driver behaviour patch.mingoversions,package.jsonorpnpm-lock.yamlin this PR. The bump belongs to Dependabot chore(deps)(deps): Bump the production-dependencies group with 19 updates #9212, which is expected to go green because of this card.Premises — verify before you build, and report a fork instead of working around them
parseFilterASTcan reach, i.e. inpackages/spec, with@objectstack/objectqldelegating to it.objectqldepends onspec, so the reverse import is not available. If the assertion cannot move without dragging engine-only concepts intospec, that is a fork — report it, do not invent a third home.parseFilterASTrefuses input that today survives to the engine gate anyway (which already answers 400), but stored view metadata, examples, docs fixtures andobjectuiview definitions must be checked, not assumed. Grep for authoredin/nin/not_in/notin/not inrules with non-array values acrossexamples/**,content/docs/**,packages/**/fixtures/**and the objectui view metadata. If real authored usage exists, stop and report the fork with the sites: normalizing a scalar to a one-element list is then a live option and that is the maintainer's call, not yours.If either premise fails: report
premise_still_valid: falsewith the measurement. Do not hard-do it, and do not quietly switch to the other option.Suggested route (advisory — measurement beats it)
In
filter-comparand-type.tstheLIST_COMPARAND_OPERATORSbranch already knows the operator is list-shaped; theArray.isArray(comparand)guard silently falls through today. Make the else arm a loud refusal reusing the existing envelope (INVALID_FILTER, 400) and the #5869 wording, then haveassertListComparandShapesdelegate. Keep the message's front-loaded shape — the 500-char client bound is real (#5423).Acceptance
parseFilterAST([['name', 'notin', 'alpha']])(and thenin/not_in/not inspellings) answers a codedINVALID_FILTER/ 400 refusal, with a test that asserts bothcodeandstatus, not just that it throws.pnpm --filter @objectstack/driver-memory testgreen, withmemory-filter-ast-vocabulary.test.tsexercising every membership spelling on valid input.engine-comparand-type-door.test.ts, the 数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 suites) — one implementation, unchanged verdicts.mingo@7.2.4via a temporary local override to confirm the escape is gone, then revert the override — report the reading in the PR body, commit no manifest or lockfile change.node scripts/pm/dispatch-gates.mjsat dispatch time, re-take for your actual diff):pnpm check:test-source-alias,pnpm check:type-source-resolution,node scripts/docs-audit/check-affected-docs.mjs; and because a test file moves:pnpm check:query-options-erasure,pnpm check:type-check-coverage,pnpm check:type-check-debt(needs the workspace built first),pnpm check:engine-double-contract,pnpm check:where-matcher.Re-check commands for the premises above
Links
BigIntcrashes with a raw mingoTypeError, and four other types silently answer zero rows #7872, closed by PR feat(spec): comparand-type door — the accepted literal comparand set, enforced once at the shared compile face for all five drivers (#7872) #8234not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 / PR fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869) #6209