feat(spec): list-view grouping is server-side — compile the group header query and the per-group row page from the view - #15284
Conversation
…der query and the per-group row page from the view (#14556) Maintainer ruling A on objectui#7189: the set of groups and every number in a group header are properties of the query, not of the fetched page; rows inside a group are paged. Seat ruling: reuse, no new query shape. - `GroupingConfigSchema` / `GroupingFieldSchema` / `ColumnSummarySchema` / `ListView.grouping` state the contract in JSDoc and `.describe()`. - New `ui/view-grouping-query.ts`: `compileListViewGroupQuery` (one `EngineAggregateOptions` — `groupBy` in nesting order, a `count` node, the column summaries mapped onto `AggregationFunction`, the view filter) and `compileListViewGroupRowsQuery` (the existing paged find with the group key AND-ed into the view filter; the empty group spelled with `$null`). `COLUMN_SUMMARY_AGGREGATION` is exhaustive by type; `count_empty` / `count_filled` / `percent_empty` / `percent_filled` have no counterpart and refuse loudly (`NOT_IMPLEMENTED` / 501 + path) until the mapping is ruled. - Pins on the 186-row / five-unit / `$top: 100` fixture: the page-scoped artefacts (86, 14) and (31/31/30/7/1) reproduce, the compiled header query answers 86/61/31/7/1 in both row orders. - Generated followers regenerated by `check:generated --fix`; `QueryInput` re-exported on the `ui` entry for entry-nameability. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
…lowers to — not a claim that `$eq: null` is refused `data/filter.zod.ts` accepts `$eq: null` as the "has no value" predicate; what it refuses is null as an ordering or list comparand. The helper keeps `$null` (the spelling `parseFilterAST` gives `is_empty` / `is_null`, lowered to `IS NULL`) and its JSDoc, the pin title and the changeset now say why. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 516e7a594b2f229e59cddd41c9ff121a19bf704f && git checkout 516e7a594b2f229e59cddd41c9ff121a19bf704f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d 49f19218b28dce64d48c7894a27baefe3c2dfa60 && git checkout -B drift-repro cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d && git merge --no-ff 49f19218b28dce64d48c7894a27baefe3c2dfa60
node scripts/docs-audit/affected-docs.mjs --json cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d
|
Takes main's deletion of skills/objectstack-ui/contracts/react-blocks.contract.json (the react-blocks generator now emits one artifact, references/react-blocks.md); the regenerated artifacts follow in the next commit, after the merge is committed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
…s derive from one COUNT(field) node; the existing query door; scalar keys; unknown-member refusal
Contract review conditions (seat comment on the card, 2026-09-04):
- alias_collision also refuses a grouping field named `count` (checked up
front, and the per-summary collision check now precedes the count
early-return); pinned.
- fold sentence: `count_distinct` does not fold across leaves — listed with
`avg`; an outer-level `count_unique` needs the `depth` query; pinned (8 vs 4).
- the "no aggregate route" premise corrected: both queries ride the existing
`POST /data/:object/query` → `protocol.findData` → `engine.aggregate` door
(answering `{ object, records, total, hasMore }`), `client.data.query()`
and the RPC `method: 'aggregate'`; the platform half pins that door.
- an unknown ColumnSummary value → `summary_unknown` (INVALID_QUERY / 400);
`summary_unmapped` (NOT_IMPLEMENTED / 501) kept for a declared member with
no counterpart (none today); both pinned.
- group keys are scalar-valued: `group_key_not_scalar` (INVALID_QUERY) for an
array/object key; per-instant date grouping and unbounded header
cardinality recorded in the GroupingConfigSchema JSDoc.
- fork (i) ruling implemented: count_filled / count_empty / percent_filled /
percent_empty compile to ONE `{ function: 'count', field, alias:
'count_<field>' }` node (deduplicated, never the fieldless count) and
`deriveColumnSummary(row, summary, field)` computes them on the header row
(percent_filled 0 when count is 0; percent_empty = 1 − percent_filled);
describes and the changeset updated; pinned on the fixture's nullable field
in both row orders.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
…st-view-grouping-contract
… deriveColumnSummary and the ColumnSummary describe Regenerated by `check:generated --fix` after the merge was committed (api-surface/ui.json + export-origins/ui.json: the new export; content/docs/references/ui/view.mdx: the ColumnSummarySchema describe). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
|
Provenance (seat landing stroke, 2026-09-04T12:41Z): Clause ② PASS · ACCEPT recorded on #14556 (comment 5540162483) by the Generated by Claude Code |
…ct lift no longer reports itself as a clear that matched nothing (#15406) The merge-queue log for PR #15284 printed, one line under its own `LIFTED skills/objectstack-ui/references/react-blocks.md` note: ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge. … ⛔ ZERO review lookups were made: the path test runs first and returns Both sentences are false for that run. The path test MATCHED (the diff's eleventh file is on the `skills/**` surface), and the register's own recompute ran and certified it. Read back from the log, a compliant landing under the 2026-09-01 generated-artifact ruling is indistinguishable from a guard that never saw the file. Report-only: `guardVerdict` now carries the paths the register lifted (default `[]`), and the `clear` rendering picks between the zero-cost clear — kept BYTE-FOR-BYTE on both legs, so the 2026-08-27 pull_request byte-identity constraint is untouched — and a clear reached through a lift, which names the lifted paths and says the recompute ran. No predicate, verdict, exit code or API cost changes. `liftedPathsBetween` derives what was lifted from the row lists on either side of `liftGeneratedExceptions`, not from its prose notes, and is deliberately conservative across rows (the #11084 fence is per-row). Self-test: 133 → 144 cases; new battery replays #15284's real 11-path file list, one commit, PR 15284, zero reviews of any kind. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
… it does not recompute, and --test stops reporting a post-lift zero as a clean read (#15406) Two report-side readings turned a compliant landing into an incident card. 1. `renderTestVerdict`'s head counts `hitPaths`, which is the POST-lift set. On PR #15284 it printed "0 of 11 path(s) hit the register" immediately above the exception line naming the path that hit it. The count keeps its meaning (what is STILL governed) and now says when the register lifted the difference. Byte-identical when nothing was lifted. 2. The sweep classifies with `governedPathsIn` alone and never consults the exception register — deliberately: provenance is a recompute against the tree a commit landed on, and this sweep holds no such tree. The row it rendered for #15284 was therefore indistinguishable from one for a hand-authored governed merge. `registerCell` adds the missing reading: which register row the governed path belongs to, that this sweep does NOT recompute, and that certification is recorded in that landing's queue-guard log. It lifts nothing and suppresses nothing — the row is still listed and still counts as a governed merge — and it repeats the register's own doctrine rather than softening it: a candidate earns the QUESTION, never the answer. Membership is the register's own `generatedExceptionFor`, so no second mechanism is authored (#11705's ruled constraint). Self-test: 263 → 274 assertions, new battery replaying #15284's shape in both directions (all-registered, mixed with hand-authored content, and none). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…d lawfully found nothing to fork — the three report-side readings that made it look like a failure (objectstack-ai#15650) * fix(pm): the queue guard — a CLEAR reached through a generated-artifact lift no longer reports itself as a clear that matched nothing (objectstack-ai#15406) The merge-queue log for PR objectstack-ai#15284 printed, one line under its own `LIFTED skills/objectstack-ui/references/react-blocks.md` note: ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge. … ⛔ ZERO review lookups were made: the path test runs first and returns Both sentences are false for that run. The path test MATCHED (the diff's eleventh file is on the `skills/**` surface), and the register's own recompute ran and certified it. Read back from the log, a compliant landing under the 2026-09-01 generated-artifact ruling is indistinguishable from a guard that never saw the file. Report-only: `guardVerdict` now carries the paths the register lifted (default `[]`), and the `clear` rendering picks between the zero-cost clear — kept BYTE-FOR-BYTE on both legs, so the 2026-08-27 pull_request byte-identity constraint is untouched — and a clear reached through a lift, which names the lifted paths and says the recompute ran. No predicate, verdict, exit code or API cost changes. `liftedPathsBetween` derives what was lifted from the row lists on either side of `liftGeneratedExceptions`, not from its prose notes, and is deliberately conservative across rows (the objectstack-ai#11084 fence is per-row). Self-test: 133 → 144 cases; new battery replays objectstack-ai#15284's real 11-path file list, one commit, PR 15284, zero reviews of any kind. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * fix(pm): the post-merge audit — a governed row names the register row it does not recompute, and --test stops reporting a post-lift zero as a clean read (objectstack-ai#15406) Two report-side readings turned a compliant landing into an incident card. 1. `renderTestVerdict`'s head counts `hitPaths`, which is the POST-lift set. On PR objectstack-ai#15284 it printed "0 of 11 path(s) hit the register" immediately above the exception line naming the path that hit it. The count keeps its meaning (what is STILL governed) and now says when the register lifted the difference. Byte-identical when nothing was lifted. 2. The sweep classifies with `governedPathsIn` alone and never consults the exception register — deliberately: provenance is a recompute against the tree a commit landed on, and this sweep holds no such tree. The row it rendered for objectstack-ai#15284 was therefore indistinguishable from one for a hand-authored governed merge. `registerCell` adds the missing reading: which register row the governed path belongs to, that this sweep does NOT recompute, and that certification is recorded in that landing's queue-guard log. It lifts nothing and suppresses nothing — the row is still listed and still counts as a governed merge — and it repeats the register's own doctrine rather than softening it: a candidate earns the QUESTION, never the answer. Membership is the register's own `generatedExceptionFor`, so no second mechanism is authored (objectstack-ai#11705's ruled constraint). Self-test: 263 → 274 assertions, new battery replaying objectstack-ai#15284's shape in both directions (all-registered, mixed with hand-authored content, and none). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #14556
Implemented-by:
claude/issue-14556-list-view-grouping-contract(dev subagent of thedomain:specseat,mode:subagent; claim comment on the card)Clause-②: yes —
needs:contract-reviewon this PR and on the card. Contract review: PASS WITH CONDITIONS (card comment 5538128398); the seven conditions are implemented in patch round 2. Head sha of every reading below:49f19218b(da0ec3c17the conditions →d117bd807merge oforigin/mainat6e67b86c0→49f19218bregeneration).What this PR is
The spec half of list-view grouping becoming server-side. It states the contract on the schema and ships the checkable form of it — a pure helper in
@objectstack/spec/uithat compiles a list view'sgrouping+ column summaries + composed filter into the two queries the platform answers it with — plus pins on the card's acceptance fixture. No engine, REST or objectui code (those are the platform half of #14556 and objectui#7189, which stays open and is not addressed here).The rulings this implements (quoted where operative)
Maintainer, ruling A on objectui#7189 (2026-09-02, verbatim 「7189 A 其他同意」), carried by the card body:
The card's spec half:
Seat ruling (third tier, claim comment 5536962024): reuse, no new query shape — (1) group set + header numbers = ONE aggregate query in the query AST's vocabulary (
groupBy=grouping.fields[].fieldin nesting order,aggregations= acountnode + the column summaries mapped ontoAggregationFunction,where= the view's composed filter); (2) rows inside a group = the EXISTING pagedfindwith the group key AND-ed into the view filter; (3) execution = the existingIDataEngine.aggregate— no new engine verb, no new envelope; (4) deliverable = contract text + a checkable helper with pins on the 186-row fixture. Seat ruling on fork (i) (contract review, 5538128398): the four*_filled/*_emptymembers map by derivation fromCOUNT(*)andCOUNT(field)— implemented below.Premises, verified on
origin/mainat1bc3c092(the branch point)(a)
AggregationNodeSchema+groupBy+EngineAggregateOptionscover a multi-columngroupBywith acountalias — holds.packages/spec/src/data/query.zod.ts:159-162—AggregationFunction = z.enum(['count','sum','avg','min','max','count_distinct']);:200-207—GroupByNodeSchema = z.union([z.string(), z.object({ field, dateGranularity?, alias? })]);:272-296—AggregationNodeSchema = { function, field?: (optional for COUNT(*)), alias, filter? }.packages/spec/src/data/data-engine.zod.ts:350-383—EngineAggregateOptionsSchema = BaseEngineOptions.extend({ where, groupBy: z.array(GroupByNodeSchema), aggregations: z.array(AggregationNodeSchema), having, timezone });:826—EngineAggregateOptions = z.inputof it.contracts/data-engine.ts:273—aggregate(objectName, query: EngineAggregateOptions, options?). The array form ofgroupByIS the multi-column form; a fieldlesscountnode isCOUNT(*).(b) the engine executes such a query on driver-sql and on the in-memory fallback — holds (read, not run).
packages/objectql/src/engine.ts:13209—async aggregate(object, query: EngineAggregateOptions, options?);:13323— the dispatch:if (typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory && !hasAggregationFilter)→:13330drv.aggregate(object, ast, …); else:13339-13340driver.find(object, ast, …)+applyInMemoryAggregation(raw, ast, tz). driver-sql:packages/drivers/driver-sql/src/sql-driver.ts:8311—async aggregate(object, query, options);:8332-8347— everygroupByitem becomesbuilder.groupBy(g)+select(g)(the string form) — multi-column by construction;:1261-1268— the lowering tablecount → count,sum,avg,min,max,count_distinct → count(distinct). In-memory:packages/objectql/src/in-memory-aggregation.ts:114-136— one bucket per combination of everygroupByitem, keyedfieldName = value(absent folds tonull);:188-200—countwith no field (or*) =rows.length, with a field = the non-null count. Ruled semantics across faces:packages/spec/src/data/aggregation-conformance.ts:68-82—count(*)= rows,count(col)= non-null,count_distinct(col)= distinct non-null.PM mechanism assumptions — verified, one nuance
ColumnSummarySchema+ListColumn.summaryis the view's per-column summary vocabulary — holds (view.zod.tsColumnSummarySchema,ListColumnSchema.summary: z.union([ColumnSummarySchema, ColumnSummaryConfigSchema])). Nuance for the objectui half: today's grid header aggregations are read from an objectui-localschema.aggregationsprop (objectui packages/types/src/objectql.ts:907-915, "ObjectUI-specific (not in @objectstack/spec …)";plugin-grid/src/ObjectGrid.tsx:2162-2166passes it touseGroupedData), not fromcolumns[].summary. The contract points atListColumn.summary; the switch is objectui#7189's.findwithwhere: { $and: [viewFilter, { field: key }] }— holds.data/filter.zod.ts:1362-1384—FilterConditioncarries$and/$or/$not;{ $and: [] }is TRUE by the ruled reduction (:1437). The empty group is spellable:SpecialOperatorSchema.$null(:1142-1146, lowered toIS NULL/{ field: null }), and it is the spelling the view filter dialect'sis_empty/is_nulllower to (parseFilterAST,:2038-2041);$eq: nullis also accepted as "has no value" (:251-253) — the helper uses$nullso a group predicate and a view filter agree on what empty means. The key predicate is{ field: { $eq: key } }(explicit, so an object-shaped key can never be read as nested operators); keys are scalar-valued and an array/object key is refused (group_key_not_scalar).content/docs/references/ui/view.mdx(describes) ANDcontent/docs/references/api/protocol.mdx+content/docs/references/data/object.mdx(they embed theListViewtable, so thegroupingdescribe lands there too);api-surface/ui.json+export-origins/ui.json(15 new names + 1 re-export);declaration-mapunchanged (gate green);authorable-surfaceunchanged (no member added). Not predicted: the react-blocks register (skills/objectstack-ui/references/react-blocks.md, one artifact since refactor(spec,skills): gen:react-blocks emits one artifact — the markdown rendering is the single AI-facing output #15257 — the retired JSON twin was dropped whenmainwas merged) carriesListView.grouping's describe. All regenerated bycheck:generated --fix, then a clean run: "✓ All 15 generated artifacts are up to date."@objectstack/specminor— taken. New exported helper + declared contract semantics; nothing changes in what parses (no key added/removed/re-shaped), so not breaking; no ADR-0087 marker owed (check:adr-0087-registrationgreen).Fork (i) — ruled and implemented: the mapping table
ColumnSummary→ the aggregation vocabularyListColumn.summarynonecount{ function: 'count' }(fieldless,COUNT(*))count— the group count itself (the footer'scountis "every row, filled or not", objectuiuseColumnSummary.ts:213-215)count_unique{ function: 'count_distinct', field }count_distinct_FIELD—COUNT(DISTINCT field), nulls excluded on every face (aggregation-conformance.ts:68-82)sum/avg/min/maxfieldFUNCTION_FIELDcount_filled{ function: 'count', field }— ONE node per summarised field, deduplicated, never the fieldless countcount_FIELD=COUNT(field), the non-null countcount_emptycount − count_FIELDpercent_filledcount_FIELD / count— a ratio in 0..1,0whencountis0percent_empty1 − percent_filled(an empty group reads1)Derivation is
deriveColumnSummary(row, summary, field)(new export): reads any member off a header row — the aggregate members from their own column (countfromcount), the four derived ones fromcount+count_FIELD;undefinedfornoneand for a column the header query was compiled without. Server "empty" =nullon every face (aggregation-conformance.ts:81-82); the footer's client-side''/[]reading is objectui's to converge under "one vocabulary" (stated in the describe). No per-aggregationfilter(it would route the whole header query through the in-memory tier), no newAggregationFunctionmember.COLUMN_SUMMARY_AGGREGATIONis aRecordoverColumnSummary— a new enum member without a row is a type error;UNMAPPED_COLUMN_SUMMARIESis[]today and thesummary_unmappedrefusal (NOT_IMPLEMENTED/ 501) stays for a future declared member with no counterpart; a value that is no member at all issummary_unknown(INVALID_QUERY/ 400 — a typo is not a capability gap). All pinned.Fork (ii) — corrected: the door exists
The claim's premise "no
aggregateroute exists on the data endpoint" was false; verified onorigin/main6e67b86c0:POST /data/:object/query(packages/rest/src/rest-server.ts:7947-7994, the body validated asFindDataRequest) →protocol.findData(packages/metadata-protocol/src/protocol.ts:10293-10322: thehasGroupBy || hasAggregationsbranch callsengine.aggregate({ where, groupBy, aggregations, having, context })and answers{ object, records, total, hasMore }, slicinglimitafter aggregation) —client.data.query()(packages/client/src/index.ts:5321-5330) posts there, and the RPC face declaresmethod: 'aggregate'with anEngineAggregateOptionsbody (data-engine.zod.ts:720-725). So both compiled queries ride the existing door verbatim (EngineAggregateOptionsfor the header rows asrecords,EngineQueryOptionsfor the row page): no new route, no new wire shape. The platform half of #14556 is rescoped to "pin the existing door on the compiled queries" (the 186-row fixture through the route, on driver-sql and on the in-memory tier). The module note, theGroupingConfigSchemaJSDoc and this body say so.The compiled queries (from the pins)
Header query for
{ grouping: { fields: [{ field: 'business_unit' }] }, columns: [{ field: 'id' }, { field: 'amount', summary: 'sum' }, { field: 'owner', summary: 'count_unique' }] }with view filter{ status: { $eq: 'open' } }:With
notes: count_filled,count_empty(object form onnotes),notes: percent_filled,amount: percent_emptydeclared, the aggregations arecount,{ function: 'count', field: 'notes', alias: 'count_notes' },{ function: 'count', field: 'amount', alias: 'count_amount' }— one node per field.Header row naming: each grouped field under its own name (raw stored value;
nullfor the empty group),count, each summary underFUNCTION_FIELD(columnSummaryAlias), the derived four oncount_FIELD. A two-level grouping compiles togroupBy: ['business_unit', 'status'](one row per leaf;depth: 1compiles the outer level's own query). Folding across leaves:count,sum,min,maxandcount_FIELDfold exactly;avgandcount_distinctdo NOT — an outer-levelavg/count_uniqueneeds thedepthquery (pinned: the 86-row unit's two leaves each see all four owners, folding says 8,depth: 1says 4).Row page for the 86-row group, page 2 of 25:
The empty group:
{ where: { $and: [{ business_unit: { $null: true } }] }, limit: 10 }. Refusals (ListViewGroupQueryError,code+status+path+reason):alias_collision(a summary alias on a grouped field's column, or a grouping field namedcount),summary_unknown,summary_unmapped,grouping_empty,grouping_field_blank,depth_out_of_range,group_key_not_a_prefix,group_key_not_scalar. Recorded limits (GroupingConfigSchemaJSDoc): a date/datetime grouping field groups per distinct stored instant (nodateGranularityonGroupingField); header cardinality is unbounded (the door sliceslimitafter aggregation; bounding it isorderBy+limiton the aggregate verb, a separate engine-contract card).Measurements at
49f19218b(the acceptance fixture: 186 rows, five units 86/61/31/7/1,$top: 100;notesnull on every fifth row)useGroupedDatadoes)status = donebusiness_unit×statusnorthgate_operations: open 58 + done 28 = 86; leafcount_distinct_ownerfolds to 8,depth: 1answers 4notes:count_filled/count_emptyper unitpercent_filled= filled/size,percent_empty= empty/sizecount_notes0) · acount: 0rowpercent_filled0,percent_empty1 · 0 and 1, no divisionlimit: 50Both artefacts the card measured reproduce on this fixture and both orders answer the same numbers from the compiled query.
Tests, typecheck, consumer readings — at
49f19218b, exits captured before any pipe, verdict lines quotedpackages/spec/src/ui/view-grouping-query.test.ts, 42 pins — targeted run: "Test Files 1 passed (1) / Tests 42 passed (42)".@objectstack/specsrc/uisuite: "Test Files 59 passed (59) / Tests 2185 passed (2185)".pnpm --filter @objectstack/spec typecheck→ "check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s) held in test-typecheck-debt.json" (the ledgered debt, unchanged; at the earlier headtsc --listFilesshowed both files inside the program with 0 ledgered errors in them).880d45dd0, direction DEPENDENTS of spec, named explicitly):@objectstack/objectqltypecheck against the built spec dist → "VERDICT command-exit 0". objectuiplugin-gridat24e027e9, read-only:ObjectGrid.tsx:1795-1797+:1948fetch one window withdataSource.find(objectName, { $select, $top, $skip, … });:2162-2166useGroupedData(schema.grouping, data, schema.aggregations, …);:3384/:3390the!isGroupedexclusions. Once the platform half pins the door, the grouped branch posts the compiled header query toPOST /data/:object/query(client.data.query()) and per expanded group the compiled row page;useGroupedDataconsumesListViewGroupHeaderRow[](count,FUNCTION_FIELD,count_FIELDviaderiveColumnSummary) instead of bucketingdata; the two!isGroupedexclusions go; thePartialmarker retires when counts are server-true — objectui#7189's list.--format json;eslint.config.mjssets noparserOptions.project, so the narrowing cannot move an untouched file's verdict; repo-widepnpm lintis CI's.Reverse verification (pins bite) — three legs, each mutated, proved on disk, run, restored, proved restored
79f007c5b) — the empty-group spelling$null: true→$eq: null: first attempt was a NO-OP (perl interpolated$null; the disk proof caught it, blob unchanged — recorded, not counted); rerun with a literal sed anchor: blob626e7fce→841ad69d; "Tests 1 failed | 35 passed (36)" — exactly the$nullpin.79f007c5b) —names.slice(0, depth)→names.slice(0, 1): blob →9febb482; "Tests 2 failed | 34 passed (36)" — the two-columngroupBypin and thedepthpin.49f19218b) —total === 0 ? 0→? 1inderiveColumnSummary: anchor 1→0 / injected 0→1, blob3923dc3c→980b7358; "Tests 1 failed | 41 passed (42)" — exactly the zero-count pin.git checkout HEAD -- ABSOLUTE_PATHundertrap … EXIT INT TERM; proved by STATE:git diff HEAD0 lines,git status --short0 paths, blob back to HEAD's (match=YES). No rebuild owed or performed: the pins import./view-grouping-queryrelatively fromsrc/, andpackages/spec/vitest.config.tsdeclares no alias, so nodist/stands between the mutation and the run.Gates at
49f19218b— derived, not recallednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom the worktree (no path args; stderr: "derived from the tree of 'objectstack-ai/objectstack' at commit 49f1921 … --repo checked against this checkout's 'origin' remote — it holds") → the same 88 commands as at880d45dd0; 86 exit 0, 2 exit 3 = PREREQUISITE NOT MET, read as NOT MEASURED, not as red (check:dual-build-cjs-loads,check:type-check-debt: every workspace package built — CI'slint.ymldoes that). Four of the 86 were green only on a re-run after building the workspace closure (client-react,lint,formula; 34 tasks):check:doc-formula-expressions,check:doc-security-posture(first pass exit 3),check:skill-examples(first pass PREREQUISITE NOT MET), andcheck:docs-audit-scope— whose first pass was an exit 1 from the build-state-dependentkind=contractself-test case landed by #15274 onmain(this branch touches 0 lines underscripts/); that exit-class mismatch is filed as #15328, unassigned, out of scope here. Named verdict lines:check:generated --fix(3 stale: api-surface, export-origins, docs) then clean "✓ All 15 generated artifacts are up to date.";check:api-surface"@objectstack/spec public API surface + factory signatures unchanged ✓";check:export-origins"✅ export-origins/ is current: 5275 exports across 17 entry points resolve exactly as recorded.";check:docs"✅ 230 generated files in sync with packages/spec";check:react-blocks"✅ 1 generated files in sync with packages/spec";check:entry-nameability"✅ entry-nameability: 439 call probes across 17 public entries, 0 new unnameable structural mentions";check:doc-authoring"✓ doc authoring guard: … clean";check:dual-source-exports"✅ no new dual-source exports: 5065 names across 17 entry points";check:exported-any"✅ no exported type resolves toany: 2460 types + 1524 schemas";check:nul-bytes"OK … no raw ASCII control bytes".Full 88-row table (command · exit · last line):
check-adr-0087-registration.mjs --self-testcheck-changeset-no-major.mjs --self-testcheck-ci-filter-parity.mjscoreorcrosspkg, everycrosspkgecheck-closing-keyword-parity.mjscheck-closing-keyword-parity.mjs --self-testcheck-comment-mask-adoption.mjscheck-comment-mask-adoption.mjs --self-testcheck-comment-mask-corpus.mjscheck-dev-prereqs.mjs --self-testcheck-doc-frontmatter.mjscheck-doc-frontmatter.mjs --self-testcheck-doc-route-spelling.mjs --advisorycheck-doc-route-spelling.mjs --self-testcheck-docs-section-name.mjscheck-docs-section-name.mjs --self-testcheck-empty-changeset.mjs --self-testcheck-keyed-text-bounds.mjscheck-keyed-text-bounds.mjs --self-testcheck-plugin-teardown-shape.mjscheck-plugin-teardown-shape.mjs --self-testcheck-section-landing-index.mjscheck-section-landing-index.mjs --self-testcheck-system-context-census.mjscheck-system-context-census.mjs --self-testcheck-undeclared-dep-imports.mjs>=and cannot see an upward drift at all, wcheck-undeclared-dep-imports.mjs --self-testdocs-audit/check-affected-docs.mjsdocs-audit/check-drift-comment.mjspm/release-rehearsal-clone.mjs --self-testlint check:doc-formula-expressions@objectstack/formulaunbuilt)lint check:doc-security-posture@objectstack/lintunbuilt)spec check:api-surfacespec check:authorable-surfacespec check:browser-reachable-entriesspec check:docsspec check:dual-source-exportsspec check:empty-statespec check:entry-nameabilityspec check:export-originsspec check:exported-anyany: 2460 types + 1524 schemas across 17 entry points.spec check:generatedspec check:livenessspec check:llms-txtspec check:react-blocksspec check:skill-examplesspec check:skill-refsspec check:strictness-ledgerspec check:variant-docsspec check:yaml-examplescheck:agent-test-spellingcheck:changeset-gate-self-testscheck:corpus-claim-driftcheck:cross-package-test-inputscheck:dispatcher-error-vocabularyskipStringLiteraltracks `${ … }check:doc-anchorscheck:doc-authoringcheck:docs-audit-scopekind=contractself-test case, build-state dependent — filed as its own card)check:docs-redirectscheck:docs-single-h1#heading (0 subtree(s) exclucheck:dual-build-cjs-loadscheck:engine-double-contractcheck:logger-receiver-detachcheck:merge-drivercheck:nul-bytescheck:objectql-double-limitcheck:objectui-changesetcheck:page-declaration-shapecheck:pm-governed-mergescheck:pm-half-statescheck:published-filesfileswhitelistcheck:published-readme-linkscheck:query-options-erasurecheck:quick-reference-countscheck:react-page-adapter-contractcheck:refd-timer-probecheck:role-wordcheck:skill-compatibilitycheck:skill-frame-synccheck:skill-identifier-livenesscheck:slot-lookupcheck:spec-parsed-aliascheck:test-source-aliascheck:type-check-coveragecompositionAt-- @objectstack/spec-moncheck:type-check-debtcheck:type-source-resolutiontsconfig*.jsoneachcheck:vendor-version-stampscheck:watch-hint-literalcheck:where-matcherskills/**readingsskills/objectstack-ui/references/react-blocks.md: 117 → 117 lines (one register row regenerated byte-exact from theListView.groupingdescribe, the same row CI's queue leg regenerates); the JSON twin retired by #15257 is gone from the diff since the first merge ofmain; whole package (everyskills/*/SKILL.md): 6835 → 6835 lines, 0 SKILL.md diffs. Token counts: not reported — no sibling gate defines them yet.Deviations, on record
Claim:comment was posted late (after the first edit) — see the card.packages/spec/src/ui/index.tscarries one type re-export (QueryInput) beyond the plannedexport *line — the entry-nameability repair the gate prescribes.content/docs/references/api/protocol.mdxandcontent/docs/references/data/object.mdxare in the diff (generated followers proved stale; they embed theListViewtable).check:doc-authoring); the tracking card is named in JSDoc / comments only.check:docs-audit-scopeanswers exit 1 on an unbuilt workspace where its siblings exit 3; same class as [finding] check:published-readme-exports reports an unbuilt workspace as 40+ README findings — exit 1 where its sibling exits 3 PREREQUISITE NOT MET #15285).🤖 Generated with Claude Code
https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk