Skip to content

objectql having answers an array in the equality slot by JS coercion (having: { total: [5] } is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with a named landing site. Measured by the at-tier contract review of PR #19882 (record 5808368753, ①.10), filed by the domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d) as remedy (a) of that record. Filed unassigned and unlabelled: routing and grading are triage's.
Hand that acts: the lane triage routes this to (the landing is packages/objectql), in one claim, after PR #19882 lands.
Dedupe (including closed): having filter array comparand implicit equality applyHaving aggregate having ungated → 11 hits. The open ones are #19888 (the analytics normalizer, a different face) and #19757 (this ruling's own card). Closed #7158, #6520 and #5905 are earlier having gates for other operators. None covers the equality-slot array.

The defect

Ruling 乙 on #19757 (5793368540): an array in the implicit-equality slot is refused at the shared comparand-shape face 「for every driver at once」. PR #19882 gates where (engine.ts at its head :866, :939) and aggregations[i].filter (:14776), but hands ast.having to applyHaving ungated (:14885, :14929).

packages/objectql/src/having-filter.ts:357-363 (at origin/main at review time) sends an array condition into the implicit-equality arm value == condition, and the $eq arm compares with != (:396). By JS coercion 5 == [5] and 'a' == ['a'] are both true. So having: { total: [5] } still answers, on every driver, where the ruling says the shape is refused.

compile-surfaces.md marks this half-face as having no conformance table, so no gate goes red for it.

Remedy shape (for the implementing round to confirm)

Route ast.having through the same shared comparand-shape face the engine already applies to where and aggregations[i].filter, with the same INVALID_FILTER / 400 envelope and the same remedy text. Pin: having: { total: [5] } and having: { total: { $eq: [5] } } are refused, scalars pass.

Dedupe words: having array equality slot · applyHaving ungated · having-filter implicit equality coercion · ruling 乙 having


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #19882

    分诊首次定级:priority:p2 · bug · domain:engine · pm:blocked —— objectql 的 having 靠 JS 宽松比较回答等号位置上的数组,having: { total: [5] } 判为真

    Path: packages/objectql/src/having-filter.ts(:357-364 的隐式等值分支)+ packages/objectql/src/engine.ts(ast.having 交给 applyHaving 的两处)

    Triage: lands in the objectql aggregate having evaluator ⇒ domain:engine, bug, priority:p2, pm:blocked Blocked-by #19882 (open, executes ruling 乙); rationale: ruling 乙 refuses an array in the equality slot "for every driver at once", PR #19882 gates where and aggregations[i].filter but not having, and having answers the refused shape by JS == coercion on every driver with no conformance table to go red — the fix routes having through the face PR #19882 lands, so it follows that PR.

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ,座位贴 #6015),2026-09-24T14:25Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),在 main(2c1011b01b)上核对了代码,并读了 PR #19882 的正文。

    本席核对

    为什么挂 pm:blocked

    修法是让 ast.having 走 PR #19882 落地的同一个共享校验,用同样的 INVALID_FILTER / 400 和同样的处理说明。那个校验还在 PR 里。⇒ Blocked-by #19882,合并后即可派发。

    定级说明

    p2:这是结果错误(一个本应被拒绝的写法却返回了行),但写法本身少见,也不涉及权限。

    执行要点

    • 钉子:having: { total: [5] } 和 having: { total: { $eq: [5] } } 被拒绝;标量照常通过。
    • 同一轮在 compile-surfaces.md 给 having 这一面补上一致性表,否则下次仍然没有检查会变红。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    ContributorAuthor

    Unblock re-derivation: pm:blocked → pm:queue · 2026-09-24T23:57Z

    Acting on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced by check-half-states.mjs H19 on this sweep.

    State: pm:blocked → pm:queue; domain:engine, priority:p2 unchanged; no assignee.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 22
    Session: session_01Bvd69VPa6puiNzzPUroDBx
    Branch: claude/issue-19974-having-array-equality
    Worktree: objectstack-issue-19974
    Domain: domain:engine
    Seat: domain:engine#1
    File surface:

    Stop on breach and explain in the report. ⛔ Not .claude/**: references/compile-surfaces.md is governed surface. The line it should gain is reported, and the seat routes it. ⛔ Not the option-visibility threading in engine.ts (PR #20079). ⛔ Not service-analytics (PR #20058). ⛔ Not packages/spec.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: no (narrowing)
    Thread-read: 5824278987
    Serial constraints cleared: at 2026-09-25T02:24Z, #19882 (the blocker) has landed (unblock 5824278987). Of the 18 open PRs, PR #20079 (#18783, this seat) edits engine.ts in the option-visibility threading, which is disjoint from the aggregate having path (~15706). PR #20058 edits service-analytics' own comparand face. Neither touches having-filter.ts.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 19974,
    "status": "done",
    "branch": "claude/issue-19974-having-array-equality",
    "pr": "#20097",
    "session": "session_01Bvd69VPa6puiNzzPUroDBx — this run's harness-stamped id (mode:subagent = the PM seat's id)",
    "premise_still_valid": true,
    "summary": "ObjectQL.aggregate now calls the shared comparand-shape face (the objectql wrapper assertListComparandShapes, which delegates to @objectstack/spec/data) on query.having, path seeded at having, once, ahead of both applyHaving doors, the driver and the middleware chain. That is the same face where and aggregations[i].filter already take on this verb. No second face, and having-filter.ts is untouched. Measured on base 9b8c74c through the public engine.aggregate on driver-memory and driver-sqlite-wasm, both doors: all 20 shapes the face refuses on where ANSWERED on having (e.g. { total: [500] } kept c1 by JS 500 == [500]). At head 5649560 all 20 are refused with the face's INVALID_FILTER / 400, and the message is byte-identical to the where refusal apart from having. in place of where. in the path. Scalars and the declared list/range shapes answer exactly as before. Card assignee not written (it was os-sales on arrival). The PR assignee was set to os-sales via label-write, per os-dev.md at base (see deviations).",
    "tests": "Head 5649560 unless stated. (1) objectql vitest run --project local: 'Test Files 313 passed (313) / Tests 5320 passed (5320)', run on 0052ce2, whose engine.ts blob 7830b0cee3 equals head; the only later change is the new test file's types, re-run at head. --project repo: 1 file, 5 passed. (2) objectql typecheck exit 0; check:test-typecheck 'OK — 40 file(s) / 234 error(s) / 65 pinned signature(s)', unchanged; tsc -p tsconfig.test.json --listFiles includes the new file (1 hit, 314 test files). (3) New file engine-aggregate-having-comparand-shape.test.ts: 37 passed, plus engine-aggregate-having.test.ts 5 passed. (4) Consumer suites that assert having: metadata-protocol protocol.query-param-arity.test.ts 46 passed; plugin-security predicate-guard.test.ts 10 passed; rest list-view-grouping-query-door.test.ts 33 passed, with objectql dist rebuilt and rest^... built (turbo 24 tasks, 14 cached). (5) Real-driver scratch measurement, head vs base: 20/20 refused-set rows went from ANSWERED to REFUSED INVALID_FILTER/400 on having, on both drivers and both doors; the 6 controls' output is diff-identical. (6) Ablation on head 5649560: scripts/ablation-replace.mjs replaced the one gate line with a marker; the anchor hit once, blob 7830b0cee3 -> 380a8f6fd9, grep -c anchor 1->0 and marker 0->1. The run was 'Tests 24 failed | 18 passed (42)'. Red: 20 parity rows + 3 FILTER_COMPARAND_TYPE_CASES shape rows + the empty-grouped-set pin. Green: 10 pass-through controls, the $ne held-to-face pin, 2 partition checks, 5 pre-existing having tests. An EXIT/INT/TERM trap restored with git checkout HEAD --: 'RESTORED blob=7830b0cee3… diff-HEAD-lines=0', git status clean. No dist preflight: the test imports ./engine.js from src, so no package exports are on the resolution path. The same ablation on 6992738 gave the same 24/18. (7) Lint, a declared narrowing: eslint --no-inline-config --format json on the 2 changed .ts files gave 2 files, 0 errors, 0 warnings. Population: --print-config returns a config for both, so neither is ignored. Invariance: eslint.config.mjs sets no parserOptions.project and no typed rule, so no untouched file's verdict can move.",
    "mcp_calls": "0 — no MCP GitHub tool was called; reads went through single-card REST GETs (issue 19974 + comments, comment 5793368540, pull 19882, comment 5808368753, issue 19886, issue 19757 comments, pulls?head=, pull 20097)",
    "api_writes": "3 — all through the fleet-write relay as objectstack-fleet[bot], each ONE POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft, PR #20097; body read back byte-identical, 12879 bytes); (2) label-write --issue 20097 --assign os-sales → POST /repos//issues/20097/assignees (read back: assignees os-sales; labels documentation, size/m, tests, tooling were set by others and not written here); (3) this os-dev-report comment → POST /repos//issues/19974/comments. Not REST: git push to claude/issue-19974-having-array-equality (5 pushes, the first the empty-branch probe).",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a (+b) · having resolves no { $field } reference. At head, driver-memory: groups c1 (total 500, max_cap 50) and c2 (total 900, max_cap 5000); having: { total: { $gt: { $field: 'max_cap' } } } → 200 [] where c1 should pass. Contract: ADR-0087 entry filter-between-field-reference-endpoint-refused, replacement 「{"$gte": {"$field": "a"}} … which is the position #5222 compiles on every face」 and 「A reference remains legal, unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte」. The face's $between field-reference refusal, now shown on having, prescribes that spelling, and the changeset says having does not evaluate it. Seam: spec:FieldReferenceSchema ({ $field }) → runtime:packages/objectql/src/having-filter.ts checkCondition · dedupe: having $field reference · applyHaving field reference silent · having column-to-column · having-filter $field",
    "class: a · the comparand-TYPE door is not run on having. At head, driver-memory: having: { total: { $eq: { v: 1 } } } → 200 [], while where: { amount: { $eq: { v: 1 } } } → INVALID_FILTER/400 'Filter comparand at where.amount.$eq is a plain object'. In-process { total: undefined } and $eq: new Map() also answer []. The dispatch forbade a second face here. Seam: spec:normalizeFilterComparandTypes → runtime:packages/objectql/src/engine.ts ObjectQL.aggregate (query.having) · dedupe: having comparand type door · having plain object comparand · normalizeFilterComparandTypes having · having undefined comparand",
    "class: a · FilterArray sugar in having answers no group. At head, driver-memory: having: [['total','>',100]] → 200 [], while where: [['amount','>',100]] is lowered and answers c1, c2. applyHaving walks the array's index keys as column names. Seam: spec:QuerySchema.having (FilterConditionSchema; FilterArray is input-only sugar) → runtime:packages/objectql/src/engine.ts ObjectQL.aggregate · dedupe: having FilterArray · having array sugar lowering · having filter array empty",
    "class: a · having's own walker refusals depend on the data. At head, driver-memory: having: { total: { $median: 1 } } → INVALID_FILTER/400 on a populated grouped set and 200 [] on an empty one. Same for { customer_id: { $icontains: '' } }. applyHaving evaluates per aggregated row (rows.filter), so an empty set never reaches the refusal. Seam: runtime:packages/objectql/src/having-filter.ts applyHaving · consumer: engine.aggregate · dedupe: having unknown operator empty set · applyHaving empty rows refusal · having refusal data-dependent",
    "carrier: #19886's claim (ruled letter A, pm:dispatched) · $ne with an array still answers on having (c2, c3 by == coercion). It is not an arm of the face yet. Once #19886 lands it on the face, having inherits it through this PR's call; the held-to-the-face pin stays green either way · noted, not filed",
    "carrier: 承接者:无 · ADR-0087 entry filter-equality-array-comparand-refused (packages/spec, outside the claim) lists the doors that refuse the shape 'at this release' without having · noted in PR Acceptance notes, not filed",
    "carrier: 承接者:无 · the having-filter.ts header 'the only face no conformance table covers' is now true of the logic axis only; not edited (claim scope) · noted in PR Acceptance notes, not filed"
    ],
    "gates": {
    "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head 5649560: 64 families (stderr names repo and commit; the list is identical to the one derived at 0052ce2). The PM list (2c1011b, 53 lines) is a subset.",
    "reconciliation": "--ran: '64 derived, 62 run, 2 NOT-MEASURED, 0 UNRUN' — '✓ dispatch-gates --ran: 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)'",
    "exit_0": 62,
    "not_measured": [
    "pnpm check:dual-build-cjs-loads — exit 3 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/'; needs the whole workspace built; CI runs it",
    "pnpm check:type-check-debt — exit 3 'check-type-check-coverage: PREREQUISITE NOT MET'; same reason"
    ],
    "red_then_fixed": "pnpm check:query-options-erasure went red on 0052ce2: 'test surface grew 236 → 243 site(s)', from as any on the new test's option bags. 5649560 types the options (off-contract bags cast through unknown to EngineAggregateOptions), and the gate now reads '✓ … test surface: 236 site(s) in 47 file(s) — at the ceiling'",
    "key_verdicts": [
    "check-adr-0087-registration --base: '✓ 1 declared-breaking changeset(s)' · '.changeset/19974-having-comparand-shape-face.md [BREAKING+bang+clause-②-narrowing] not-required (already-registered)'",
    "check-changeset-no-major --base: '✓ This diff introduces no major bump.' (clause-② level axis: NOT APPLICABLE locally, no pull_request payload)",
    "check-empty-changeset --base: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'",
    "check:nul-bytes: 'OK (scanned 9492 text file(s) … no raw ASCII control bytes)'",
    "check:engine-double-contract: exit 0",
    "node scripts/check-issue-citations.mjs --base 9b8c74c: '✅ check-issue-citations: every citation this change adds resolves' (2 judged)"
    ],
    "ci": "in_progress — not waited on, per os-dev.md"
    },
    "line_budget": "n/a — no skills/** in the diff",
    "deviations": [
    "PR assignee. The dispatch lists label-write only 'if a label is due'. os-dev.md on main at base 9b8c74c (landed by #20066) says 'PR assignee = 卡的:--issue PR_NUMBER --assign LOGIN' and counts it in the label-write budget, and that file wins over the dispatch. So the one label-write set PR #20097's assignee to os-sales. No label was due: the dispatch names none, and skip-changeset does not apply because the diff publishes. The os-dev.md this run read first was the shared checkout's stale 2c1011b copy, which predates that clause.",
    "compile-surfaces.md was not edited, per the seat amendment; its line is in compile_surfaces_line.",
    "Ablation: no ablation-dist-preflight, because the mutated module is imported from source by a relative ./engine.js, not through @objectstack/objectql's exports.",
    "The objectql full local suite ran on 0052ce2, not on the final head. engine.ts is byte-identical between them; the later commit changed only the new test's types, and that file was re-run at head."
    ],
    "files_changed": [
    "packages/objectql/src/engine.ts (+24: the gate call and its comment)",
    "packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts (new, 37 tests)",
    ".changeset/19974-having-comparand-shape-face.md (new, @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing))"
    ],
    "compile_surfaces": {
    "1 driver-sql (incl. driver-sqlite-wasm, local driver-turso)": "not reached by having: no driver reads the clause and the engine evaluates it after aggregation. Unchanged.",
    "2 turso RemoteTransport": "not reached by having. Unchanged.",
    "3 service-analytics read-scope-sql": "not reached by having. Unchanged.",
    "4 service-analytics filter-normalizer": "not reached by having; analytics sends no having to the engine. Unchanged.",
    "5 formula": "not reached by having. Unchanged.",
    "half-face objectql having-filter": "CHANGED — behind the shared comparand-shape face at engine.ts:15629, ahead of both applyHaving doors. Where/having parity table (20 arms) plus a leg driven from FILTER_COMPARAND_TYPE_CASES' shape-face door-refusal rows, both doors.",
    "driver-memory / driver-mongodb": "not reached by having. Unchanged."
    },
    "having_doors": [
    "packages/objectql/src/engine.ts:15730 (head) — native door: return applyHaving(aggregated, ast.having) after drv.aggregate()",
    "packages/objectql/src/engine.ts:15774 (head) — in-memory fallback door: return applyHaving(applyInMemoryAggregation(raw, ast, tz), ast.having)",
    "gate for both: engine.ts:15629 assertListComparandShapes(object, 'aggregate', query.having, 'having'), before getDriver, executeWithMiddleware and either door",
    "not a door: engine.ts:15648 having: query.having on opCtx.ast only carries the clause for plugin-security's predicate guard (field walk). Nothing reassigns ast.having (git grep '.having =' finds no package source hit).",
    "applyHaving / matchesHaving are not exported from @objectstack/objectql's . or ./core entry, and no driver reads having. REST reaches this through metadata-protocol protocol.ts forwarding having to engine.aggregate. Repo-wide, applyHaving is called only from engine.ts (2 sites) and tests."
    ],
    "refused_set_parity": {
    "face_arms_measured": "20 rows: equality-slot array, empty array, $eq array, array nested in $or / $and / $not, scalar $in, scalar $nin, null $in member, null $nin member, $gt/$gte/$lt/$lte null, scalar $between, one-bound $between, null / '' / undefined / { $field } $between bound",
    "before_9b8c74c6c5": {
    "where": "20/20 refused INVALID_FILTER/400 (aggregate('order'): prefix), driver-memory and driver-sqlite-wasm",
    "having": "0/20 refused — all answered, on both drivers and both doors (e.g. { total: [500] } → c1; $nin: 'c1' → every group; $lt: null → none)"
    },
    "after_5649560428": {
    "where": "20/20 refused, unchanged",
    "having": "20/20 refused INVALID_FILTER/400, message === where message with where. → having., on both drivers and both doors, before any driver read"
    },
    "outside_the_face": "$ne with an array: where is refused by the drivers themselves (driver-memory: no aggregate('order'): prefix; driver-sqlite-wasm: detail withheld), having answers c2, c3, both before and after. It follows the face's answer (#19886 carries the ruling)"
    },
    "compile_surfaces_line": "| 半面 | objectql having-filter | packages/objectql/src/having-filter.ts:279 / :292(applyHaving / matchesHaving);共享形状面入口 packages/objectql/src/engine.ts:15629(ObjectQL.aggregate 内 assertListComparandShapes(object, 'aggregate', query.having, 'having'),在两个 applyHaving 门之前) | 聚合后过滤。算半面是因为词表是子集,但申报义务不打折。比较值形状轴已有 conformance:packages/objectql/src/engine-aggregate-having-comparand-shape.test.ts(where/having 对等表覆盖共享形状面全部拒收臂,并以 FILTER_COMPARAND_TYPE_CASES 中形状面的 door-refusal 行驱动 HAVING 路径,两个门都跑);逻辑轴(FILTER_LOGIC_CASES)与比较值类型门仍不驱动 HAVING 路径,漏了它们门禁仍不会红 |"
    }

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20097 at 1a47a60b

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T04:52Z. Reviewed on GitHub against references/review-checklist.md, not from the dev's os-dev-report.

    • Shape: the first line is Fixes #19974, and it is the only closing keyword. The body and the changeset declare Clause-②: no (narrowing). The changeset grades @objectstack/objectql minor, with fix(objectql)!:, one BREAKING banner, the FROM → TO table, and ADR-0087 not-required (already-registered …).
    • Scope: 3 files, +400/−0: engine.ts (+24), the new pin, and the changeset. Not governed (check-governed-merges --pr 20097). packages/spec and having-filter.ts are untouched. The shared-face-only limit in the dispatch holds.
    • Ruling 乙 (5793368540) executed for the one face no gate reached: engine.aggregate({ having }) now walks the shared comparand-shape face once, ahead of the driver, the middleware chain and both applyHaving doors. The refusal is byte-for-byte the where refusal, rooted at having.
    • Contract review of record: PASS at 5649560428 (5826694842), and delta PASS at 1a47a60b (5826950623).
      • Refused-set parity: at base 0/100 face cells were refused on having; at head 100/100 are, with 0 message mismatches against where.
      • 0 of 164 non-face controls changed.
      • The error's own keys are code,status, with 0 driver calls on a refusal.
      • The pins go 24 red on the base engine.
      • The patch round's two changeset cells are measured TRUE.
    • Out of scope, already filed: the four doors having still lacks are objectql having does not take the rest of where's filter doors: a $field reference is never resolved, the comparand-TYPE door does not run, FilterArray sugar answers no group, and its own refusals fire only on a non-empty grouped set #20099, which unblocks when this card closes. The dated ADR-0087 entry reason needs a spec docs-only carrier. The compile-surfaces.md half-face note rides the seat's next Tier S batch.
    • CI at this head: 34 runs, 31 success and 3 skipped, and every required context is green. git merge-tree against current main is clean.

    Landing: ready plus auto-merge through the queue now.

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20097, verified on main

    domain:engine#1, session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-25T05:10Z.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions