Skip to content

feat(spec): list-view grouping is server-side — compile the group header query and the per-group row page from the view - #15284

Merged
os-justin merged 6 commits into
mainfrom
claude/issue-14556-list-view-grouping-contract
Sep 4, 2026
Merged

feat(spec): list-view grouping is server-side — compile the group header query and the per-group row page from the view#15284
os-justin merged 6 commits into
mainfrom
claude/issue-14556-list-view-grouping-contract

Conversation

@os-justin

@os-justin os-justin commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14556

Implemented-by: claude/issue-14556-list-view-grouping-contract (dev subagent of the domain:spec seat, mode:subagent; claim comment on the card)
Clause-②: yes — needs:contract-review on 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 (da0ec3c17 the conditions → d117bd807 merge of origin/main at 6e67b86c049f19218b regeneration).

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/ui that compiles a list view's grouping + 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:

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 card's spec 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.

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[].field in nesting order, aggregations = a count node + the column summaries mapped onto AggregationFunction, where = the view's composed filter); (2) rows inside a group = the EXISTING paged find with the group key AND-ed into the view filter; (3) execution = the existing IDataEngine.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 / *_empty members map by derivation from COUNT(*) and COUNT(field) — implemented below.

Premises, verified on origin/main at 1bc3c092 (the branch point)

(a) AggregationNodeSchema + groupBy + EngineAggregateOptions cover a multi-column groupBy with a count alias — holds.
packages/spec/src/data/query.zod.ts:159-162AggregationFunction = z.enum(['count','sum','avg','min','max','count_distinct']); :200-207GroupByNodeSchema = z.union([z.string(), z.object({ field, dateGranularity?, alias? })]); :272-296AggregationNodeSchema = { function, field?: (optional for COUNT(*)), alias, filter? }. packages/spec/src/data/data-engine.zod.ts:350-383EngineAggregateOptionsSchema = BaseEngineOptions.extend({ where, groupBy: z.array(GroupByNodeSchema), aggregations: z.array(AggregationNodeSchema), having, timezone }); :826EngineAggregateOptions = z.input of it. contracts/data-engine.ts:273aggregate(objectName, query: EngineAggregateOptions, options?). The array form of groupBy IS the multi-column form; a fieldless count node is COUNT(*).

(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:13209async aggregate(object, query: EngineAggregateOptions, options?); :13323 — the dispatch: if (typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory && !hasAggregationFilter):13330 drv.aggregate(object, ast, …); else :13339-13340 driver.find(object, ast, …) + applyInMemoryAggregation(raw, ast, tz). driver-sql: packages/drivers/driver-sql/src/sql-driver.ts:8311async aggregate(object, query, options); :8332-8347 — every groupBy item becomes builder.groupBy(g) + select(g) (the string form) — multi-column by construction; :1261-1268 — the lowering table count → 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 every groupBy item, keyed fieldName = value (absent folds to null); :188-200count with no field (or *) = rows.length, with a field = the non-null count. Ruled semantics across faces: packages/spec/src/data/aggregation-conformance.ts:68-82count(*) = rows, count(col) = non-null, count_distinct(col) = distinct non-null.

PM mechanism assumptions — verified, one nuance

  • (c) ColumnSummarySchema + ListColumn.summary is the view's per-column summary vocabulary — holds (view.zod.ts ColumnSummarySchema, ListColumnSchema.summary: z.union([ColumnSummarySchema, ColumnSummaryConfigSchema])). Nuance for the objectui half: today's grid header aggregations are read from an objectui-local schema.aggregations prop (objectui packages/types/src/objectql.ts:907-915, "ObjectUI-specific (not in @objectstack/spec …)"; plugin-grid/src/ObjectGrid.tsx:2162-2166 passes it to useGroupedData), not from columns[].summary. The contract points at ListColumn.summary; the switch is objectui#7189's.
  • (d) a per-group row page is find with where: { $and: [viewFilter, { field: key }] } — holds. data/filter.zod.ts:1362-1384FilterCondition carries $and / $or / $not; { $and: [] } is TRUE by the ruled reduction (:1437). The empty group is spellable: SpecialOperatorSchema.$null (:1142-1146, lowered to IS NULL / { field: null }), and it is the spelling the view filter dialect's is_empty / is_null lower to (parseFilterAST, :2038-2041); $eq: null is also accepted as "has no value" (:251-253) — the helper uses $null so 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).
  • (e) generated followers — as predicted, plus two not named: content/docs/references/ui/view.mdx (describes) AND content/docs/references/api/protocol.mdx + content/docs/references/data/object.mdx (they embed the ListView table, so the grouping describe lands there too); api-surface/ui.json + export-origins/ui.json (15 new names + 1 re-export); declaration-map unchanged (gate green); authorable-surface unchanged (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 when main was merged) carries ListView.grouping's describe. All regenerated by check:generated --fix, then a clean run: "✓ All 15 generated artifacts are up to date."
  • (f) changeset @objectstack/spec minor — 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-registration green).

Fork (i) — ruled and implemented: the mapping table ColumnSummary → the aggregation vocabulary

ListColumn.summary aggregation node on the header row
none no node
count { function: 'count' } (fieldless, COUNT(*)) count — the group count itself (the footer's count is "every row, filled or not", objectui useColumnSummary.ts:213-215)
count_unique { function: 'count_distinct', field } count_distinct_FIELDCOUNT(DISTINCT field), nulls excluded on every face (aggregation-conformance.ts:68-82)
sum / avg / min / max the same name, field FUNCTION_FIELD
count_filled { function: 'count', field } — ONE node per summarised field, deduplicated, never the fieldless count count_FIELD = COUNT(field), the non-null count
count_empty the same node derived: count − count_FIELD
percent_filled the same node derived: count_FIELD / count — a ratio in 0..1, 0 when count is 0
percent_empty the same node derived: 1 − percent_filled (an empty group reads 1)

Derivation is deriveColumnSummary(row, summary, field) (new export): reads any member off a header row — the aggregate members from their own column (count from count), the four derived ones from count + count_FIELD; undefined for none and for a column the header query was compiled without. Server "empty" = null on 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-aggregation filter (it would route the whole header query through the in-memory tier), no new AggregationFunction member. COLUMN_SUMMARY_AGGREGATION is a Record over ColumnSummary — a new enum member without a row is a type error; UNMAPPED_COLUMN_SUMMARIES is [] today and the summary_unmapped refusal (NOT_IMPLEMENTED / 501) stays for a future declared member with no counterpart; a value that is no member at all is summary_unknown (INVALID_QUERY / 400 — a typo is not a capability gap). All pinned.

Fork (ii) — corrected: the door exists

The claim's premise "no aggregate route exists on the data endpoint" was false; verified on origin/main 6e67b86c0: POST /data/:object/query (packages/rest/src/rest-server.ts:7947-7994, the body validated as FindDataRequest) → protocol.findData (packages/metadata-protocol/src/protocol.ts:10293-10322: the hasGroupBy || hasAggregations branch calls engine.aggregate({ where, groupBy, aggregations, having, context }) and answers { object, records, total, hasMore }, slicing limit after aggregation) — client.data.query() (packages/client/src/index.ts:5321-5330) posts there, and the RPC face declares method: 'aggregate' with an EngineAggregateOptions body (data-engine.zod.ts:720-725). So both compiled queries ride the existing door verbatim (EngineAggregateOptions for the header rows as records, EngineQueryOptions for 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, the GroupingConfigSchema JSDoc 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' } }:

{
  where: { status: { $eq: 'open' } },
  groupBy: ['business_unit'],
  aggregations: [
    { function: 'count', alias: 'count' },
    { function: 'sum', field: 'amount', alias: 'sum_amount' },
    { function: 'count_distinct', field: 'owner', alias: 'count_distinct_owner' },
  ],
}

With notes: count_filled, count_empty (object form on notes), notes: percent_filled, amount: percent_empty declared, the aggregations are count, { 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; null for the empty group), count, each summary under FUNCTION_FIELD (columnSummaryAlias), the derived four on count_FIELD. A two-level grouping compiles to groupBy: ['business_unit', 'status'] (one row per leaf; depth: 1 compiles the outer level's own query). Folding across leaves: count, sum, min, max and count_FIELD fold exactly; avg and count_distinct do NOT — an outer-level avg / count_unique needs the depth query (pinned: the 86-row unit's two leaves each see all four owners, folding says 8, depth: 1 says 4).

Row page for the 86-row group, page 2 of 25:

{ where: { $and: [{ status: { $eq: 'open' } }, { business_unit: { $eq: 'northgate_operations' } }] }, limit: 25, offset: 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 named count), summary_unknown, summary_unmapped, grouping_empty, grouping_field_blank, depth_out_of_range, group_key_not_a_prefix, group_key_not_scalar. Recorded limits (GroupingConfigSchema JSDoc): a date/datetime grouping field groups per distinct stored instant (no dateGranularity on GroupingField); header cardinality is unbounded (the door slices limit after aggregation; bounding it is orderBy + limit on 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; notes null on every fifth row)

reduction contiguous order interleaved (round-robin) order
page-scoped grouping over the first 100 rows (the interim, what useGroupedData does) 2 headers: 86, 14 — three units absent 5 headers: 31 / 31 / 30 / 7 / 1
the compiled header query, reduced over the whole set 86 / 61 / 31 / 7 / 1 86 / 61 / 31 / 7 / 1
+ view filter status = done 28 / 20 / 10 / 2 (the 1-row unit has no done row — no header, by the query) same
two-level business_unit × status 9 leaf rows; northgate_operations: open 58 + done 28 = 86; leaf count_distinct_owner folds to 8, depth: 1 answers 4 same
derived on notes: count_filled / count_empty per unit 69/17 · 49/12 · 25/6 · 6/1 · 1/0; percent_filled = filled/size, percent_empty = empty/size same
an all-empty group (count_notes 0) · a count: 0 row filled 0, percent_filled 0, percent_empty 1 · 0 and 1, no division
row page of the 86-row group, limit: 50 pages of 50 + 36; 86 distinct ids, all in the group same

Both 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 quoted

  • packages/spec/src/ui/view-grouping-query.test.ts, 42 pins — targeted run: "Test Files 1 passed (1) / Tests 42 passed (42)".
  • @objectstack/spec src/ui suite: "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 head tsc --listFiles showed both files inside the program with 0 ledgered errors in them).
  • Downstream consumer (read at 880d45dd0, direction DEPENDENTS of spec, named explicitly): @objectstack/objectql typecheck against the built spec dist → "VERDICT command-exit 0". objectui plugin-grid at 24e027e9, read-only: ObjectGrid.tsx:1795-1797 + :1948 fetch one window with dataSource.find(objectName, { $select, $top, $skip, … }); :2162-2166 useGroupedData(schema.grouping, data, schema.aggregations, …); :3384 / :3390 the !isGrouped exclusions. Once the platform half pins the door, the grouped branch posts the compiled header query to POST /data/:object/query (client.data.query()) and per expanded group the compiled row page; useGroupedData consumes ListViewGroupHeaderRow[] (count, FUNCTION_FIELD, count_FIELD via deriveColumnSummary) instead of bucketing data; the two !isGrouped exclusions go; the Partial marker retires when counts are server-true — objectui#7189's list.
  • ESLint (plain form) on the 4 edited TS files: 0 errors / 0 warnings; file count 4 from --format json; eslint.config.mjs sets no parserOptions.project, so the narrowing cannot move an untouched file's verdict; repo-wide pnpm lint is CI's.

Reverse verification (pins bite) — three legs, each mutated, proved on disk, run, restored, proved restored

  • Leg A (at 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: blob 626e7fce841ad69d; "Tests 1 failed | 35 passed (36)" — exactly the $null pin.
  • Leg B (at 79f007c5b) — names.slice(0, depth)names.slice(0, 1): blob → 9febb482; "Tests 2 failed | 34 passed (36)" — the two-column groupBy pin and the depth pin.
  • Leg C (at 49f19218b) — total === 0 ? 0? 1 in deriveColumnSummary: anchor 1→0 / injected 0→1, blob 3923dc3c980b7358; "Tests 1 failed | 41 passed (42)" — exactly the zero-count pin.
  • Restore each leg: git checkout HEAD -- ABSOLUTE_PATH under trap … EXIT INT TERM; proved by STATE: git diff HEAD 0 lines, git status --short 0 paths, blob back to HEAD's (match=YES). 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 the mutation and the run.

Gates at 49f19218b — derived, not recalled

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 49f1921 … --repo checked against this checkout's 'origin' remote — it holds") → the same 88 commands as at 880d45dd0; 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's lint.yml does 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), and check:docs-audit-scope — whose first pass was an exit 1 from the build-state-dependent kind=contract self-test case landed by #15274 on main (this branch touches 0 lines under scripts/); 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 to any: 2460 types + 1524 schemas"; check:nul-bytes "OK … no raw ASCII control bytes".

Full 88-row table (command · exit · last line):

command exit last line
check-adr-0087-registration.mjs --self-test 0 ✓ check-adr-0087-registration --self-test: 292 assertions over real temp git repos (real scan()/assertInputs
check-changeset-no-major.mjs --self-test 0 ✓ check-changeset-no-major --self-test: 116 assertions (frontmatter dialects measured against @changesets/pa
check-ci-filter-parity.mjs 0 OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg, every crosspkg e
check-closing-keyword-parity.mjs 0 check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found
check-closing-keyword-parity.mjs --self-test 0 ✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to
check-comment-mask-adoption.mjs 0 OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/**, all 14 rec
check-comment-mask-adoption.mjs --self-test 0 PASS check-comment-mask-adoption --self-test (0 failure(s))
check-comment-mask-corpus.mjs 0 ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 5892 files, 0 disagree, 0 unparseable, 54.6s (com
check-dev-prereqs.mjs --self-test 0 ✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned (16 ca
check-doc-frontmatter.mjs 0 ✓ check-doc-frontmatter: 2 content root(s) verified, each against its own floor — content/docs 405, conten
check-doc-frontmatter.mjs --self-test 0 ✓ check-doc-frontmatter --self-test: 85 assertions — the card's own description observed failing with the
check-doc-route-spelling.mjs --advisory 0 ✓ route-spelling guard (advisory): population clean — every shape-matched literal spells its ledger row.
check-doc-route-spelling.mjs --self-test 0 ✓ check-doc-route-spelling self-test: extraction tidy-up, the variant relation (plural + pinned lexicon, no
check-docs-section-name.mjs 0 so it is carried by --self-test rather than by this corpus.
check-docs-section-name.mjs --self-test 0 ✓ check-docs-section-name self-test: 85 cases pass (real temp trees on disk; both historical misses reproduc
check-empty-changeset.mjs --self-test 0 ✓ check-empty-changeset --self-test: 118 assertions over real temp git repos (real scan() path)
check-keyed-text-bounds.mjs 0 ⚠ The delta is information, not a verdict — this population grows AND shrinks for good reasons, and only
check-keyed-text-bounds.mjs --self-test 0 PASS check-keyed-text-bounds --self-test (0 failure(s))
check-plugin-teardown-shape.mjs 0 ✓ check:plugin-teardown-shape: 64 Plugin implementation(s) across 5405 source(s) under packages/**; every te
check-plugin-teardown-shape.mjs --self-test 0 ✓ check-plugin-teardown-shape self-test: 47 cases pass (real pre-#10375 fixture reds, the repaired file and
check-section-landing-index.mjs 0 ✓ check-section-landing-index: 8 section index block(s) enumerate their meta.json pages, in order, both dire
check-section-landing-index.mjs --self-test 0 ✓ check-section-landing-index --self-test: 31 assertions over synthetic inputs and a temp fixture (real judg
check-system-context-census.mjs 0 check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140
check-system-context-census.mjs --self-test 0 check-system-context-census --self-test: all cases passed
check-undeclared-dep-imports.mjs 0 ⚠ The delta is information, not a verdict — the floors are >= and cannot see an upward drift at all, w
check-undeclared-dep-imports.mjs --self-test 0 PASS check-undeclared-dep-imports --self-test (0 failure(s))
docs-audit/check-affected-docs.mjs 0 → the unreachable rows themselves: this command with --json
docs-audit/check-drift-comment.mjs 0 ✓ check-drift-comment: 56 cases pass across 5 fixture diff(s).
pm/release-rehearsal-clone.mjs --self-test 0 ✓ self-test passed
lint check:doc-formula-expressions 0 (re-run after building the closure; first pass exit 3 PREREQUISITE NOT MET: @objectstack/formula unbuilt) ✓ check:doc-formula-expressions: 14 predicate(s) judged clean; 6 skipped as undeterminable
lint check:doc-security-posture 0 (re-run after building the closure; first pass exit 3 PREREQUISITE NOT MET: @objectstack/lint unbuilt) ✅ 27 ObjectSchema.create example(s) in 227 marked block(s) across 236 prose file(s) carry an os validate-clean security posture
spec check:api-surface 0 @objectstack/spec public API surface + factory signatures unchanged ✓
spec check:authorable-surface 0 ✅ Successfully generated 1603 schemas.
spec check:browser-reachable-entries 0 ✅ check:browser-reachable-entries — 2 declared browser-reachable entries link no zod; 44 bundle(s) scanne
spec check:docs 0 ✅ 230 generated files in sync with packages/spec
spec check:dual-source-exports 0 ✅ no new dual-source exports: 5065 names across 17 entry points — 206 re-exported (single declaration), 0
spec check:empty-state 0 ✓ all classified (2 closed, 2 open, 4 output, 8 scope)
spec check:entry-nameability 0 ⚠️ NOT MEASURED: no callable export on @objectstack/spec/qa.
spec check:export-origins 0 ✅ export-origins/ is current: 5275 exports across 17 entry points resolve exactly as recorded.
spec check:exported-any 0 ✅ no exported type resolves to any: 2460 types + 1524 schemas across 17 entry points.
spec check:generated 0 ✓ All 15 generated artifacts are up to date.
spec check:liveness 0 (not a completeness claim about the 301 child key(s) under the declared blanket verdicts above — those are
spec check:llms-txt 0 ✓ packages/spec/llms.txt: 97 claim(s) re-derived — every advertised symbol resolves against api-surface/ (
spec check:react-blocks 0 ✅ 1 generated files in sync with packages/spec
spec check:skill-examples 0 (re-run after building the closure; first pass PREREQUISITE NOT MET: client-react unbuilt) ✅ 257 prose examples type-check across 3 surface(s) — every marked block parsed
spec check:skill-refs 0 ✅ 9 generated files in sync with packages/spec
spec check:strictness-ledger 0 ✓ docs/audits/2026-07-unknown-key-strictness-ledger.counts.md is current — 440 site(s) measured, 1 authora
spec check:variant-docs 0 ✓ variant/doc gate: 18 discriminated union(s) — 8 governed (every variant mentioned in a bound doc), 10 ex
spec check:yaml-examples 0 ↳ 18 component node(s) also judged against their ComponentPropsMap props schema; 1 skipped (no row for th
check:agent-test-spelling 0 which drives this same sweep RED over a temp tree on disk.
check:changeset-gate-self-tests 0 ✓ check-changeset-no-major --self-test: 116 assertions (frontmatter dialects measured against @changesets/pa
check:corpus-claim-drift 0 Ledger: 2 baselined file(s) in scripts/corpus-claim-drift-baseline.json.
check:cross-package-test-inputs 0 OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:dispatcher-error-vocabulary 0 [#14626] the shared textual scanners now carry a TEMPLATE-LITERAL mode (skipStringLiteral tracks `${ … }
check:doc-anchors 0 ✅ check-doc-anchors: 298 internal #fragment link(s) across 410 source file(s) all resolve to a real heading
check:doc-authoring 0 ✓ doc authoring guard: sibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s)
check:docs-audit-scope 0 (re-run after building the closure; first pass exit 1 from the kind=contract self-test case, build-state dependent — filed as its own card) ✓ scope injection is live
check:docs-redirects 0 check-docs-redirects: OK (apps/docs/redirects.mjs: 92 entries -- 89 page destination(s) resolved against conte
check:docs-single-h1 0 ✓ check-docs-single-h1: 405 page(s) under content/docs/ carry no body-level # heading (0 subtree(s) exclu
check:dual-build-cjs-loads 3 ELIFECYCLE Command failed with exit code 3.
check:engine-double-contract 0 check-engine-double-contract: 708 (file, verb) row(s) held by the RETAINED ledger — a pin that leaves names
check:logger-receiver-detach 0 control corpus fired on all five detach shapes in this same run, and stayed silent on the measured `consol
check:merge-driver 0 ✓ check-regen-pending self-test passed.
check:nul-bytes 0 check-nul-bytes: OK (scanned 7438 text file(s) -- 7438 tracked, 0 untracked-not-ignored; skipped 7 binary; no
check:objectql-double-limit 0 baseline key set verified against 6e67b86: no files added.
check:objectui-changeset 0 ✓ objectui-range --self-test: all checks passed
check:page-declaration-shape 0 blind spot: 1 computed carrier(s) no source scan can enumerate — examples/app-crm/objectstack.config.ts:86
check:pm-governed-merges 0 live: the real generator declared 9 output(s) and certified this tree
check:pm-half-states 0 ✓ check-half-states self-test: 2062 cases pass.
check:published-files 0 ✓ check:published-files — 69 publishable package(s) of 79 workspace member(s) declare a files whitelist
check:published-readme-links 0 ✓ check:published-readme-links — 176 outbound link(s) across 60 published markdown file(s): 0 root-relativ
check:query-options-erasure 0 baseline key set verified against 6e67b86: no files added.
check:quick-reference-counts 0 ✓ content/docs/getting-started/quick-reference.mdx: 13 section(s), every "(N of M schemas)" heading matches
check:react-page-adapter-contract 0 ✓ check-react-page-adapter-contract: 21 app-showcase page module(s) + 1 content/docs react-page sample(s) (f
check:refd-timer-probe 0 1 code site(s), all inside the approved module, which is present and still reads it.
check:role-word 0 Ledger: 44 baselined file(s) still carrying it (123 occurrence(s)) in scripts/role-word-baseline.json.
check:skill-compatibility 0 2 justified exemption(s), each with its stated reason still true of the file.
check:skill-frame-sync 0 binding sentence present in all 2; 5 count mention(s) agree; 72 markdown files scanned for undeclared copies
check:skill-identifier-liveness 0 check-skill-identifier-liveness OK — Leg 1: 465 citation(s) over 46 published file(s) checked against 95267
check:slot-lookup 0 baseline key set verified against 6e67b86: no files added.
check:spec-parsed-alias 0 ADR-0122 type-alias convention: 1519 bare z.input aliases, 828 pinned isomorphic, 691 paired with an XParsed.
check:test-source-alias 0 check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace de
check:type-check-coverage 0 composition: 1 entr(ies) carry a tier itemisation DECLARED stale by compositionAt -- @objectstack/spec-mon
check:type-check-debt 3 ELIFECYCLE Command failed with exit code 3.
check:type-source-resolution 0 check-type-source-resolution OK — 123 tsc program(s) across 78 packages scanned (every tsconfig*.json each
check:vendor-version-stamps 0 attestations. Re-verify one and you may restamp it; otherwise it stays a historical fact.
check:watch-hint-literal 0 ✓ check-watch-hint-literal: 49 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 32, ROOT_FIL
check:where-matcher 0 baseline key set verified against 6e67b86: no files added.

skills/** readings

skills/objectstack-ui/references/react-blocks.md: 117 → 117 lines (one register row regenerated byte-exact from the ListView.grouping describe, the same row CI's queue leg regenerates); the JSON twin retired by #15257 is gone from the diff since the first merge of main; whole package (every skills/*/SKILL.md): 6835 → 6835 lines, 0 SKILL.md diffs. Token counts: not reported — no sibling gate defines them yet.

Deviations, on record


🤖 Generated with Claude Code

https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk

os-justin and others added 2 commits September 4, 2026 07:55
…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
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 50 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json, packages/spec/src/ui/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d.

2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json, packages/spec/src/ui/index.ts) — pages documenting those are invisible to this run
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 516e7a594b2f229e59cddd41c9ff121a19bf704f — the merge of head 49f19218b28dce64d48c7894a27baefe3c2dfa60 into base cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs cd1f8ee968d8a38d8a7c818f06e5bcd6cb962a6d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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
os-justin and others added 3 commits September 4, 2026 11:29
…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
… 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

Copy link
Copy Markdown
Collaborator Author

Provenance (seat landing stroke, 2026-09-04T12:41Z): Clause ② PASS · ACCEPT recorded on #14556 (comment 5540162483) by the domain:spec seat at CONTRACT_REVIEW_TIER after patch round 2 closed the seven conditions of 5538128398 (the isolated second opinion, adopted verbatim); needs:contract-review cleared on both carriers in that stroke (2026-08-31 ruling); all 40 check runs success on head 49f19218 (Lint & Repo Gates 12:14Z, Test Core ×6, all Type Check jobs, Governed Surface Queue Guard — skills/objectstack-ui/references/react-blocks.md is a pure gen:react-blocks regeneration with the generator untouched, Spec property liveness, Check Changeset). Not a governed surface. Flipped ready and auto-merge (squash) armed by the seat — the merge queue lands it. Follow-ons: #15330 (platform-half pin, Blocked-by: #14556), objectui#7189 (installability-gated).


Generated by Claude Code

baozhoutao pushed a commit that referenced this pull request Sep 5, 2026
…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
baozhoutao pushed a commit that referenced this pull request Sep 5, 2026
… 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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)

2 participants