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. added
    bugSomething isn't working
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Sep 24, 2026
  3. 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

  4. 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

  5. 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 路径,漏了它们门禁仍不会红 |"
    }

  6. 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.

  7. 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

bugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions