Skip to content

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

@os-project-manager

Surfaced by the weekly Dependabot production group #9212 (mingo 7.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:

FAIL packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts
  > InMemoryDriver filter vocabulary <-> VALID_AST_OPERATORS > expresses notin without dropping it
AssertionError: promise rejected "TypeError: b.filter is not a function" instead of resolving
  > $in  node_modules/.pnpm/mingo@7.2.4/.../operators/_predicates.js:95
  > $nin node_modules/.pnpm/mingo@7.2.4/.../operators/_predicates.js:99

mingo's own changelog for 7.2.4 names the change: "make $elemMatch with empty field and $in/$nin with regex behave correctly (#613)". 7.2.2 tolerated a non-array $in/$nin operand; 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.

  1. FieldOperatorsSchema (packages/spec/src/data/filter.zod.ts) declares $in: z.array(z.any()), $nin: z.array(z.any()), $between: z.tuple([min, max]).
  2. 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 from engine.ts, answers 400 INVALID_FILTER. Its header states the rule verbatim: "parseFilterAST lowers ['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).
  3. The spec-side comparand-type door added by PR feat(spec): comparand-type door — the accepted literal comparand set, enforced once at the shared compile face for all five drivers (#7872) #8234 (driver-memory has no policy for an unsupported comparand TYPE: a BigInt crashes with a raw mingo TypeError, 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:
} else if (LIST_COMPARAND_OPERATORS.has(op) && Array.isArray(comparand)) {
  // Each member is a comparand in its own right (#5234). A non-array
  // comparand here is a SHAPE defect owned by the engine's #5869 gate.

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-party TypeError with no code and no status, 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-821 is where it lands ({ [field]: { $nin: store(value) } }, no normalization, no refusal). The test only exercises it because valueFor (memory-filter-ast-vocabulary.test.ts:80-87) hands an array to in / nin / not_in but a scalar to the equally-accepted notin and not in spellings — an accident that turned out to be the one probe covering this hole.

Ruling (not open for re-adjudication by the dev)

Premises — verify before you build, and report a fork instead of working around them

  1. Dependency direction allows the sink. The rule has to end up somewhere parseFilterAST can reach, i.e. in packages/spec, with @objectstack/objectql delegating to it. objectql depends on spec, so the reverse import is not available. If the assertion cannot move without dragging engine-only concepts into spec, that is a fork — report it, do not invent a third home.
  2. Nothing in-tree authors a scalar membership comparand. Tightening parseFilterAST refuses input that today survives to the engine gate anyway (which already answers 400), but stored view metadata, examples, docs fixtures and objectui view definitions must be checked, not assumed. Grep for authored in / nin / not_in / notin / not in rules with non-array values across examples/**, 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: false with 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.ts the LIST_COMPARAND_OPERATORS branch already knows the operator is list-shaped; the Array.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 have assertListComparandShapes delegate. Keep the message's front-loaded shape — the 500-char client bound is real (#5423).

Acceptance

  • parseFilterAST([['name', 'notin', 'alpha']]) (and the nin / not_in / not in spellings) answers a coded INVALID_FILTER / 400 refusal, with a test that asserts both code and status, not just that it throws.
  • pnpm --filter @objectstack/driver-memory test green, with memory-filter-ast-vocabulary.test.ts exercising every membership spelling on valid input.
  • The engine path keeps its existing behaviour and its existing tests (engine-comparand-type-door.test.ts, the 数据 API:集合算子 not_in/in 的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 suites) — one implementation, unchanged verdicts.
  • Measured, not assumed: run the driver-memory suite once against mingo@7.2.4 via 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.
  • Local gates for this surface (taken from node scripts/pm/dispatch-gates.mjs at 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.
  • A patch changeset is required (published packages change).

Re-check commands for the premises above

git fetch origin main
git show origin/main:packages/spec/src/data/filter-comparand-type.ts | grep -n "SHAPE defect owned by"
git show origin/main:packages/objectql/src/filter-comparand-shape.ts | grep -n "export function assertListComparandShapes"
git grep -n "notin\|not_in" origin/main -- examples content packages/*/fixtures

Links

Activity

  1. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim: 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 the packages/spec filter door.
    Serial constraints cleared: #9205 / PR #9224 (spec lane — automation notify + email templates) and #9198 / PR #9221 (spec lane — packages/spec/src/ui element schemas), both in flight under a different seat (os-zhuang) and both disjoint from packages/spec/src/data/**; no open PR touches the filter door or filter-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:spec seat is this lane's normal owner and the bypass is valid only for the cards the maintainer named:

    先立卡修我们这侧

    立一个专项卡,你负责派发


    Generated by Claude Code

  2. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Dispatch 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 & model line 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 from scripts/pm/dispatch-gates.mjs). If the delivered diff touches packages/spec/src/**, this seat will not queue the PR — it will record the finding, apply needs: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

  3. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor
    {
      "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

  4. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Review 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 line Fixes #9228. 11 changed files, +768/−255. Check Changeset green, .changeset/list-comparand-shape-door.md present.
    • Surface respected: no package.json, no pnpm-lock.yaml, no pnpm-workspace.yaml anywhere 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.ts lost 242 lines, and assertListComparandShapes is now a seven-line wrapper delegating to assertListComparandShapesAt with the engine's caller prefix. Read off the branch, not off the report.
    • Ruling 3 held: driver-memory is test-only in this diff.
    • The out-of-scope finding is filed as [finding][drivers] driver-memory's filter switch carries a not in arm 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 were documentation, 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 at parseFilterAST, plus one new export) and the dispatch tier was opus, below CONTRACT_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 clears needs:contract-review at 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 all in_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:

    1. The accept/reject delta table in the PR body is the whole claim: newly refused only via a direct parseFilterAST call 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.
    2. "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 }, spelling not_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 a case label in the frozen driver instead of AST_OPERATOR_MAP. The accepted set is in / 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

  5. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    契约复审结论(skills 席代行,fable 档):PR #9235 的契约增量通过,无缺陷——本质是 declared=enforced 的恢复(拒绝对象均为 schema 本就禁止的形状)、窄口四钉齐全、单一实现、信封双半区断言完整;全文见 PR 评论。needs:contract-review 已清,卡可按正常流程入队。


    Generated by Claude Code

  6. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Landed — verified on origin/main, not from the merge notification alone.

    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 c44f4f29f was completed: success before it was queued.

    Consequences, for whoever picks these up:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions