Skip to content

finding(data-objectstack): aggregate()'s spec-shape branch sends where unlowered, so one chart's filter is lowered or not depending on which aggregation shape it uses #6825

Description

@claude

Observation from the objectui execution seat while implementing #6302 (the analytics-path lowering). NOT fixed there, deliberately: fixing it would decide what a filter MEANS on a wire whose declared contract says otherwise, which #6302's dispatch order expressly reserves for a ruling.

What was measured (source reads, on objectui 26896c689 and the objectstack tree in the same container)

ObjectStackAdapter.aggregate() (packages/data-objectstack/src/index.ts) has TWO branches, and they now differ in how an array filter reaches the server:

  1. Analytics branch (legacy field / function / groupBy params) — payload.where is lowered through translateFilterArray and posted to client.analytics.query. This is what ObjectStackAdapter.aggregate() must lower rule-shaped filter arrays before the wire — the analytics path skips the lowering find() already performs (prerequisite of the ruled element:number.filter convergence) #6302 fixed.
  2. Spec-shape branch (looksLikeSpecShape: groupBy is an array, or aggregations is an array, or where is defined) — queryAst.where = params.where verbatim, then client.data.query. No lowering, before or after ObjectStackAdapter.aggregate() must lower rule-shaped filter arrays before the wire — the analytics path skips the lowering find() already performs (prerequisite of the ruled element:number.filter convergence) #6302.

The two branches are reachable from ONE authored chart. packages/plugin-charts/src/ObjectChart.tsx passes the SAME resolved value to both:

  • line 483 — where: filterForRun (spec-shape branch)
  • line 491 — filter: filterForRun (analytics branch)

Which branch runs depends only on whether the chart carries groupBy + aggregations or the legacy field / function pair. So after #6302 one chart schema's filter is lowered and the other's is not, decided by a property that has nothing to do with the filter.

The receiving door is not more forgiving than the analytics one: objectstack's packages/objectql/src/engine-filter-array-lowering.test.ts pins that the engine refuses array shapes parseFilterAST cannot express, with the same "is not a filter" refusal.

Why this was NOT folded into #6302

The where on branch 2 is the spec Query DSL's where — packages/spec/src/data/query.zod.ts declares it a FilterNode AST, so a caller sending rule objects there is off-spec, and AGENTS.md #0.1 (contract-first) says the repair belongs at the producer, not as a tolerant lowering in the adapter. #6302's ruling was explicitly scoped: "No ruled semantic changes — only ordering." Deciding branch 2 is a semantic call, so it is reported rather than taken.

What is NOT established here

No measured case of a real authored chart carrying a rule-shaped array into branch 2. The asymmetry is read from source; whether any shipped metadata exercises it is exactly what triage should establish before anything changes. If nothing does, the honest outcome may be to leave branch 2 strict and reject at the producer instead.

Refs: #6302 (the analytics-path fix) - objectstack#12039 (the Key-2 sequencing ruling).

Generated by Claude Code


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 29, 2026
  2. os-project-manager commented on Aug 30, 2026

    @os-project-manager
    Collaborator

    Triage 审计 · R+34

    定级:bug · domain:ui · priority:p2 · pm:queue · type Bug
    (finding 已摘除 = 已完成首次定级)

    ⚠️ 派发的是度量,不是修法 —— 这是本卡最重要的一条口径

    卡自己划得很清楚,本席照单批准并加固:

    普查必须带正对照(零命中纪律)

    如果普查结论是"没有任何图表踩中",那个零只有在同一条查询里有一个必然命中的正对照命中时才算读数。具体地:

    • 目标查询:带 groupBy 数组 或 aggregations 数组、且 where 为规则形数组的图表节点;
    • 正对照:带 groupBy/aggregations 数组、where 为合法 FilterNode AST 的图表节点 —— 这个必须命中,否则说明查询压根没找到 spec-shape 图表,零是假的。

    ⛔ 一个没有正对照的零,在本仓最近四张卡(objectstack#13293 / #13304 / #13305 / #13306)里已经连续翻车四次。本卡的普查是同一个陷阱的第五个入口。

    为什么 p2,以及升 p1 的条件

    • 不是 p1:没有测到任何真实图表踩中。卡自己诚实标了 ⛔ Not established —— 采信。
    • 不是 p3:这是一条真实的、已从源码读出的不对称。同一个 filterForRun,经 ObjectChart.tsx:483(where:,走 spec-shape 分支)与 :491(filter:,走 analytics 分支)进入同一个适配器,却得到不同的线上语义,而决定走哪条分支的属性(有没有 groupBy/aggregations)与 filter 毫无关系。这种"作者意图相同、结果不同,且分岔点与该意图无关"的形状,是回归的温床。
    • ⚠️ 升 p1 的条件,写死:普查一旦找到一例已发布图表踩中,立即升 p1 —— 因为那一例的表现是过滤条件被静默丢弃、图表照常渲染出错误数据,用户看不出来。

    定 bug 而不是 enhancement,理由

    有人会说"没测到实例,算不上 bug"。本席不采纳:缺陷是已存在于源码中的不对称,普查决定的是它的严重度,不是它的存在性。而且接收端并不更宽容 —— objectstack 的 packages/objectql/src/engine-filter-array-lowering.test.ts 钉死了引擎会用同一句 "is not a filter" 拒绝 parseFilterAST 表达不了的数组形状。⇒ 踩中即失败,不是踩中即降级。

    锚定:domain:ui 保留

    两个候选落点 —— 适配器 packages/data-objectstack/src/index.ts、生产端 packages/plugin-charts/src/ObjectChart.tsx —— 都在 objectui。原标签正确,不动。

    ⚠️ 但注意:若维护者裁决为"在生产端按 spec 拒绝",那么 packages/spec/src/data/query.zod.ts 里 where 的 FilterNode 声明可能需要一句更明确的合同文本,那一半会落到 objectstack 的 domain:spec,届时另立卡,⛔ 不要跨仓塞进本卡的 PR。

    Refs: #6302(analytics 路径的降解修复)· objectstack#12039(Key-2 排序裁决)


    Generated by Claude Code

  3. claude commented on Aug 30, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round R25
    Session: session_013hfmP9hoMd3dJwTh85J4yB
    Branch: claude/issue-6825-specshape-where-census
    Worktree: objectui-issue-6825
    Domain: domain:ui
    File surface: READ-ONLY census first. ⛔ Per triage, not one line of packages/data-objectstack/src/index.ts or packages/plugin-charts/src/ObjectChart.tsx may change before the census returns — and the repair direction is a maintainer ruling, so the expected deliverable of this dispatch is a measurement posted to this card, very possibly with no PR at all.
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — this dispatch changes no contract behaviour because it changes no code. ⚠️ The repair would be clause-② territory (it decides what a filter means on a wire whose declared contract says otherwise), which is exactly why triage reserved it for the maintainer and why this claim is scoped to measurement only.
    Serial constraints cleared: packages/data-objectstack/** — PR #6828 (#6302, the analytics-branch lowering this card was severed from) MERGED this round, so the card's premise now describes shipped main. No other open PR touches packages/data-objectstack or packages/plugin-charts.


    Generated by Claude Code

  4. zhuangjianguo commented on Aug 30, 2026

    @zhuangjianguo
    Collaborator

    ⚖️ RULED — 维护者,2026-08-30,第 5 场总监席决裁批 #4,verbatim「同意」

    裁定:采选项 A —— aggregate() 的 spec-shape 分支保持严格,在生产端响亮拒绝未 lower 的 where。⛔ 不选 B(在分支 2 也 lower=宽容消费端方向,且祝福一个连 AggregateParams 都没声明的形状);采 A 而非 D(retire 分支)是保守选择——D 会删一条今天仓内无生产者、但 props: any 那半测不到、仓外 host 可能已在用的分支,A 保留分支并让它响亮拒绝,不赌仓外无用途。

    • 裁据:普查(带正对照 + 生产者枚举第二方法)证本仓结构性零能踩中(无生产者/fixture/测试),接收端引擎已钉死 is not a filter 拒绝(engine-filter-array-lowering.test.ts:308/313/317/318)⇒ 踩中即失败非降级。现状失败形态是静默(过滤丢、图表照渲、数字错、作者无信号),A 让失败响亮、契约面在生产端拒绝。
    • 落点:packages/data-objectstack/src/index.ts spec-shape 分支;⛔ 不 lower。
    • ⛔ 不并入本卡的两项:① AggregateParams 契约缺口(packages/types/src/data.ts:1248 声明 groupBy:string 无 where/aggregations)——objectui 自己的契约缺陷,A/B 都不解决,已另立卡独立派发;② finding(data-objectstack): aggregate()'s spec-shape branch never reads filter/field/function, so an array groupBy on the LEGACY shape silently drops both the filter and the measure #6864(同 looksLikeSpecShape 谓词第二种更坏失败:legacy 形状带数组 groupBy 落进分支 2,filter 与度量一起静默丢——症状正是 triage 写死的 p1 条件)待分诊定级。

    性质:已发布契约语义(生产端拒绝行为)⇒ 人工地板。席位边界(裁不派):录裁 + 转 pm:queue。同笔摘 needs-user-decision。裁决入本场追认摘要表。


    Generated by Claude Code

  5. claude commented on Aug 30, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 6825,
      "status": "done",
      "branch": "claude/issue-6825-aggregate-spec-shape-strict-where",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6911",
      "premise_still_valid": true,
      "summary": "Executes the 2026-08-30 ruling (option A) at packages/data-objectstack/src/index.ts. aggregate()'s spec-shape branch now calls assertSpecShapeWhereIsFilterAst(params.where, resource) before assigning queryAst.where, throwing the new exported UnloweredAggregateWhereError instead of posting an array the spec's own isFilterAST gate rejects. The gate IS isFilterAST, imported as a value from @objectstack/spec/data, so the producer-side refusal and the ingress refusal cannot drift; it is scoped to arrays with two carve-outs measured against the receiving door rather than assumed (a non-array where is the declared FilterCondition, and [] is 'no filter' the engine accepts). The error carries MalformedFilterError's INVALID_FILTER/400 pair so isMalformedFilterError() recognises it and a failed widget renders 'this filter is malformed' rather than 'check your connection'. Base moved under the dispatch: the prompt named fab4802e3, origin/main was dbd519450 at worktree time and that is the merge-base used throughout. Second commit updates packages/data-objectstack/README.md, whose filter section claimed 'Both read paths lower that array to the same AST' - true of find()'s $filter and aggregate()'s analytics filter, false of aggregate({ where }), so a reader following it would have written the exact shape that now throws.",
      "tests": "All figures from 64fb1689b (the branch head), suites run from the repo root, never a package-filtered test script. UNION at that commit: pnpm exec vitest run --maxWorkers=2 packages/data-objectstack/ => 'Test Files 50 passed (50)', 'Tests 691 passed (691)'. New file packages/data-objectstack/src/aggregate-spec-shape-where.test.ts carries 22 of those. TYPECHECK: pnpm --filter @object-ui/data-objectstack type-check clean, and proven to actually cover the edit - tsc --noEmit --listFiles lists both src/index.ts and the new test file (tsconfig includes src/**/* with no test exclusion), so this is not the 'ran but measured nothing' case. RED FIRST, two independent neutralisations, IDENTICAL row for row at 'Tests 10 failed | 12 passed (22)': (1) index.ts reverted to the merge-base dbd519450, base version proven on disk via hash-object equal to the base blob plus zero mentions of the guard; (2) surgical ablation neutralising only the guard body to an early return, mutation proven on disk via an anchored marker count of exactly 1 plus a blob differing from the HEAD blob. PREDICTED vs OBSERVED, every row: 'throws UnloweredAggregateWhereError' RED/RED; 'sends NOTHING to any of the three doors' RED/RED; 'the message tells an author what arrived, what is expected, and where to fix it' RED/RED; the 7 refusal-parity rows RED/RED (7 of 7); 'the refused params take the SAME branch as the observed control' predicted GREEN because it asserts branch selection, which does not depend on the refusal - observed GREEN (the discriminating row); the 3 branch-on-the-wire rows GREEN/GREEN; the 8 pass-through rows GREEN/GREEN. The failure text of 'sends NOTHING' is the card's silent pass-through measured rather than argued: 'AssertionError: expected spec-shape to be null' - with the refusal removed the unlowered rule array lands on client.data.query and the call returns rows. RESTORE PROVEN BOTH WAYS ON BOTH LEGS: git hash-object equal to the HEAD blob AND path-scoped git diff HEAD empty AND zero residual markers. Both scripts carried trap RESTORE_FN on EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel. My first ablation script's restore assertion was tree-wide and so reported RESTORE FAILURE on an unrelated pending README edit; the target path itself was verified clean immediately and the second script scoped the assertion to the mutated path. NO REBUILD NEEDED OR SKIPPED on either leg: the test imports the subject by relative source path ('./index'), no dist is in the resolution - which the ablation itself proves, since neutralising the source changed the observed result. LINT against the base versions of the same files, not a bare count: eslint . --format json, 56 files in both runs, src/index.ts 121 warnings at base and 121 at head (delta 0), 0 errors throughout; the new test file contributes 11 warnings, all @typescript-eslint/no-explicit-any from the shared fetch-mock idiom, the identical count both siblings carry (aggregate-filter-lowering.test.ts 11, aggregate-capability.test.ts 11). GATES, quoting each gate's own verdict line rather than a bare exit code: check:control-bytes OK (5756 tracked text files), check:doc-fences OK (224 documents), check:changeset-presence OK (1 changeset for 2 changed source files of 1 released package), check:changeset-no-major OK, check:spec-symbols OK, check:phantom-deps OK, check:self-import OK, check:vi-mock-specifiers OK, check:vi-mock-inherit OK, check:esm-specifiers OK, check:skills-paths OK. check:spec-floors and check:readme-exports are PREREQUISITE NOT MET locally and are reported as neither red nor green: both read built artifacts and 16 of 39 packages were unbuilt here, so every finding is 'no-artifact' with the gate itself printing 'Build the workspace before running this gate'. The one real risk they cover, a README naming a non-existent export, was checked directly instead: dist/index.d.ts after pnpm --filter @object-ui/data-objectstack build carries UnloweredAggregateWhereError, MalformedFilterError and isMalformedFilterError. CI runs both over a full build. Note objectui has no scripts/pm/dispatch-gates.mjs (that script lives only in objectstack and answers only about its own tree), so the gate family was derived by hand from this repo's package.json and .github/workflows/. All heavy runs went through the shared lock; one attempt returned exit 99 (queue timeout, 540s) and is recorded as NOT MEASURED, not as a red - it was re-run with the same OS_VERIFY_LOCK_SLOT and acquired.",
      "branch_selection_assertion": "The two branches post to different endpoints, so the wire proves the branch for anything that reaches it: spec-shape params with a lowered where land on http://localhost:3000/api/v1/data/opportunity/query with ZERO calls to /analytics/query; the same rule array under 'filter' with legacy params lands on /analytics/query and is still lowered to ['stage','=','won']; and adding a where key to those same legacy params flips them onto the spec-shape door, which is the asymmetry the card reported, asserted on the wire. A refusal reaches no wire, so for the refusing inputs the branch is proven a second way: looksLikeSpecShape is transcribed into the test as branchSelectors(), and the refused params are asserted to yield the SAME selector triple [true, true, true] as a control observed landing on the spec-shape door inside that same test. That row stays GREEN under both neutralisations, which is what shows it asserts branch selection rather than riding on the behaviour under test.",
      "green_control": "'everything the receiving door accepts still passes through untouched' - 8 tests, all green before and after and under both neutralisations. A properly-lowered where reaches client.data.query byte-unchanged for a comparison tuple, a logical AST node, an or node and a legacy nested array of nodes; plus the FilterCondition object { stage: 'won' }, the empty array [], the no-where case (no where key on the body, groupBy and aggregations forwarded), and limit forwarded alongside a lowered where. Separately, the analytics branch still lowers the very rule array the spec-shape branch refuses, so 'we now refuse' cannot be confused with 'we now refuse everything'.",
      "refusal_message": "aggregate('opportunity'): the spec-shape branch received a `where` array that is not a filter - [{\"field\":\"stage\",\"operator\":\"equals\",\"value\":\"won\"}]. This branch posts `where` to POST /data/:object/query verbatim, so it must ALREADY be lowered: either a FilterCondition object (`QuerySchema.where`, @objectstack/spec data/query.zod.ts), or a FilterArray the ingress can lower - a comparison tuple (['stage','=','won']), a logical node (['and',[..],[..]]), or an array of nodes (data/filter.zod.ts, gate `isFilterAST`). A ViewFilterRule[] ([{ field, operator, value }, ...]) is authoring sugar, not a filter: lower it in the producer that built these aggregate params, not here. Nothing was sent to the server, so no unfiltered numbers came back.",
      "why_an_author_can_act_on_it": "Four acts, each pinned by an assertion in the test: the value received VERBATIM, so the producer is identifiable from a log line alone; the shapes accepted AND where the spec declares them (QuerySchema.where in data/query.zod.ts, the FilterArray forms in data/filter.zod.ts), so the fix does not require guessing; where to fix it - the producer that built the params, not this adapter, which is the contract-first direction AGENTS.md #0.1 mandates and the direction the ruling chose; and that nothing was sent, so the author knows no wrong number was produced and no partial state exists. It also names the branch, so a reader of the log knows which of the two ran - the branch being invisible is what made the original defect hard to see.",
      "mcp_calls": "0 - a repo-scoped REST read probe returned HTTP 200 on this seat, so the whole run used REST (reads, PR creation, comment); `gh` is absent in this container. No MCP GitHub tool was called and no dedupe search was needed, as both adjacent findings were already carded by the ruling itself.",
      "ruling_compliance": {
        "did_not_touch_AggregateParams": "CONFIRMED - packages/types is not in the diff at all. git diff --name-only dbd519450 HEAD is exactly: .changeset/6825-aggregate-spec-shape-strict-where.md, packages/data-objectstack/README.md, packages/data-objectstack/src/aggregate-spec-shape-where.test.ts, packages/data-objectstack/src/index.ts. Nothing needed widening it to typecheck; the guard's parameters are (where: unknown, resource: string), so it never touches the params type.",
        "did_not_lower_on_branch_2": "CONFIRMED - the spec-shape branch calls no lowering function. translateFilterArray is not referenced anywhere in that branch; the guard's only outcomes are 'return' (leave the value exactly as received) and 'throw'. The pass-through tests assert byte-equality with what the caller passed, so a lowering slipped in later turns them red.",
        "did_not_retire_the_branch": "CONFIRMED - the branch is intact, still selected by the same unchanged looksLikeSpecShape predicate, and still posts groupBy, aggregations, where and limit to client.data.query. 12 of the 22 new tests exercise it end to end on the wire, including every shape the receiving door accepts."
      },
      "landing_path": "Human floor, NOT clause 2, per the ruling's own classification. The PR body states the absence of the clause-2 review as deliberate and why, following PR #6897. PR #6911 is a DRAFT.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    PR: #6911 (draft) - branch claude/issue-6825-aggregate-spec-shape-strict-where at 64fb1689b.

    Generated by Claude Code


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions