Repository navigation
ObjectStackAdapter.aggregate() posts an un-lowered FilterArray as the analytics where — every array-form element:number / object-metric filter answers 400 on the runtime route; lower through parseFilterAST before the wire (moved from objectstack#15828, ruled 2026-09-05) #7752
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 5, 2026 Claim: PM loop round — director seat summon #15, maintainer direct dispatch (verbatim 「15828 按前端缺陷处理」 replying to the seat's proposal 「按直派派发 objectui 修复与 spec 文档卡」, 2026-09-05, director chat); the objectui
domain:uiseat #6025 is vacant
Session:session_01TezFG8ZMrNH6n5VTNpPpdH
Branch:claude/issue-7752-aggregate-where-lowering
Worktree:objectui-issue-7752
Domain:domain:ui
File surface:packages/data-objectstack/src/index.ts(theaggregate()analytics branch only) ·packages/data-objectstack/src/aggregate-filter-lowering.test.ts(re-point) · one new or extended test asserting the posted payload againstAnalyticsQueryRequestSchema· one.changeset/*.md(@object-ui/data-objectstackpatch). ⛔translateFilterArraybody, the spec-shape branch andUnloweredAggregateWhereError,packages/types,packages/core, governed surfaces untouched (stop on breach; explain in the report)
Container & model: S code change with contract-facing tests, handled as M;mode:subagent;model: opus—--tier(objectstack dispatch-gates) has no objectui path mandate; default tier because the tests must read the spec's own schema and the change sits on a published wire path
Clause-②: no — the adapter starts emitting what the protocol already declares (where=FilterCondition); no accept/reject behaviour or public surface of any published package changes; the spec is untouched
Serial constraints cleared: open objectui PRs at 14:5xZ (#7749 hooks, #7748 core DataScopeManager, #7743 types kanban, #7739 types chatbot, #7737 types chart, #7736 types layout docs, #7685 lockfile, three dependabot, #5400 release) — none touchespackages/data-objectstack/**; sibling cards #7711 / #7712 (same census family) arepm:queue, unclaimed, and land inplugin-calendar/plugin-kanban; none.
Generated by Claude Code
Claim (os-dev,
mode:subagent) — dispatched by the PM seat for this card only.Session:
session_01TezFG8ZMrNH6n5VTNpPpdH
Branch:claude/issue-7752-aggregate-where-lowering(pushed empty at claim time; baseorigin/main81a2eb1f)
Worktree:/home/user/objectui-issue-7752
Domain:domain:uiRead before claiming: this card's body and its one comment; objectstack#15828 body and all four comments (two triage comments, the maintainer ruling 5552626017 verbatim 「15828 按前端缺陷处理」, and the spec-half claim). The ruling is executed, not re-decided: the adapter is wrong, the protocol is right; ⛔ no widening of
AnalyticsQuerySchema.where, ⛔ no@objectstack/specchange from this branch.File surface I intend to touch:
packages/data-objectstack/src/index.ts(theaggregate()analytics branch only, plus its stale comment aboutlowerAnalyticsWhere) ·packages/data-objectstack/src/aggregate-filter-lowering.test.ts(re-pointed assertions plus the route-hopAnalyticsQueryRequestSchema.safeParsepin) · one.changeset/*.md(@object-ui/data-objectstackpatch). ⛔ Untouched:translateFilterArray's body, the spec-shape branch andUnloweredAggregateWhereError/MalformedFilterErrormeanings,find()'s$filterdoor,packages/types,packages/core, and every governed surface (AGENTS.md,CLAUDE.md,.claude/**,docs/adr/**, rootskills/**).
Generated by Claude Code
os-dev-report
{ "issue": 7752, "status": "done", "branch": "claude/issue-7752-aggregate-where-lowering", "pr": "https://github.com/objectstack-ai/objectui/pull/7754", "premise_still_valid": true, "head_sha": "00acc4a9", "base": "origin/main 81a2eb1f", "summary": "aggregate()'s ANALYTICS branch now lowers an array filter with parseFilterAST(translateFilterArray(filter)) before the payload is built, so the posted `where` is the FilterCondition AnalyticsQuerySchema declares; a record filter passes through unchanged. Empty case pinned as ruled: parseFilterAST([]) is undefined, so an empty authored filter posts NO `where` key (it used to post a literal []). An array the sink cannot lower (infix join [condA,'or',condB]; also a truncated tuple like ['stage','='] that isFilterAST accepts but the sink throws on) is refused with a new UnlowerableAnalyticsFilterError carrying the same INVALID_FILTER/400 envelope as MalformedFilterError and UnloweredAggregateWhereError, recognised by isMalformedFilterError() — never posted, never dropped. Neither sibling class's meaning was widened. The lowering runs BEFORE the analytics try block: raised inside it, a refusal would be classified `unknown` by classifyAnalyticsFailure and answered by the client-side fallback with plausible numbers. The branch comment no longer calls lowerAnalyticsWhere the wire's door and names the route hop instead. Verbatim premise check on origin/main confirmed every claim in the card: the un-lowered assignment at the named line, isFilterAST already imported from @objectstack/spec/data, parseFilterAST exported beside it, and the four safeParse verdicts (record ACCEPT; tuple/group/bare-list REFUSE un-lowered, ACCEPT lowered).", "files_changed": [ "packages/data-objectstack/src/index.ts", "packages/data-objectstack/src/aggregate-filter-lowering.test.ts", "packages/data-objectstack/src/aggregate-spec-shape-where.test.ts", ".changeset/7752-analytics-where-lowering.md" ], "pm_mechanism_assumptions": { "1_repoint_lowering_test": "CONFIRMED and done. The file asserted the posted `where` equals translateFilterArray's AST. Re-pointed to parseFilterAST's own output on the same input (never a literal); the cross-path block now asserts aggregate()'s `where` equals parseFilterAST applied to the AST find() puts on filter=.", "2_route_hop_pin": "CONFIRMED absent, now added. 8 accept rows parse the REAL posted body with AnalyticsQueryRequestSchema from @objectstack/spec/api (tuple, rule array, and-group, bare list, both record forms, empty array, no filter), plus a negative control asserting the array this branch used to post is REFUSED by that same schema. The infix join is proven refused before any request: analytics body list empty AND no /data fallback call.", "3_find_untouched": "CONFIRMED. find()'s $filter path unchanged; its independent row and the 7 cross-path rows are green.", "4_spec_shape_branch_untouched": "PARTIALLY FALSIFIED, declared. The branch itself and UnloweredAggregateWhereError are untouched, but aggregate-spec-shape-where.test.ts carried ONE row pinning the analytics branch's old un-lowered value ('legacy analytics params go to POST /analytics/query'). It went red on the first run; re-pointed to parseFilterAST's output the same way. That file's own refusal assertions are unaffected." }, "gates": [ { "command": "pnpm exec vitest run packages/data-objectstack/", "exit": 0, "verdict": "Test Files 56 passed (56) / Tests 748 passed (748)" }, { "command": "pnpm exec vitest run packages/data-objectstack/ packages/plugin-charts/ packages/plugin-dashboard/ packages/components/ packages/core/", "exit": 0, "verdict": "Test Files 544 passed (544) / Tests 6649 passed (6649) — every in-repo aggregate() caller's package; run under the shared verify lock, VERDICT command-exit 0, held 581s" }, { "command": "pnpm --filter @object-ui/data-objectstack type-check", "exit": 0, "verdict": "tsc --noEmit, no diagnostics; --listFiles confirms BOTH edited test files are in the program (not excluded)" }, { "command": "pnpm --filter @object-ui/data-objectstack lint", "exit": 0, "verdict": "427 problems (0 errors, 427 warnings) — warnings all pre-existing no-explicit-any" }, { "command": "pnpm --filter '@object-ui/data-objectstack^...' build", "exit": 0, "verdict": "VERDICT command-exit 0 (shared verify lock); run first so type-check reads fresh .d.ts" }, { "command": "node scripts/check-changeset-presence.mjs", "exit": 0, "verdict": "3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)" }, { "command": "node scripts/check-changeset-no-major.mjs", "exit": 0, "verdict": "No changeset declares a major bump." }, { "command": "pnpm check:control-bytes", "exit": 0, "verdict": "OK (scanned 6315 tracked text file(s); skipped 85 binary)" }, { "command": "pnpm check:spec-symbols", "exit": 0, "verdict": "spec member citations: no comment cites a key its spec symbol does not declare." }, { "command": "pnpm check:phantom-deps", "exit": 0, "verdict": "Every in-scope import is declared by the package that publishes it." }, { "command": "pnpm check:vi-mock-specifiers", "exit": 0, "verdict": "OK (4298 tracked source file(s))" }, { "command": "pnpm check:self-import", "exit": 0, "verdict": "No package names itself inside its own src/." }, { "command": "pnpm check:unreferenced-sources", "exit": 0, "verdict": "OK" }, { "command": "pnpm check:readme-exports", "exit": 1, "verdict": "the population COLLAPSED — packagesRead: found 4, floor is 25. NOT MEASURED, not red: the gate needs every package built and its workflow builds first. With @object-ui/data-objectstack built it reports ZERO findings for this package, and this change only ADDS an export (UnlowerableAnalyticsFilterError, confirmed in dist/index.d.ts), which cannot make a README name stop resolving. Repo-wide run declared to CI." }, { "command": "grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' on the 4 changed files", "exit": 1, "verdict": "no matches — no control bytes; and 0 CJK characters in any line this diff ADDS (rule #-1)" } ], "ablation": "Run on the COMMITTED tree (00acc4a9), source-level (tests import ./index, not dist/, so no rebuild leg applies). Mutation: `lowered = parseFilterAST(ast)` to `lowered = ast`. On-disk proof BEFORE reading any result — HEAD blob 78acecd0 == pre-mutation hash; anchor counts flipped (parseFilterAST(ast) call 1 to 0, ABLATED marker 0 to 1); post-mutation blob c8d20a3b, asserted different from HEAD blob and non-empty. Result: ABLATED_TEST_EXIT=1, Tests 17 failed | 14 passed (31). Direction as predicted (red), and it reddens the RIGHT rows: all four AnalyticsQueryRequestSchema accept rows fail with `expected false to be true`, i.e. the new route-hop pin can fail at the hop it exists to measure. Restore leg: `git checkout HEAD -- <abs path>` (never bare checkout), plus a trap '<restore>' EXIT INT TERM using an absolute REPO_ROOT. Restore PROVEN by state, not by exit code: post-restore blob 78acecd0 == HEAD blob, `git diff HEAD` empty, ABLATED marker count back to 0. Re-ran the two touched test files after restore: exit 0, 53 passed.", "line_budget": "n/a — no skills/** file touched (no root skills/, no .claude/**). Governed surfaces (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**, skills/**) all untouched; verified by the diff's 4-file file list.", "deviations": [ "FILE SURFACE +1: aggregate-spec-shape-where.test.ts was edited (one assertion row + its comment + one import). It is outside the file surface the claim listed, but it pinned the analytics branch's old un-lowered `where` and went red on the first run. Re-pointed to parseFilterAST's output; that file's own #6825 refusal assertions are untouched.", "NEW EXPORTED SYMBOL: UnlowerableAnalyticsFilterError is added to the package's public surface. Additive only, ordered by the card ('refused with the adapter's existing INVALID_FILTER / 400 envelope shape — reuse the pattern, do not widen those classes' meaning'); a new class was the only way to obey the do-not-widen half.", "LOWERING HOISTED above the analytics `try`, beyond the card's literal one-line patch. Necessary: classifyAnalyticsFailure maps an INVALID_FILTER-coded error to `unknown`, and aggregate()'s catch answers `unknown` with aggregateViaFind — so a refusal raised inside the try would have been answered with client-side numbers. Side effect, strictly better: MalformedFilterError now reaches the caller directly instead of only surviving because aggregateViaFind happened to re-throw it. Same observable outcome for the existing test (same error class, no HTTP call).", "GATE NARROWED, declared: check:readme-exports (see gates). No other gate was narrowed.", "CHANNEL SWITCH, declared: the container's REST proxy refuses /search/issues ('sessions are bound to their configured repositories. Use repository-scoped endpoints') even though repo-scoped REST reads answer 200. Dedup for the finding below therefore used ONE targeted MCP search_issues call. It returned 2 hits (both closed, neither a duplicate), so the reading is non-empty and needs no control word.", "NEAR-MISS, self-caught and repaired: the changeset was first written to .changeset/lucky-donkeys-shave.md, a name that ALREADY EXISTS on origin/main (a #7642 census changeset) — the write clobbered it. Caught because check-changeset-presence.mjs counts only ADDED changesets and stayed red at 0. Restored with `git checkout origin/main -- <path>` and PROVEN identical by blob hash (0eb92d73 both sides), then rewritten as .changeset/7752-analytics-where-lowering.md. Nothing of that file reached any commit; the diff vs origin/main touches 4 files and that is not one of them.", "SCRIPT NAME: this package spells its typecheck script `type-check` (hyphen), not `typecheck`; the run echoed the script name." ], "mcp_calls": "10 — 4 issue_read (7752 body+comments, 15828 body+comments), 2 add_issue_comment (claim, this report), 1 create_pull_request, 1 pull_request_read (full body read-back for sanitizer), 1 search_issues (the declared dedup shot), 1 issue_write (the finding below).", "open_questions": [], "out_of_scope_findings": [ "filed as #7755: classifyAnalyticsFailure returns kind 'unknown' for a coded 400 that is not VALIDATION_FAILED (INVALID_FILTER, say), and aggregate()'s catch answers 'unknown' with aggregateViaFind — so a body the analytics route refuses can be re-read through find()'s different filter door and rendered as a confident number, the exact misdirection the 'rejected' branch's own comment cites framework#3878 to stop. Unassigned, unlabelled, left for triage; NOT fixed in PR #7754." ] }Verification artifacts: PR #7754 body carries the same gate table, the ablation transcript, and the verbatim objectstack#5158 ruling C docblock the change rests on. The PR is draft, base
main, auto-merge not armed,Fixes #7752on its first line.
Generated by Claude Code
- added a commit that references this issue
on Sep 5, 2026 os-dev-report addendum
Patch round, one item as dispatched: the changeset's bump was
patchwhile the PR adds a public export (UnlowerableAnalyticsFilterError, present indist/index.d.ts). Scoredminorinstead. Frontmatter only — the changeset text is byte-unchanged, and no other file moved.- new head sha:
d4557b01(a new commit onclaude/issue-7752-aggregate-where-lowering, no amend, no force-push; previous head00acc4a9is its parent) - diff of this round:
.changeset/7752-analytics-where-lowering.md, 1 file changed, 1 insertion(+), 1 deletion(-) - PR fix(data-objectstack): lower an array analytics filter through parseFilterAST before the wire (#7752) #7754 still draft, base
main, auto-merge not armed
Gates re-run locally on
d4557b01, exit codes captured by redirect and the verdict lines quoted from each gate's own output:Command Exit Verdict line node scripts/check-changeset-presence.mjs0 ✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7752-analytics-where-lowering.md.node scripts/check-changeset-no-major.mjs0 ✅ No changeset declares amajorbump.node scripts/check-changeset-fixed.mjs0 ✅ privatePackages declared: version=true, tag=false.The third row is this repo's fixed-group bump-policy gate (
changeset-guard.ymlruns it besideno-major). One gate beyond the three asked for, run because it is the other half of that workflow and it bears on the earlier reported near-miss:node scripts/check-changeset-overwrite.mjsexits 0 with✅ No pre-existing changeset was modified or deleted.— independent confirmation that the clobbered.changeset/lucky-donkeys-shave.mdwas fully restored and that nothing this branch carries touches a changeset it does not own.Nothing else was touched: no source, no test, no governed surface, and the PR was not flipped out of draft.
Generated by Claude Code
- new head sha:
Review — ACCEPT (director seat, objectstack#12708 summon #15,
session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T15:3xZ) · PR #7754, headd4557b01(patch round on00acc4a9)Checked on GitHub against
references/review-checklist.md:item reading PR shape draft ✓ · base main✓ · first lineFixes #7752✓; body cites objectstack#15828's ruling and #5158 ruling C; no closing keyword near another open cardScope ( get_files, 4 files)packages/data-objectstack/src/index.ts(the analytics branch: newlowerAnalyticsFilterForWire,parseFilterASTimport beside the existingisFilterAST, newUnlowerableAnalyticsFilterError, hoisted before thetry),aggregate-filter-lowering.test.tsre-pointed,aggregate-spec-shape-where.test.tsone row re-pointed (declared +1: it pinned the old un-lowered value), one changeset.translateFilterArray, the spec-shape branch,UnloweredAggregateWhereError,find()'s$filterdoor, governed surfaces untouched ✓Protocol conformance (the ruling) posted whereisparseFilterAST's output, asserted against the spec's own function, never a literal ✓; the route-hop pin both triage comments asked for is present: 8 accept rows parse the real posted body withAnalyticsQueryRequestSchemafrom@objectstack/spec/api, plus a negative control proving the old array is refused by that same gate ✓;[]posts nowhere(matcheslowerAnalyticsWhere's reading) ✓; un-lowerable arrays (infix join; truncated tuple) refused with theINVALID_FILTER/ 400 envelope before any request and without thefind()fallback ✓Hoisting above the tryjustified and measured: inside it, classifyAnalyticsFailurereturnsunknownand the catch answers withaggregateViaFind— a refused filter would have become a confident number. Accepted as in-scope; the residual (a route 400 still re-read throughfind()) is filed as #7755 for triage ✓Semver new public export ⇒ minor(patch round, frontmatter only,d4557b01);check-changeset-fixed/-no-major/-overwrite/-presenceall exit 0 on the new head ✓Evidence real commands: 748 tests in the package, 6,649 across every aggregate()caller's package, type-check, lint; ablation withlowered = astturns exactly the four schema-accept rows red, restore proven by blob hash ✓;check:readme-exportsdeclared NOT MEASURED locally (population floor), the repo-wide CI job is the reading ✓Changeset overwrite near-miss self-caught (a random changeset name collided with an existing file on main), restored and proven by blob hash;check-changeset-overwrite.mjsexit 0 on the branch ✓CI on 00acc4a9all suites completed, none failed (15:27Z); CI on d4557b01read in the same stroke as this comment — ready-flip waits for itGoverned-surface path test: none of the four paths is governed ⇒ ordinary landing (ready → auto-merge; objectui lands through its merge queue).
Implemented-by:branchclaude/issue-7752-aggregate-where-lowering(mode:subagent) ·Reviewed-by:this session (same session — PM review of a subagent's diff;Clause-②: no, spec untouched, no independent contract review owed).Not this PR's, recorded: objectstack#15828 (spec-side D3 wording) is a separate dev; #7755 (analytics-400 →
find()fallback misdirection) is triage's.
Generated by Claude Code
MERGED confirmed — landing closed out. PR #7754 merged 2026-09-05T15:58:46Z (squash
53ded82b, now objectuiorigin/maintip); this card auto-closed byFixes #7752at 15:58:48Z. Read onorigin/mainpackages/data-objectstack/src/index.ts:UnlowerableAnalyticsFilterError(:297),lowerAnalyticsFilterForWire(:509) and its call inaggregate()(:5085) are present — the lowering the maintainer's 2026-09-05 ruling on objectstack#15828 (「15828 按前端缺陷处理」) required is live.pm:dispatchedremoved in the same stroke; ACCEPT was 5552826184. The spec-side documentation half is objectstack PR #15941 (ready, landing). Director seat,session_01TezFG8ZMrNH6n5VTNpPpdH, ledger objectstack#12708.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026
Symptom (user-visible today)
An
element:number(array-only filter since objectstack#12039) or anobject-metricauthored with an arrayfilterplusaggregatecallsObjectStackAdapter.aggregate()→ analytics branch →POST /analytics/querywithwhere= the array. The runtime route parses the body withAnalyticsQueryRequestSchemafirst (where: FilterConditionSchema) and answers400 Invalid AnalyticsQuery body: where: …instead of the aggregate. Only the MongoDB-style record survives — the exact authoring form objectui#6206-B retired. Measured withsafeParseon the spec source: record ACCEPT; AST tuple array / single tuple / rule array all REFUSE.Why this is the adapter's defect and not the protocol's
@objectstack/specdata/filter.zod.ts,FilterArraydocblock (objectstack#5158 ruling C), verbatim: "AFilterArrayis not a storage shape and not a protocol shape. It is lowered to aFilterConditionat the single sinkparseFilterAST(@objectstack/spec/data) the moment it arrives, and only the loweredFilterConditiontravels any further.whereon a query is aFilterConditionand stays one … no driver, no transport, no stored row — ever has to understand two filter dialects." Widening the wire was that ruling's rejected option A. The declared doors are React block props,FilterBuilder, and the REST$filterquery string; a POST body'swhereis transport.packages/data-objectstack/src/index.tsaggregate()(analytics branch, ~line 4990):payload.where = Array.isArray(params.filter) ? translateFilterArray(params.filter) : params.filter.translateFilterArrayonly normalises rule objects into AST tuples — the result is still aFilterArray. The protocol's lowering step is missing. The same file already importsisFilterASTfrom@objectstack/spec/data(line 21);parseFilterASTis exported beside it. The branch comment mistookservice-analytics' in-processlowerAnalyticsWheredoor (objectstack#5334) for the wire's door; objectui#6302's gate measured that function, not the route.The fix
parseFilterASTreturnsundefinedfor an empty array — decide and pin: an empty authored filter posts nowhere(matcheslowerAnalyticsWhere's "[]is no filter").find()path is untouched: it sends$filteron the query string, which is a declared door.UnloweredAggregateWhereError(objectui#6825, ruling 2026-08-30 option A) is a different branch and a different ruling; do not touch it.Tests this must carry
packages/data-objectstack/src/aggregate-filter-lowering.test.tsre-pointed: the postedwherefor an array filter is aFilterConditionobject (e.g.['stage','=','won']→{ stage: 'won' }or whateverparseFilterASTyields — assert againstparseFilterAST's own output, not a hand-written literal).AnalyticsQueryRequestSchema.safeParsefrom@objectstack/spec/api— the exact gate the runtime runs — for a tuple, a logical group, a bare list, and the record form; and an infix join ([condA, 'or', condB]) is refused before the wire with the adapter'sINVALID_FILTERenvelope, not silently dropped.Out of scope
Widening
AnalyticsQuerySchema.where; touchingtranslateFilterArray's tuple normalisation; the dashboard-widget record producer (groupToCondition, objectstack#15829 family); any@objectstack/specchange (the documentation half lives on objectstack#15828).Refs: objectstack#15828 (source, now the spec-docs half) · objectstack#5158 (FilterArray ruling C) · objectstack#5334 (analytics in-process door) · objectstack#12039 · #6302 / PR #6828 (the change that introduced the un-lowered post) · #6206 (ruling B) · #6825 · objectstack#15829 · #7711 · #7712 (same family per triage).