Skip to content

List-view grouping is server-side: group set and per-group counts come from an aggregate query, rows within a group are paged (objectui#7189 ruling A) #14556

Closed

Description

@os-project-manager

Filed by the director seat (objectstack #12708, summon #8, session session_01ShyhexkB2d1AeRZ85tgAAe) to carry a maintainer ruling into the spec lane. Filed unassigned; domain:spec and pm:queue set because the ruling names the lane. objectui#7189 is pm:blocked on this card.

The ruling this card implements

Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #7 in which objectui#7189 was item 1 with the recommendation B (fetch the whole filtered set within the platform ceiling) and A (server-side grouping) offered as the fallback. Verbatim reply: 「7189 A 其他同意」 — the maintainer takes A over the recommendation.

Ruled: A. Grouping on a list view (the grid) is server-side. The set of groups and every number in a group header (the count and any per-group aggregation) are properties of the query, not of the fetched page. Rows inside a group are paged. The client-side grouping of the fetched page that packages/plugin-grid/src/useGroupedData.ts performs today is the interim state, not the contract.

Measured facts the ruling rests on (all from objectui#7189 and its landing note, comment 5496459435)

  • A grid grouped by a lookup over 186 rows in five units with $top: 100 renders two group headers (86, 14) with three units absent when rows are contiguous, and five headers reading 31/31/30/1/7 when interleaved. Which shape appears depends on row order. Neither is the data.
  • ObjectGrid.tsx computes useServerPagination = !hasInlineData && !isGrouped, so a grouped grid that owns its fetch has no server pager at all: its pager pages groups in memory over the rows already loaded. Records past the first window are not on page 2. They are unreachable from that screen.
  • The platform already groups the same field over the same data with true totals through the dataset engine (the same application's dashboard does it). The capability exists; the grid surface lacks it.
  • The disclosure half landed in objectui PR examples/app-todo: is_completed and is_overdue are readonly flags that nothing ever maintains — permanently false, and one of them is read by a hook #7226 (a "Partial" marker beside every group count). It is honest and it tells the reader about rows they cannot reach. Disclosure is a ceiling here, not a floor.

Scope, three halves in order

  1. Spec (this card's first half). Define what grouping on a list view means and what the platform returns for it: the group keys, the per-group total count, the per-group aggregations (the same aggregation vocabulary datasets already use, one vocabulary and not two, per objectui#4576), and the paging model for rows within a group. Whether this is a new query shape on the data endpoint or a reuse of the dataset aggregate query is the spec seat's design call. An ADR if the contract is new. Clause ② yes.
  2. Platform. The group query on the REST and ObjectQL path, reusing the dataset aggregate engine: group-by over plain and lookup fields, total per group, a row page per group. Blocked-by the spec half.
  3. objectui (plugin-grid). Grouped mode consumes group headers from the server; useGroupedData stops grouping the page; the grouped exclusion in useServerPagination goes; the "Partial" marker from PR examples/app-todo: is_completed and is_overdue are readonly flags that nothing ever maintains — permanently false, and one of them is read by a hook #7226 is retired once counts are server-true (enforce-or-remove); the "Grouping is page-scoped" docs section written under chore(deps)(deps-dev): bump the development-dependencies group across 1 directory with 5 updates #7189 is rewritten. Blocked-by the platform half. Tracked on objectui#7189.

Not in scope

  • The unbounded fetch on gantt, calendar, map and tree: ruled a′ on objectui#7210 (platform ceiling constant plus a loud footnote) and dispatched separately.
  • Any change to dashboard datasets.

Acceptance

  • The 186-row, five-unit, $top: 100 fixture renders five group headers with counts 86/61/31/7/1 regardless of row order, and opening the 86-row group pages its rows.
  • No "Partial" marker renders when counts are server-true.
  • A grouped grid with more rows than one window can reach every row through the UI.

Related: objectui#7189 (origin; the ruling is recorded there) · objectui#7210 · objectui PR #7226 · objectui#7179 · objectstack #12708 (director seat ledger).

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 2, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 4, 2026
  3. self-assigned this
    on Sep 4, 2026
  4. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Claim: PM loop round R1 (slot refill) — domain:spec seat (seat post #6017). Executing the maintainer ruling carried by the director seat (this card's body; origin objectui#7189, batch #7, verbatim 「7189 A 其他同意」, 2026-09-02): A — grouping on a list view is server-side; this card is the spec half (the first of three). The design call the card hands to this seat — "whether this is a new query shape on the data endpoint or a reuse of the dataset aggregate query" — is ruled: reuse, no new query shape (third tier, premises below; the dev verifies them before writing and stops on a falsified one).
    Session: session_01H2oQebDDxYKfWZusyd8GXk (GitHub os-justin)
    Branch: claude/issue-14556-list-view-grouping-contract
    Worktree: objectstack-issue-14556
    Domain: domain:spec
    Seat ruling (third tier). (1) The group set and every number in a group header are ONE ObjectQL aggregate query, expressed in the vocabulary the query AST already declares: groupBy = the list view's grouping.fields[].field in nesting order (multi-level grouping = a multi-column groupBy, headers derived from the grouped keys at each level), aggregations = a count node plus the view's declared per-column summaries mapped onto AggregationFunction (data/query.zod.ts: count / sum / avg / min / max / count_distinct — one vocabulary, the one datasets use), where = the view's composed filter (the same filter the row query carries). (2) Rows inside a group are the EXISTING paged find with the group's key predicate AND-ed into the view filter (top / skip per group). (3) The execution contract is the existing IDataEngine.aggregate(objectName, EngineAggregateOptions, options) (packages/spec/src/contracts/data-engine.ts) — ⛔ no new engine verb, no new envelope. (4) The spec half delivers the contract text on the list-view grouping (JSDoc + describe on GroupingConfigSchema / GroupingFieldSchema: server-side; groups and counts are query properties; rows within a group are paged) AND a checkable artifact — a pure helper in packages/spec that compiles a list view's grouping + filter + column summaries into the EngineAggregateOptions header query and the per-group row query, with pins on the card's 186-row / five-unit / $top: 100 fixture (counts 86/61/31/7/1 regardless of row order) — ⛔ prose alone is not a deliverable. Premises the dev verifies first: (a) AggregationNodeSchema + groupBy on the query AST and EngineAggregateOptions cover multi-column groupBy with count; (b) the engine's aggregate() executes them on the SQL and memory drivers (read, not run: the dev cites the dispatch sites). Two forks are STOP-AND-REPORT, never decided by the dev: (i) ColumnSummarySchema members with no AggregationFunction counterpart (count_empty / count_filled / count_unique / percent_* …) — report the exact mapping table and which members are unmappable; ⛔ do not invent a third vocabulary and do not silently drop them; (ii) the REST door — no aggregate route exists on the data endpoint today (packages/rest/src mounts none; the only aggregate path is the analytics dataset route); if the spec half needs to declare a new public route or wire shape, that is ADR-class ⇒ report the shape you would propose and stop (the platform half, item 2 of the card, carries the route; this seat files it at ACCEPT). ⛔ No ADR is written under reuse; ⛔ no engine / REST / objectui code.
    File surface: packages/spec/src/ui/view.zod.ts (the grouping block GroupingFieldSchema / GroupingConfigSchema + the ColumnSummarySchema docs — JSDoc/describe only unless a member is added, which is a fork to report), a new helper module beside it (placement the dev's call inside packages/spec/src/ui/ or kernel/; exported from the ui entry) + its test, generated followers the gates prove stale (references/ui/view.mdx, authorable-surface/ui.json, api-surface / export-origins / declaration-map for the new export), one @objectstack/spec changeset. contracts/data-engine.ts read-only. ⛔ Not packages/objectql, not packages/rest, not objectui. Stop on breach; explain in the report.
    Container & model: M–L, mode:subagent, model: fable — --tier on ui/view.zod.ts at 9c1bcda3 (06:44Z): no path-derived mandate, Clause ② SUSPECT hint; judged from content: a new exported helper (mechanical floor) and a published contract's declared semantics (grouping becomes server-side by declaration) ⇒ CONTRACT_REVIEW_TIER.
    Clause-②: yes
    Serial constraints cleared: ui/view.zod.ts last touched e9b377e8 (#14111, 2026-09-03; free on the seat post); 0 open PRs on it at 07:03Z (branch-name read of the open PR list); in-flight lane cards (#14462 family, #14559) do not touch it. H17 hit: ui/view.zod.ts is a declared Restart-touch trigger file of #8346 (pm:on-hold, PDF/print export, v18) — notification posted on that card per its precedent; no rider work is taken (zero export/print surface here), the hold stands. objectui#7189 is pm:blocked on this card (its landing note is the acceptance fixture's source).


    Generated by Claude Code

  5. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Dispatch (R1 slot refill, 2026-09-04T07:05Z) — domain:spec seat, session_01H2oQebDDxYKfWZusyd8GXk (os-justin), seat post #6017. mode:subagent, model: fable (CONTRACT_REVIEW_TIER), size M–L, Clause ② yes (dual carrier: the dev hangs needs:contract-review on the PR and on this card in one stroke when the draft PR exists — no pre-hang). The dev leaves its own Claim: comment below before its first edit.

    Rulings (not re-decidable). Maintainer: A (objectui#7189, batch #7, 「7189 A 其他同意」) — the card body's "Ruled: A" paragraph and "Scope, three halves in order" item 1 verbatim: "Define what grouping on a list view means and what the platform returns for it: the group keys, the per-group total count, the per-group aggregations (the same aggregation vocabulary datasets already use, one vocabulary and not two, per objectui#4576), and the paging model for rows within a group." Seat ruling (claim 5536962024): reuse, no new query shape — items (1)–(4) there are the contract; the two forks (i) unmappable ColumnSummarySchema members, (ii) a REST door that would need a new public route/wire shape, are STOP-AND-REPORT. ⛔ No ADR under reuse; ⛔ not packages/objectql, packages/rest, objectui.

    PM mechanism assumptions (verify; falsify freely — report, then follow the ruling's intent). (a) AggregationNodeSchema + groupBy on the query AST (data/query.zod.ts, incl. the { field, dateGranularity } groupBy member form) and EngineAggregateOptions cover multi-column groupBy + count with an alias; (b) the engine's aggregate() dispatch (packages/objectql/src/engine.ts, the drv.aggregate presence test) executes it on driver-sql and on the in-memory fallback — cite the sites, do not run the engine; (c) ColumnSummarySchema (view.zod.ts) is the view's own per-column summary vocabulary and ListColumn.summary its authoring site — the header aggregation for a group reuses THAT declaration (the same list of summaries the footer computes), mapped onto AggregationFunction; build the mapping table (count → count, count_unique → count_distinct, sum/avg/min/max → same, …) and list every member with no counterpart as fork (i); (d) a per-group row page is the existing find with where: { $and: [viewFilter, { [groupField]: groupKey }] } — verify the filter AST spells AND-composition that way and that a null group key has a spellable predicate; (e) generated followers: references/ui/view.mdx (describes), authorable-surface/ui.json (only if a member is added — that would be a fork), api-surface / export-origins / declaration-map for the new export; (f) changeset @objectstack/spec minor (a new exported helper + declared contract semantics; not breaking — the previously undeclared page-scoped behaviour was never the contract).

    PM suggested route (optional; measured beats suggested). Helper module packages/spec/src/ui/view-grouping-query.ts (or the name the ui entry's conventions give): compileListViewGroupQuery(view, filter) → EngineAggregateOptions (groupBy in nesting order, count + mapped summaries, where) and compileGroupRowsQuery(view, filter, groupKeys, page) → QueryAST (the AND-ed predicate + top/skip); pins: the 186-row five-unit fixture as an in-memory reduction over the compiled header query yields 86/61/31/7/1 for both row orders the card measured (contiguous and interleaved); a two-level grouping compiles to a two-column groupBy; a summary with no counterpart refuses loudly at compile time with code + path (not a silent drop) — if fork (i) is reported, that refusal IS the interim contract and says so. JSDoc on GroupingConfigSchema: server-side; groups and counts are query properties; rows within a group are paged; the platform half (#14556 item 2) and objectui (#7189) are named as the follow-ons.

    Surface, gates, report. File surface = the claim comment 5536962024 (stop on breach). Consumer-package readings for a contract card: @objectstack/spec (src/ui tests + the new pins), @objectstack/objectql typecheck (nothing should change — the helper is additive), objectui at 24e027e9 read-only: plugin-grid's useGroupedData and ObjectGrid grouped branch are the future consumers — report what they would call, no edit. Derive gates from your actual paths after the last commit (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no path args); the spec check:* families, check:generated --fix then a clean run, check:api-surface / export-origins / declaration-map regenerated for the new export, check-system-context-census (--fix if demanded), check:spec-parsed-alias, check-affected-docs, check:doc-authoring, check:nul-bytes, eslint on edited TS. Report: os-dev-report first as a comment here, then as the return; machine-checkable fields only (status, pr, premise_still_valid per premise, files_changed, gates pinned to the head sha, the mapping table for fork (i), the REST-door answer for fork (ii), deviations, open_questions, out_of_scope_findings filed as cards after a dedup search). Follow-ups the seat files at ACCEPT, not the dev: the platform half card (REST/ObjectQL, Blocked-by: #14556) and the objectui#7189 unblock note.


    Generated by Claude Code

  6. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Claim:
    Session: session_01H2oQebDDxYKfWZusyd8GXk (dev subagent of the domain:spec seat, mode:subagent)
    Branch: claude/issue-14556-list-view-grouping-contract
    File surface: packages/spec/src/ui/view.zod.ts (JSDoc + .describe() on GroupingFieldSchema / GroupingConfigSchema / ColumnSummarySchema / ListView.grouping — no member added), new packages/spec/src/ui/view-grouping-query.ts + view-grouping-query.test.ts, packages/spec/src/ui/index.ts (export + one entry-nameability type re-export), generated followers the gates proved stale (content/docs/references/{ui/view,api/protocol,data/object}.mdx, packages/spec/{api-surface,export-origins}/ui.json, skills/objectstack-ui/{contracts/react-blocks.contract.json,references/react-blocks.md} — register rows regenerated from the grouping describe, line counts unchanged), one @objectstack/spec changeset (minor).
    Clause-②: yes

    Deviation, on record: this claim is posted late — after the first edit, not before it (the dispatch order was read and the step was skipped while sequencing the pre-reads; no other agent holds these files, 0 open PRs touched them at claim time per the seat's read).

    Status (reply to the 07:52Z probe): step = gates (helper + pins written, spec suite 59 files / 2179 tests green, spec + objectql typecheck green, check:generated clean, all named spec/root gate families green locally); fork (i) HIT and handled on the dispatch's allowed path — count_empty / count_filled / percent_empty / percent_filled have no AggregationFunction counterpart, the mappable members ship and those four refuse loudly at compile time (NOT_IMPLEMENTED / 501 + path), the mapping table goes to open_questions — not a needs_decision stop; fork (ii) NOT hit — no route or wire shape declared, the REST-door answer is prose in the report; ETA for the draft PR: ~20 minutes (commit, reverse-verification legs, gate derivation on the head sha, push).


    Generated by Claude Code

  7. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 4, 2026
  8. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    os-dev-report

    {
      "issue": 14556,
      "status": "done",
      "branch": "claude/issue-14556-list-view-grouping-contract",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15284",
      "head_sha": "880d45dd0",
      "premise_still_valid": true,
      "premises": {
        "a_ast_covers_multi_column_groupBy_with_count": "holds — packages/spec/src/data/query.zod.ts:159-162 (AggregationFunction: count/sum/avg/min/max/count_distinct), :200-207 (GroupByNodeSchema = string | {field, dateGranularity?, alias?}), :272-296 (AggregationNodeSchema {function, field? (optional for COUNT(*)), alias, filter?}); data-engine.zod.ts:350-383 (EngineAggregateOptionsSchema: where, groupBy: z.array(GroupByNodeSchema), aggregations, having, timezone), :826 (EngineAggregateOptions = z.input); contracts/data-engine.ts:273 aggregate(objectName, query: EngineAggregateOptions, options?)",
        "b_engine_executes_on_sql_and_in_memory": "holds (read, not run) — packages/objectql/src/engine.ts:13209 aggregate(); :13323 dispatch `typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory && !hasAggregationFilter` → :13330 drv.aggregate(object, ast); else :13339-13340 driver.find + applyInMemoryAggregation; drivers/driver-sql/src/sql-driver.ts:8311 aggregate(), :8332-8347 builder.groupBy per item (multi-column), :1261-1268 lowering table; objectql/src/in-memory-aggregation.ts:114-136 bucket per groupBy combination, :188-200 fieldless count = rows.length; spec data/aggregation-conformance.ts:68-82 ruled count(*)/count(col)/count_distinct semantics",
        "c_column_summary_is_the_view_vocabulary": "holds — view.zod.ts ColumnSummarySchema + ListColumnSchema.summary (string | {type, field}); nuance: objectui's grid header aggregations today read an objectui-local `schema.aggregations` prop (objectui packages/types/src/objectql.ts:907-915; ObjectGrid.tsx:2162-2166), not columns[].summary — the switch is objectui#7189's",
        "d_row_page_is_find_with_and_composed_group_key": "holds — data/filter.zod.ts:1362-1384 FilterCondition $and/$or/$not; `{ $and: [] }` TRUE (:1437); empty group spellable with $null (:1142-1146) — the spelling is_empty/is_null lower to (parseFilterAST :2038-2041); $eq: null also accepted as has-no-value (:251-253); helper emits `{ field: { $eq: key } }` / `{ field: { $null: true } }` AND-ed after the view where",
        "e_generated_followers": "as predicted plus two: content/docs/references/ui/view.mdx, api/protocol.mdx, data/object.mdx (the two extra embed the ListView table); api-surface/ui.json + export-origins/ui.json (14 new names + QueryInput re-export); skills/objectstack-ui/{contracts/react-blocks.contract.json,references/react-blocks.md} (react-blocks register rows carry the ListView.grouping describe); declaration-map and authorable-surface unchanged (no member added)",
        "f_changeset_minor": "taken — .changeset/list-view-grouping-server-side-contract.md `@objectstack/spec: minor`; not breaking (no key added/removed/re-shaped); no ADR-0087 marker owed (check:adr-0087-registration exit 0)"
      },
      "files_changed": [
        ".changeset/list-view-grouping-server-side-contract.md",
        "packages/spec/src/ui/view-grouping-query.ts",
        "packages/spec/src/ui/view-grouping-query.test.ts",
        "packages/spec/src/ui/view.zod.ts",
        "packages/spec/src/ui/index.ts",
        "packages/spec/api-surface/ui.json",
        "packages/spec/export-origins/ui.json",
        "content/docs/references/ui/view.mdx",
        "content/docs/references/api/protocol.mdx",
        "content/docs/references/data/object.mdx",
        "skills/objectstack-ui/contracts/react-blocks.contract.json",
        "skills/objectstack-ui/references/react-blocks.md"
      ],
      "summary": "Spec half of maintainer ruling A (objectui#7189, 「7189 A  其他同意」) under the seat's third-tier ruling 'reuse, no new query shape'. GroupingConfigSchema / GroupingFieldSchema / ColumnSummarySchema / ListView.grouping now state the contract (server-side; groups and header numbers are query properties; rows within a group are paged; follow-ons named). New ui/view-grouping-query.ts: compileListViewGroupQuery(view, {where?, depth?}) → EngineAggregateOptions (groupBy = grouping.fields[].field in nesting order, a fieldless `count` node aliased `count`, the column summaries mapped onto AggregationFunction under `FUNCTION_FIELD` aliases, the view where); compileListViewGroupRowsQuery(view, groupKey, {where?, limit?, offset?, orderBy?, fields?}) → EngineQueryOptions (`{ $and: [viewWhere, ...groupKeyPredicates] }`, $null for the empty group); listViewGroupKeyPredicate; COLUMN_SUMMARY_AGGREGATION (exhaustive Record over ColumnSummary) + UNMAPPED_COLUMN_SUMMARIES; ListViewGroupQueryError (ADR-0112 envelope: code + status + path + reason). Fork (i) HIT and shipped on the dispatch's allowed path: count_empty / count_filled / percent_empty / percent_filled refuse loudly (NOT_IMPLEMENTED / 501, path ['columns', i, 'summary']); the table is in open_questions. Fork (ii) NOT hit: no route or wire shape declared. Not packages/objectql, not packages/rest, not objectui, not docs/adr.",
      "tests": "All at 880d45dd0, exits captured by redirect-then-capture, verdict lines quoted. (1) new packages/spec/src/ui/view-grouping-query.test.ts, 36 pins: 'Test Files 1 passed (1) / Tests 36 passed (36)' — the 186-row five-unit fixture reproduces the card's page-scoped artefacts on the first 100 rows (contiguous: 2 headers 86/14; round-robin interleaved: 31/31/30/7/1) and the compiled header query reduced over the whole set answers 86/61/31/7/1 in both orders; view filter status=done → 28/20/10/2; two-level groupBy ['business_unit','status'] → 9 leaf rows, ops open 58 + done 28 = 86; depth:1 = the outer level; the 86-row group pages as [50, 36] with 86 distinct ids; each unmapped member refuses with code NOT_IMPLEMENTED / status 501 / reason summary_unmapped / path ['columns',1,'summary'] and a message with no issue id; codes pinned to StandardErrorCode.enum. (2) spec src/ui suite under the verify lock: 'Test Files 59 passed (59) / Tests 2179 passed (2179)'. (3) pnpm --filter @objectstack/spec typecheck: 'check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s)' (ledgered debt unchanged); tsc --listFiles -p tsconfig.test.json lists both new files, 0 of the 261 ledgered errors in them. (4) Consumer, direction DEPENDENTS of spec, named explicitly: @objectstack/objectql — upstream closure built ('Tasks: 14 successful, 14 total'), pnpm --filter @objectstack/objectql typecheck against the final spec dist → 'VERDICT command-exit 0'. objectui plugin-grid at 24e027e9 read-only: ObjectGrid.tsx:1795-1797/:1948 (windowed dataSource.find with $top/$skip), :2162-2166 useGroupedData(schema.grouping, data, schema.aggregations), :3384/:3390 the !isGrouped exclusions; what they would call once the platform half lands is in the PR body. (5) ESLint plain form on the 4 edited TS files: 0 errors / 0 warnings (file count 4 from --format json; eslint.config.mjs has no parserOptions.project so the narrowing cannot move an untouched file's verdict; repo-wide lint is CI's). (6) Reverse verification at 79f007c5b (helper byte-identical at head), each leg: mutate → prove on disk (anchor grep -c 1→0, injected 0→1, blob hash moved) → vitest → restore with `git checkout HEAD -- ABSOLUTE_PATH` under trap EXIT INT TERM → prove restored (git diff HEAD 0 lines, status clean, blob == HEAD blob 626e7fce). Leg A ($null → $eq: null for the empty group): FIRST ATTEMPT WAS A NO-OP — perl interpolated `$null` in the anchor, disk proof caught it (1→1, blob unchanged), recorded and not counted; rerun with a literal sed anchor: blob → 841ad69d, 'Tests 1 failed | 35 passed (36)' — exactly the $null pin. Leg B (names.slice(0, depth) → slice(0, 1)): blob → 9febb482, 'Tests 2 failed | 34 passed (36)' — the two-column groupBy pin and the depth pin. No rebuild owed or performed: the pins import ./view-grouping-query relatively from src and packages/spec/vitest.config.ts declares no alias, so no dist stands between mutation and run (ablation-dist-preflight does not apply).",
      "gates": {
        "head_sha": "880d45dd0",
        "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands from the worktree, no path args (stderr: derived from the tree of 'objectstack-ai/objectstack' at commit 79f007c5b; re-run at head) → 88 commands, all run at 880d45dd0 in three chunks with per-command exit capture; the full 88-row table is in the PR body",
        "totals": "86 exit 0; 2 exit 3 = PREREQUISITE NOT MET (NOT MEASURED, not red): check:dual-build-cjs-loads and check:type-check-debt both need every workspace package built ('Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured') — CI's lint.yml builds that closure first",
        "spec check:generated --fix then clean": "0 — '✓ All 15 generated artifacts are up to date.' (first run 4 stale: react-blocks, api-surface, export-origins, docs)",
        "spec check:api-surface": "0 — '@objectstack/spec public API surface + factory signatures unchanged ✓' (against the regenerated ui.json)",
        "spec check:authorable-surface": "0 — '✅ Successfully generated 1603 schemas.'",
        "spec check:docs": "0 — '✅ 230 generated files in sync with packages/spec'",
        "spec check:export-origins": "0 — '✅ export-origins/ is current: 5274 exports across 17 entry points resolve exactly as recorded.'",
        "spec check:declaration-map": "0 (inside check:generated: ✓)",
        "spec check:liveness": "0", "spec check:strictness-ledger": "0 — '440 site(s) measured'",
        "spec check:dual-source-exports": "0 — '✅ no new dual-source exports: 5064 names across 17 entry points — 206 re-exported (single declaration)'",
        "spec check:exported-any": "0 — '✅ no exported type resolves to `any`: 2460 types + 1524 schemas across 17 entry points.'",
        "spec check:entry-nameability": "0 — '✅ entry-nameability: 438 call probes across 17 public entries, 0 new unnameable structural mentions' (first run: 1 NEW — @objectstack/spec/ui • QueryInput → repaired by `export type { QueryInput } from '../data/query.zod'` on ui/index.ts, the gate's own prescription and the FilterCondition precedent)",
        "spec check:browser-reachable-entries": "0 — '✅ 2 declared browser-reachable entries link no zod; 44 bundle(s) scanned'",
        "spec check:empty-state": "0 — '✓ all classified'",
        "spec check:llms-txt": "0 — '✓ 97 claim(s) re-derived'",
        "spec check:skill-refs": "0 — '✅ 9 generated files in sync'", "spec check:react-blocks": "0 — '✅ 2 generated files in sync'",
        "spec check:skill-examples": "0 — '✅ 257 prose examples type-check across 3 surface(s) — every marked block parsed' (first run PREREQUISITE NOT MET: client-react unbuilt → built it, 34 tasks)",
        "spec check:variant-docs": "0", "spec check:yaml-examples": "0", "spec check:error-code-provenance": "0 — 'OK — every registered-code stamp site is listed under its own owner key' (the two codes stamped are standard-catalog members)",
        "check:system-context-census": "0 — 'OK — 106 elevation read sites in 20 packages across 45 files, all anchored' (no --fix demanded)",
        "check:spec-parsed-alias": "0 — 'ADR-0122 type-alias convention: 1519 bare z.input aliases … OK'",
        "docs-audit/check-affected-docs.mjs": "0",
        "check:doc-authoring": "0 — '✓ doc authoring guard: 14738 customer-facing string(s) across 729 spec sources clean' (first run ✗ 3 describes carried #14556 → ids stripped from describes and the refusal message, kept in JSDoc/comments; pin asserts the message carries no issue id)",
        "check:nul-bytes": "0 — 'check-nul-bytes: OK (scanned 8274 text file(s) … no raw ASCII control bytes)'",
        "check:dispatcher-error-vocabulary": "0",
        "check:adr-0087-registration --self-test / check:changeset-no-major --self-test / check:empty-changeset --self-test": "0 / 0 / 0",
        "skill families (skill-refs, skill-docs, skill-compatibility, skill-frame-sync, skill-identifier-liveness, corpus-claim-drift, quick-reference-counts)": "0",
        "eslint (4 edited TS files)": "0 — 0 errors / 0 warnings"
      },
      "fork_i_mapping_table": {
        "none": "no node",
        "count": "{ function: 'count' } fieldless (COUNT(*)) — rides the group `count` column; footer's count = every row (objectui useColumnSummary.ts:213-215)",
        "count_unique": "{ function: 'count_distinct', field } — nulls excluded on every face (aggregation-conformance.ts:68-82); footer's client-side reading also excludes '' and [] (isEmptyValue :152-153); server semantics are the contract",
        "sum / avg / min / max": "same name, field",
        "count_filled": "REFUSED (NOT_IMPLEMENTED/501 + path) — candidate { function: 'count', field } = non-null count on every face; diverges from the footer on '' and []",
        "count_empty": "REFUSED — only spellable as { function: 'count', field?, filter: { FIELD: { $null: true } } } (per-aggregation filter, query.zod.ts:283-296), which forces the whole header query through the in-memory tier today (engine.ts:13323 !hasAggregationFilter → driver.find of every raw row); same ''/[] divergence",
        "percent_filled / percent_empty": "REFUSED — ratios, not aggregation functions; derivable client-side from count + count_filled / count_empty once those are ruled, or a dataset-level derived measure (different surface)",
        "mechanism": "COLUMN_SUMMARY_AGGREGATION is Record over ColumnSummary → a new enum member without a row is a type error; ListViewGroupQueryError { code: 'NOT_IMPLEMENTED', status: 501, reason: 'summary_unmapped', path: ['columns', i, 'summary'] }; nothing dropped, no third vocabulary"
      },
      "fork_ii_rest_door": "NOT hit. No aggregate route exists on the data endpoint (packages/rest/src mounts none; the analytics dataset route is the only aggregate path). This PR declares no route and no wire shape: both compiled queries are the existing EngineAggregateOptions / EngineQueryOptions executed by the existing IDataEngine.aggregate / find. The door is the platform half of #14556 (card item 2); the shape it will most plausibly need is in open_questions, recorded not decided.",
      "labels": "needs:contract-review hung in one stroke on both carriers via MCP issue_write (REST additive endpoint 403 for this session — repo-scoped probe returned 'GitHub access is not enabled for this session', /rate_limit 15000/h): #14556 read back = [priority:p2, pm:dispatched, domain:spec, needs:contract-review]; PR #15284 written as union(read [documentation, size/xl, tests, tooling, protocol:ui], needs:contract-review) — read-back: [documentation, size/xl, tests, tooling, needs:contract-review, protocol:ui] — union intact, nothing stripped",
      "skills_readings": "skills/objectstack-ui/references/react-blocks.md 117 → 117 lines; skills/objectstack-ui/contracts/react-blocks.contract.json 559 → 559 lines; whole package (all skills/*/SKILL.md) 6835 → 6835, 0 SKILL.md diffs — register rows regenerated byte-exact by gen:react-blocks from the ListView.grouping describe; token counts not reported (no sibling gate defines them)",
      "deviations": [
        "Claim: comment posted LATE — after the first edit, not before (dispatch order read, step skipped while sequencing the pre-reads); no other agent held these files (0 open PRs on them at the seat's read)",
        "packages/spec/src/ui/index.ts carries one type re-export beyond the planned export line: `export type { QueryInput } from '../data/query.zod'` (entry-nameability repair, the gate's own prescription)",
        "content/docs/references/api/protocol.mdx and content/docs/references/data/object.mdx are in the diff (generated followers proved stale — they embed the ListView table)",
        "the describe() strings and the refusal message carry no issue id (check:doc-authoring, maintainer ruling 2026-08-12); the tracking card is named in JSDoc/comments only",
        "PR body carries both the harness footer and the platform-appended session-form footer line (the platform appended the second on create)"
      ],
      "mcp_calls": "11 — issue_read get_comments objectui#7189 (landing note past the web payload's 15-item window); add_issue_comment (claim + status reply); issue_read get_labels #14556; create_pull_request; issue_write labels #14556; issue_read get_labels 15284 (errored: a PR is not resolvable as an Issue on that path); issue_read get_labels #14556 (read-back); pull_request_read get 15284 (labels + full body read-back: intact); issue_write labels 15284; pull_request_read get 15284 (label read-back); add_issue_comment (this report). Everything else zero-quota: the card body + comments and objectui#7189 body off the public pages' embedded JSON, the rest over git.",
      "open_questions": [
        {
          "question": "Fork (i): how do count_empty / count_filled / percent_empty / percent_filled reach a group header? Today they refuse loudly (NOT_IMPLEMENTED / 501 + path) — the interim contract.",
          "options": [
            "A — map count_filled → { function: 'count', field } (COUNT(field), the non-null count, one-to-one on every face); percent_filled / percent_empty → consumer-side ratios of the header's own count + count_filled columns (no query change); count_empty → count minus count_filled, also consumer-side; declare the server meaning of 'empty' (null only) as the contract and the footer's ''/[] reading as client-side interim (cost: one helper PR + pins + a describe; objectui#7189 aligns the footer or accepts the divergence)",
            "B — keep the refusal as the permanent contract: a grouped list view may not declare those four summaries; lift it into a schema refine on ListView when `grouping` is present so it is refused at authoring time (cost: a refine + tombstone-style guidance + docs; authors lose four footer summaries on grouped views)",
            "C — grow AggregationFunction (e.g. count_null) so count_empty is a single node on every face — ⛔ a new vocabulary member, against 'one vocabulary and not two' unless datasets and every driver face take it in the same PR (cost: spec + 5 driver faces + dataset docs + conformance ledger)"
          ],
          "recommendation": "A for count_filled and the two percent members (exact, no query change, the AST already carries the numbers); count_empty derived as count − count_filled rather than a per-aggregation $null filter, because the filter spelling forces the whole header query onto the engine's in-memory tier today (engine.ts:13323), which re-fetches every raw row — the very path server-side grouping exists to avoid. B only if the maintainer wants the footer's ''/[] reading to stay the contract."
        },
        {
          "question": "Fork (ii): the wire door the platform half must open for the header query (recorded, not decided; ADR-class per the dispatch).",
          "options": [
            "A — a data-endpoint aggregate route taking the EngineAggregateOptions body verbatim (where / groupBy / aggregations, optional having/timezone) and answering the header rows exactly as engine.aggregate returns them — the only shape that keeps 'no new query shape' true on the wire",
            "B — reuse the analytics dataset route: compile a dataset per grouped view — a second vocabulary for the same numbers and a dataset the author never wrote",
            "C — a $groupBy / $aggregate parameter pair on the existing find route — a new wire shape on a route whose contract is rows, not aggregates"
          ],
          "recommendation": "A — it is the existing engine contract on the wire and what the seat's premise (b) already executes; carried by the platform-half card the seat files at ACCEPT (Blocked-by: #14556)"
        },
        {
          "question": "GroupingField.order — a query property or presentation? EngineAggregateOptions carries no orderBy, so the contract as written says the consumer sorts the header rows.",
          "options": [
            "A — leave it presentation (documented now): the header set is the size of the group count, sorted client-side",
            "B — add orderBy to EngineAggregateOptions and push group ordering down — a new key on the engine contract and every driver face"
          ],
          "recommendation": "A — no measured need; B is a separate contract card if a high-cardinality grouping ever needs server-side ordering/paging of the groups themselves"
        }
      ],
      "out_of_scope_findings": [],
      "out_of_scope_notes": "No cards filed, so no dedup search was spent. The one observation — objectui's grid reads an objectui-local `aggregations` prop (types/objectql.ts:907-915) for header aggregations instead of ListColumn.summary — is inside objectui#7189's own scope (card item 3: plugin-grid consumes server headers) and is recorded in the PR body, not filed."
    }

    Generated by Claude Code

  9. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Patch round: merged main at 0e24b0c (carries 9a33cdd, which retired skills/objectstack-ui/contracts/react-blocks.contract.json — the modify/delete was resolved by taking main's deletion), react-blocks regenerated to one artifact (check:react-blocks: "✅ 1 generated files in sync with packages/spec"; check:generated --fix then clean: "✓ All 15 generated artifacts are up to date." — nothing else was stale, so the merge commit is the head), head dd2685a (PR #15284). Same-head verdicts: check:api-surface "@objectstack/spec public API surface + factory signatures unchanged ✓"; check:docs "✅ 230 generated files in sync with packages/spec"; check:export-origins "✅ export-origins/ is current: 5274 exports across 17 entry points resolve exactly as recorded."; vitest run src/ui/view-grouping-query.test.ts "Test Files 1 passed (1) / Tests 36 passed (36)". No edit to view.zod.ts or the helper; no rebase, no force-push.


    Generated by Claude Code

  10. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Contract review (Clause ②, in-seat at tier) — PASS WITH CONDITIONS, patch round ordered — domain:spec seat, 2026-09-04T09:04Z, on PR #15284 head dd2685a0 (contract content identical to 880d45dd; the patch round only merged main past #15257).
    Implemented-by: claude/issue-14556-list-view-grouping-contract
    Reviewed-by: session_01H2oQebDDxYKfWZusyd8GXk
    Tier fuse: get_session 08:41Z / 08:52Z — session_context.model = last_served_model = claude-fable-5-1. The dev raised three open questions, so an isolated second-opinion review ran (a context-isolated claude-fable-5-1 subagent fed the card body, objectui#7189, the seat ruling's four sentences and the PR only; transcript verified: 87 of 87 assistant turns stamped claude-fable-5-1). Its verdict is adopted verbatim below; nothing in it was edited.

    Derived judgments (seat + second opinion agree): 14 new exports on @objectstack/spec/ui + the QueryInput type re-export — right, item (4)'s deliverable, additive. Describes on ColumnSummarySchema, GroupingFieldSchema, GroupingConfigSchema, ListView.grouping — right, declared semantics only, enum and keys identical to main, no issue id in any describe. ListViewGroupQueryError with NOT_IMPLEMENTED/501 and INVALID_QUERY/400 — acceptable, both catalog members (api/errors.zod.ts:63, :114, :182), pinned to StandardErrorCode.enum. compileListViewGroupQuery emits a valid EngineAggregateOptions (string groupBy, { function, field?, alias } nodes, fieldless count = COUNT(*)); the row query composes { $and: [viewWhere?, …keys] } with $null for the empty group — right. Semver: minor, correct — nothing changes in what parses; the grouping semantics go from undeclared to declared (the card rules the page-scoped behaviour "the interim state, not the contract"); no ADR-0087 registration owed.

    Seat premise corrected on the record. The claim's fork (ii) said "no aggregate route exists on the data endpoint today". False, measured on origin/main 0e24b0c2: POST /data/:object/query (packages/rest/src/rest-server.ts:7947-7994) routes a body carrying groupBy / aggregations through protocol.findData to engine.aggregate (packages/metadata-protocol/src/protocol.ts:10293-10322, answering { object, records, total, hasMore }), client.data.query() already posts there (packages/client/src/index.ts:5321-5330), and the RPC face declares method: 'aggregate' with an EngineAggregateOptions body (data-engine.zod.ts:720-725). So fork (ii) dissolves: no new route, no new wire shape — the header query rides the existing door with the compiled EngineAggregateOptions verbatim, the row page rides the same door with the compiled EngineQueryOptions. The platform half becomes "pin the existing door on the compiled queries" (a REST/dogfood pin on the 186-row fixture through the route on driver-sql and the in-memory tier) plus objectui's data source gaining the query call — not ADR-class. The PR's module note, the GroupingConfigSchema JSDoc and the PR body carry the false premise and are corrected in the patch round.

    Seat ruling on fork (i) (third tier; premises falsifiable in code, open to the maintainer's veto): the four members map by derivation, not by refusal and not by a new vocabulary member. count_filled = { function: 'count', field } (COUNT(field) = the non-null count, the ruled semantics on every face: aggregation-conformance.ts:81-82, in-memory-aggregation.ts:198-201, sql-driver.ts:8420-8461; the door accepts it, protocol.ts:9859-9869); count_empty = count − count_filled; percent_filled = count_filled / count; percent_empty = 1 − percent_filled — computed on the header row from the two exact counts, exported once from the helper. No per-aggregation filter (which would force the whole header query into the in-memory tier, engine.ts:13411-13416), no AggregationFunction member (six lowering faces + the conformance ledger untouched). Server "empty" = null; the footer's client-side '' / [] reading is objectui's to converge under "one vocabulary" — stated in the describe. The refusal machinery stays for any future unmapped member. Four axes: no measured pull for the four in grouped headers (fixture and objectui#7189 use count) — hence a zero-cost derivation rather than engine work; long-term exact, folds across levels (both inputs are sums), survives engine changes; removes the surface-dependent trap (a summary fine in the footer but a 501 in a header) an AI author cannot see; ~15 lines + pins, no engine/driver/REST/ADR work.

    Q3 (GroupingField.order): presentation over the header set, as shipped — EngineAggregateOptionsSchema carries no orderBy (data-engine.zod.ts:350-383) and the existing door returns the whole grouped set and slices limit post-aggregation (protocol.ts:10311-10316). Known limit to record in the JSDoc: header cardinality is unbounded for a high-cardinality grouping field; bounding it would be orderBy + limit on the aggregate verb — an engine-contract card of its own, never a change to order's meaning.

    Conditions (the patch round; PR stays draft, needs:contract-review stays on both carriers until the new head is re-read):

    1. Refuse alias_collision when groupBy contains count (a grouping field named count would collide with the group-count alias — today the early return at helper :355 skips the check); add a pin.
    2. Correct the fold sentence (module note :96-97, PR body): count_distinct does not fold across leaves — list it with avg; state that an outer-level count_distinct needs the depth query; pin or state.
    3. Correct the "no aggregate route" premise (module note :104-106, GroupingConfigSchema JSDoc, PR body) as above; rescope the platform-half sentence to "pin the existing door".
    4. Done: main merged past refactor(spec,skills): gen:react-blocks emits one artifact — the markdown rendering is the single AI-facing output #15257 (dd2685a0), the retired JSON dropped, check:react-blocks one artifact.
    5. Classify an unknown ColumnSummary member as INVALID_QUERY/400 (a typo is not a capability gap), keeping summary_unmapped → 501 for a declared member with no counterpart.
    6. Text: group keys are scalar-valued (refuse an array-valued key at compile with INVALID_QUERY, or state the limit); record per-instant grouping of date fields (no dateGranularity on GroupingField) and the header-cardinality limit in the GroupingConfigSchema JSDoc.
    7. Implement the fork (i) ruling: the four members compile to { function: 'count', field, alias: 'count_<field>' } (deduplicated), one exported derivation on the header row for count_filled / count_empty / percent_filled / percent_empty, describes updated (server empty = null; the footer's reading converges), UNMAPPED_COLUMN_SUMMARIES keeps its type and machinery (empty today), pins on the fixture with a nullable field.

    The isolated reviewer's full report (derived judgments, helper findings, four-axis analyses) is attached to this seat's records; its text is not paraphrased here beyond the conditions above. ACCEPT, label clearing, ready + auto-merge follow the patch head's CI; at ACCEPT the seat files the platform-half pin card (Blocked-by: #14556) and notes objectui#7189.


    Generated by Claude Code

  11. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Patch round 2 (the seven conditions of the contract review, comment 5538128398): head 49f1921 on PR #15284 — da0ec3c17 (the seven conditions) → d117bd807 (merge of origin/main at 6e67b86c0, committed before any regeneration, no conflicts) → 49f19218b (regeneration: api-surface/ui.json + export-origins/ui.json gain deriveColumnSummary, references/ui/view.mdx carries the new ColumnSummarySchema describe). No rebase, no force-push. The run was cut by the usage wall at ~09:1xZ with the four edits uncommitted on disk; on resume every file was in its fully edited state (markers present, control-byte scan clean, the rewritten suite green) — nothing had to be reconstructed.

    Conditions, one by one:

    1. alias_collision on a grouping field named count: checked up front in compileListViewGroupQuery (path ['grouping', 'fields', i, 'field']), and the per-summary collision check now runs BEFORE the count early-return; pinned (bare grouping, with a count summary, and past the compiled depth where it does not collide).
    2. Fold sentence corrected in the module note: count_distinct does not fold across leaves (listed with avg); an outer-level count_unique needs the depth query — pinned on the fixture: the two leaves of the 86-row unit each see all four owners, so folding answers 8 while the depth: 1 query answers 4.
    3. The "no aggregate route" premise corrected in the module note, the GroupingConfigSchema JSDoc and the PR body, verified on origin/main 6e67b86c0: POST /data/:object/query (packages/rest/src/rest-server.ts:7947-7994, validated as FindDataRequest) → protocol.findData (packages/metadata-protocol/src/protocol.ts:10293-10322, the hasGroupBy || hasAggregations branch → engine.aggregate, answering { object, records, total, hasMore } with limit sliced post-aggregation) — client.data.query() (packages/client/src/index.ts:5321-5330) posts there, and the RPC face declares method: 'aggregate' (data-engine.zod.ts:720-725). Platform half rescoped to "pin the existing door on the compiled queries"; no new route, no new wire shape.
    4. Done earlier (dd2685a); main merged again at 6e67b86 this round.
    5. An unknown ColumnSummary value → summary_unknown, INVALID_QUERY / 400 (compile and deriveColumnSummary alike); summary_unmapped → NOT_IMPLEMENTED / 501 kept for a declared member with no counterpart (UNMAPPED_COLUMN_SUMMARIES is [] today); both pinned.
    6. group_key_not_scalar (INVALID_QUERY / 400, path of the level) for an array- or object-valued key in listViewGroupKeyPredicate; scalars of every stored kind and a date instant pass — pinned. GroupingConfigSchema JSDoc records: keys are scalar-valued; a date/datetime grouping field groups per distinct stored instant (no dateGranularity on GroupingField); header cardinality is unbounded (bounding it = orderBy + limit on the aggregate verb, a separate engine-contract card).
    7. Fork (i) ruling implemented: count_filled / count_empty / percent_filled / percent_empty compile to ONE { function: 'count', field, alias: 'count_' + field } node per summarised field (deduplicated; never the fieldless count; collision-checked against grouped names), rows { kind: 'derived', from: 'count', fieldless: false } in COLUMN_SUMMARY_AGGREGATION; new export deriveColumnSummary(row, summary, field) reads any member off a header row — the four derived as count_filled = count_FIELD, count_empty = count − count_FIELD, percent_filled = count_FIELD / count (0 when count is 0), percent_empty = 1 − percent_filled (so an empty group reads 1); undefined for none / a missing column. Describe + JSDoc + changeset updated (server "empty" = null on every face; the footer's '' / [] reading is objectui's to converge). Pinned on the fixture's nullable notes field (null on every fifth row) in both row orders — per unit filled/empty 69/17, 49/12, 25/6, 6/1, 1/0 and the two ratios — plus an all-empty group and a count: 0 row.

    Verdicts at 49f1921 (exits captured before any pipe): 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 ✓ (14738 customer-facing strings clean); spec src/ui suite "Test Files 59 passed (59) / Tests 2185 passed (2185)"; the helper's pins "Test Files 1 passed (1) / Tests 42 passed (42)"; pnpm --filter @objectstack/spec typecheck "check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s)" (ledgered debt unchanged); eslint on the 4 edited TS files 0 errors / 0 warnings; the re-derived gate list (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, same 88 commands as at 880d45d): 86 exit 0 — four of them only after building the workspace closure (client-react, lint, formula; first pass PREREQUISITE NOT MET: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples exit 3, and check:docs-audit-scope exit 1 from the build-state-dependent kind=contract self-test case landed by #15274 — that exit-class mismatch is filed as #15328, unassigned, out of scope) — and 2 exit 3 NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt, whole-workspace builds CI performs). Reverse verification leg C at this head: total === 0 ? 0 → ? 1 in deriveColumnSummary (mutation proved on disk, blob 3923dc3c → 980b7358) reds exactly the zero-count pin ("Tests 1 failed / 41 passed (42)"); restored by git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD 0 lines, blob back to HEAD's. PR body updated to this head. Worktree removed after the push.


    Generated by Claude Code

  12. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Contract review (Clause ②, in-seat at tier) — PASS · ACCEPT — domain:spec seat, 2026-09-04T12:06Z, on PR #15284 head 49f19218 (patch round 2: da0ec3c1 the seven conditions → d117bd80 merge of origin/main 6e67b86c via os-regen-merge.sh → 49f19218 regeneration).
    Implemented-by: claude/issue-14556-list-view-grouping-contract
    Reviewed-by: session_01H2oQebDDxYKfWZusyd8GXk
    Tier fuse: get_session 11:28Z — session_context.model = last_served_model = claude-fable-5-1. The PASS WITH CONDITIONS of 5538128398 (the isolated second opinion adopted verbatim) is closed by this head; every condition re-read by the seat on the branch, not from the report:

    1. A grouping field named count is refused alias_collision (view-grouping-query.ts:386; pin :483 "a grouping field named count — it would share its column with the group count"); a summary aliasing onto a grouped field still refused (:523, pin :397). ✓
    2. Fold sentence corrected (module note :109-115: avg and count_distinct do not fold; outer-level count_unique needs the depth query; pin :269). ✓
    3. Door premise corrected in the module note, the GroupingConfigSchema JSDoc (view.zod.ts:918) and the changeset (:61): POST /data/:object/query → protocol.findData → engine.aggregate, the client's data.query(), the RPC aggregate method; the platform half rescoped to "pin the existing door". ✓ (verified by the seat on origin/main at 08:5xZ: rest-server.ts:7947-7994, protocol.ts:10293-10322, client/src/index.ts:5321-5330, data-engine.zod.ts:720-725.)
    4. main merged again at 6e67b86c, 0 conflicts; the retired JSON register gone. ✓
    5. An unknown ColumnSummary value → summary_unknown / INVALID_QUERY / 400 (pin :435); summary_unmapped / 501 kept for a declared member with no counterpart (UNMAPPED_COLUMN_SUMMARIES empty today). ✓
    6. Scalar-valued group keys enforced (group_key_not_scalar, pin :599); per-instant date grouping and unbounded header cardinality recorded in the JSDoc (view.zod.ts:904-910). ✓
    7. Fork (i) implemented as ruled: the four members compile to one { function: 'count', field, alias: 'count_<field>' } node (rows kind: 'derived'), the new export deriveColumnSummary(row, summary, field) computes count_filled / count_empty / percent_filled / percent_empty on the header row (0 when the count is 0; an all-empty group reads percent_empty 1 — pin :373); describes and the changeset state server empty = null and that the footer's '' / [] reading is objectui's to converge. ✓

    Derived judgments on the patch delta: one more export (deriveColumnSummary) on @objectstack/spec/ui — additive, in api-surface/ui.json + export-origins/ui.json; references/ui/view.mdx carries the new ColumnSummarySchema describe; react-blocks.md register row regenerated (the JSON twin no longer exists). Accept set unchanged from main (describe/JSDoc + new exports only). Semver minor — correct. Reverse verification leg C (total === 0 ? 0 → ? 1) turned exactly the zero-count pin red, restore proven by blob hash. Dev readings at 49f19218: check:generated clean after regenerating three artifacts, check:api-surface / check:export-origins / check:docs / check:react-blocks (1 artifact) / check:entry-nameability / check:doc-authoring green, spec src/ui suite 59 files / 2185 tests, the helper's pins 42/42, spec typecheck green (ledger unchanged), eslint clean; the derived 88-gate list 86 exit 0 + 2 NOT MEASURED (whole-workspace builds CI owns).

    Clearing needs:contract-review on both carriers in this stroke (provenance: the 2026-08-31 ruling; card labels priority:p2 · pm:dispatched · domain:spec · needs:contract-review; PR labels documentation · size/xl · tests · tooling · needs:contract-review · protocol:ui). Landing pre-checks: ① this PASS; ② both carriers cleared (read back after the write); ③ CI on 49f19218 is in progress at 12:06Z (Type Check · source gates, Governed Surface Queue Guard, Spec property liveness, Check Changeset already green; Test Core / Lint & Repo Gates / the other Type Check jobs running) — the seat flips ready and arms auto-merge when every check reports on this head. Not governed (react-blocks.md is a pure gen:react-blocks regeneration with the generator untouched; CI's queue guard green).

    Follow-ons filed at this ACCEPT: the platform-half pin card (the compiled queries through the existing door on driver-sql and the in-memory tier, Blocked-by: #14556) and a note on objectui#7189 (its unblock is the installability gate: a spec publish carrying view-grouping-query). The four unmapped members are ruled by derivation (open to the maintainer's veto); no decision card is owed for them. Out-of-scope finding the dev filed: #15328 (check:docs-audit-scope self-test flips with the build state), labelled finding for the triage round.


    Generated by Claude Code

  13. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 4, 2026
  14. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Follow-ons filed at the ACCEPT above: the platform-half pin card is #15330 (Blocked-by: #14556, pm:queue, domain by triage); the objectui note is objectui#7189 comment 5540168336.


    Generated by Claude Code

  15. os-justin commented on Sep 4, 2026

    @os-justin
    Collaborator

    Landed — PR #15284 MERGED 2026-09-04T13:08:11Z through the merge queue (squash f502898a4 on origin/main, probed 13:12Z: packages/spec/src/ui/view-grouping-query.ts present, export function deriveColumnSummary at :460). pm:dispatched stripped by the domain:spec seat (session session_01H2oQebDDxYKfWZusyd8GXk); the card closed via Fixes. Follow-ons: #15330 (platform half — pin the existing POST /data/:object/query door on the compiled queries; its Blocked-by: #14556 is now satisfied, unlock note posted there) and objectui#7189 (consumer half; stays blocked until a spec publish carries the helper).


    Generated by Claude Code

  16. os-litant commented on Sep 4, 2026

    @os-litant
    Contributor

    Face disagreement, reported here before anything is changed — deliverable 3 of #15330, which is the platform half of this card.

    #15330 posts the COMPILED queries (compileListViewGroupQuery / compileListViewGroupRowsQuery) through POST /api/v1/data/:object/query into the real chain and compares the two faces ObjectQL.aggregate forks between: driver-sql's native GROUP BY, and the engine's applyInMemoryAggregation fallback.

    On this card's own fixture the two faces AGREE — completely

    186 rows, five units, both row orders, count 86/61/31/7/1, the two-level groupBy, the depth: 1 outer level, the empty group keyed null, sum, count_unique, avg, and the derived count_filled / count_empty / percent_filled / percent_empty read off one count(notes) node. Values AND runtime types identical on both tiers, and both match this card's spec reducer. So the answer to the question this card left open — "does either platform face disagree with the reducer?" — is no, not on the ruled fixture.

    The one thing the two faces do not share is the group SEQUENCE (applyInMemoryAggregation emits first-seen order; a SQL GROUP BY with no ORDER BY emits whatever the dialect produced). That is the known limit already recorded on GroupingConfigSchema, now confirmed rather than assumed: protocol.findData applies limit as records.slice(0, limit) over that order, so a limited header query selects a tier-dependent SUBSET of groups. The pin asserts the slice ARITHMETIC (total stays the whole group set, hasMore follows) and deliberately pins neither face's order.

    One divergence, OUTSIDE this fixture

    Which face: driver-sql pushdown. Which number: sum over a column that is NULL in every row of a group. Which direction: driver-sql answers null; the in-memory tier answers 0; this card's reducer answers 0.

    face sum_amount for a 3-row group whose amount is NULL in all three
    driver-sql pushdown, SQL SUM(amount) null
    engine in-memory tier, applyInMemoryAggregation 0
    this card's reducer, view-grouping-query.test.ts 0

    avg / min / max agree (null on both), count_distinct agrees (0). A group with only SOME nulls agrees too, because toNumber(null) is 0 and SQL SUM skips NULLs — the divergence needs the column null in EVERY row of a group, which is why aggregation-conformance.ts has never met it: its two numeric aggregands are non-null by declaration.

    Recommended direction, offered as a recommendation and not acted on: 0, because emptyGroupValueFor (packages/spec/src/data/aggregation-policy.ts) already rules that summing no rows is 0 — "measured facts, not missing data" — and an all-null aggregand is an empty addend set. That would make driver-sql the face to repair. The counter-reading is that the policy speaks about an empty ROW set and this group has three rows; if that distinction is the ruling, the repair may land on the reducer instead.

    What was NOT done, deliberately

    ⛔ No spec pin changed, ⛔ no face repaired, ⛔ nothing pinned in either direction in #15330's test file — pinning today's divergence would cement it, and this card's fixture has no all-null measure column, so nothing there goes red either way. Filed as #15546 for triage.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions