You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
Provenance. Rebuilt here from objectstack-ai/objectstack#15828 (transfer is unavailable to the filing seat) after the maintainer's ruling of 2026-09-05, verbatim 「15828 按前端缺陷处理」 — recorded on that card by the director seat (objectstack-ai/objectstack#12708, summon #15). The objectstack card stays open, narrowed to the spec-side documentation half (D3 entry wording + one .describe() sentence). Measurements below are the source card's and triage's, re-read against objectui origin/main81a2eb1f and the spec docblock on objectstack origin/main6c439f2.
Symptom (user-visible today)
An element:number (array-only filter since objectstack#12039) or an object-metric authored with an array filter plus aggregate calls ObjectStackAdapter.aggregate() → analytics branch → POST /analytics/query with where = the array. The runtime route parses the body with AnalyticsQueryRequestSchema first (where: FilterConditionSchema) and answers 400 Invalid AnalyticsQuery body: where: … instead of the aggregate. Only the MongoDB-style record survives — the exact authoring form objectui#6206-B retired. Measured with safeParse on 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, FilterArray docblock (objectstack#5158 ruling C), verbatim: "A FilterArray is not a storage shape and not a protocol shape. It is lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives, and only the lowered FilterCondition travels any further. where on a query is a FilterCondition and 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 $filter query string; a POST body's where is transport.
packages/data-objectstack/src/index.tsaggregate() (analytics branch, ~line 4990): payload.where = Array.isArray(params.filter) ? translateFilterArray(params.filter) : params.filter. translateFilterArray only normalises rule objects into AST tuples — the result is still a FilterArray. The protocol's lowering step is missing. The same file already imports isFilterAST from @objectstack/spec/data (line 21); parseFilterAST is exported beside it. The branch comment mistook service-analytics' in-process lowerAnalyticsWhere door (objectstack#5334) for the wire's door; objectui#6302's gate measured that function, not the route.
The fix
payload.where=Array.isArray(params.filter)
? parseFilterAST(translateFilterArray(params.filter))// the protocol's single lowering sink
: params.filter;// record path unchanged
parseFilterAST returns undefined for an empty array — decide and pin: an empty authored filter posts no where (matches lowerAnalyticsWhere's "[] is no filter").
The find() path is untouched: it sends $filter on the query string, which is a declared door.
The spec-shape branch's 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.ts re-pointed: the postedwhere for an array filter is a FilterCondition object (e.g. ['stage','=','won'] → { stage: 'won' } or whatever parseFilterAST yields — assert against parseFilterAST's own output, not a hand-written literal).
The route-hop pin both triage comments asked for: the built payload passes AnalyticsQueryRequestSchema.safeParse from @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's INVALID_FILTER envelope, not silently dropped.
Existing record-path tests stay green unchanged.
Out of scope
Widening AnalyticsQuerySchema.where; touching translateFilterArray's tuple normalisation; the dashboard-widget record producer (groupToCondition, objectstack#15829 family); any @objectstack/spec change (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).
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:ui seat #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 (the aggregate() analytics branch only) · packages/data-objectstack/src/aggregate-filter-lowering.test.ts (re-point) · one new or extended test asserting the posted payload against AnalyticsQueryRequestSchema · one .changeset/*.md (@object-ui/data-objectstack patch). ⛔ translateFilterArray body, the spec-shape branch and UnloweredAggregateWhereError, 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 touches packages/data-objectstack/**; sibling cards #7711 / #7712 (same census family) are pm:queue, unclaimed, and land in plugin-calendar / plugin-kanban; none.
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; base origin/main81a2eb1f)
Worktree: /home/user/objectui-issue-7752
Domain: domain:ui
Read 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/spec change from this branch.
File surface I intend to touch: packages/data-objectstack/src/index.ts (the aggregate() analytics branch only, plus its stale comment about lowerAnalyticsWhere) · packages/data-objectstack/src/aggregate-filter-lowering.test.ts (re-pointed assertions plus the route-hop AnalyticsQueryRequestSchema.safeParse pin) · one .changeset/*.md (@object-ui/data-objectstack patch). ⛔ Untouched: translateFilterArray's body, the spec-shape branch and UnloweredAggregateWhereError / MalformedFilterError meanings, find()'s $filter door, packages/types, packages/core, and every governed surface (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**, root skills/**).
{
"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 #7752 on its first line.
Patch round, one item as dispatched: the changeset's bump was patch while the PR adds a public export (UnlowerableAnalyticsFilterError, present in dist/index.d.ts). Scored minor instead. Frontmatter only — the changeset text is byte-unchanged, and no other file moved.
new head sha: d4557b01 (a new commit on claude/issue-7752-aggregate-where-lowering, no amend, no force-push; previous head 00acc4a9 is its parent)
diff of this round: .changeset/7752-analytics-where-lowering.md, 1 file changed, 1 insertion(+), 1 deletion(-)
The third row is this repo's fixed-group bump-policy gate (changeset-guard.yml runs it beside no-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.mjs exits 0 with ✅ No pre-existing changeset was modified or deleted. — independent confirmation that the clobbered .changeset/lucky-donkeys-shave.md was 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.
Review — ACCEPT (director seat, objectstack#12708 summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T15:3xZ) · PR #7754, head d4557b01 (patch round on 00acc4a9)
Checked on GitHub against references/review-checklist.md:
item
reading
PR shape
draft ✓ · base main ✓ · first line Fixes #7752 ✓; body cites objectstack#15828's ruling and #5158 ruling C; no closing keyword near another open card
Scope (get_files, 4 files)
packages/data-objectstack/src/index.ts (the analytics branch: new lowerAnalyticsFilterForWire, parseFilterAST import beside the existing isFilterAST, new UnlowerableAnalyticsFilterError, hoisted before the try), aggregate-filter-lowering.test.ts re-pointed, aggregate-spec-shape-where.test.ts one row re-pointed (declared +1: it pinned the old un-lowered value), one changeset. translateFilterArray, the spec-shape branch, UnloweredAggregateWhereError, find()'s $filter door, governed surfaces untouched ✓
Protocol conformance (the ruling)
posted where is parseFilterAST'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 with AnalyticsQueryRequestSchema from @objectstack/spec/api, plus a negative control proving the old array is refused by that same gate ✓; [] posts no where (matches lowerAnalyticsWhere's reading) ✓; un-lowerable arrays (infix join; truncated tuple) refused with the INVALID_FILTER / 400 envelope before any request and without the find() fallback ✓
Hoisting above the try
justified and measured: inside it, classifyAnalyticsFailure returns unknown and the catch answers with aggregateViaFind — a refused filter would have become a confident number. Accepted as in-scope; the residual (a route 400 still re-read through find()) is filed as #7755 for triage ✓
Semver
new public export ⇒ minor (patch round, frontmatter only, d4557b01); check-changeset-fixed / -no-major / -overwrite / -presence all 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 with lowered = ast turns exactly the four schema-accept rows red, restore proven by blob hash ✓; check:readme-exports declared 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.mjs exit 0 on the branch ✓
CI on 00acc4a9
all suites completed, none failed (15:27Z); CI on d4557b01 read in the same stroke as this comment — ready-flip waits for it
Governed-surface path test: none of the four paths is governed ⇒ ordinary landing (ready → auto-merge; objectui lands through its merge queue). Implemented-by: branch claude/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.
MERGED confirmed — landing closed out. PR #7754 merged 2026-09-05T15:58:46Z (squash 53ded82b, now objectui origin/main tip); this card auto-closed by Fixes #7752 at 15:58:48Z. Read on origin/mainpackages/data-objectstack/src/index.ts: UnlowerableAnalyticsFilterError (:297), lowerAnalyticsFilterForWire (:509) and its call in aggregate() (:5085) are present — the lowering the maintainer's 2026-09-05 ruling on objectstack#15828 (「15828 按前端缺陷处理」) required is live. pm:dispatched removed 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.
Activity
os-zhuang commentedon 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 vacantSession:
session_01TezFG8ZMrNH6n5VTNpPpdHBranch:
claude/issue-7752-aggregate-where-loweringWorktree:
objectui-issue-7752Domain:
domain:uiFile 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 pathClause-②: 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 untouchedSerial 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 touches
packages/data-objectstack/**; sibling cards #7711 / #7712 (same census family) arepm:queue, unclaimed, and land inplugin-calendar/plugin-kanban; none.Generated by Claude Code
os-zhuang commentedon Sep 5, 2026
Claim (os-dev,
mode:subagent) — dispatched by the PM seat for this card only.Session:
session_01TezFG8ZMrNH6n5VTNpPpdHBranch:
claude/issue-7752-aggregate-where-lowering(pushed empty at claim time; baseorigin/main81a2eb1f)Worktree:
/home/user/objectui-issue-7752Domain:
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-zhuang commentedon Sep 5, 2026
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
chore(changeset): score #7752 as a minor — it adds a public export
os-zhuang commentedon 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.d4557b01(a new commit onclaude/issue-7752-aggregate-where-lowering, no amend, no force-push; previous head00acc4a9is its parent).changeset/7752-analytics-where-lowering.md, 1 file changed, 1 insertion(+), 1 deletion(-)main, auto-merge not armedGates re-run locally on
d4557b01, exit codes captured by redirect and the verdict lines quoted from each gate's own output:node scripts/check-changeset-presence.mjs✅ 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.mjs✅ No changeset declares amajorbump.node scripts/check-changeset-fixed.mjs✅ 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
os-zhuang commentedon Sep 5, 2026
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:main✓ · first lineFixes #7752✓; body cites objectstack#15828's ruling and #5158 ruling C; no closing keyword near another open cardget_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 ✓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 ✓tryclassifyAnalyticsFailurereturnsunknownand 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 ✓minor(patch round, frontmatter only,d4557b01);check-changeset-fixed/-no-major/-overwrite/-presenceall exit 0 on the new head ✓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 ✓main), restored and proven by blob hash;check-changeset-overwrite.mjsexit 0 on the branch ✓00acc4a9d4557b01read 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
os-zhuang commentedon Sep 5, 2026
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
fix(data-objectstack): lower an array analytics filter through parseF…