Skip to content

feat(spec,lint): a list-view grouping level can order its groups by another field (grouping.fields[].orderBy) - #25828

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-zciswd
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-zciswd

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25786
Clause-②: yes
Metadata-protocol: added

A list-view grouping level can now order its groups by another field of the same object: { field: 'period_label', orderBy: 'period_start', order: 'desc' }. Before this, a period label grouped as text (Nov 2026, Oct 2026, Q1 2027, Q4 2025), so HotCRM's forecast list carried a second, outer grouping level on period_start and showed two header rows per forecast.

What changes

  • Spec (packages/spec/src/ui/view.zod.ts). GroupingFieldSchema gains orderBy?: string. It is optional with no default; absent, or naming the grouping field itself, means the level orders by its own key exactly as before. Its name is refused padded, as field's is (spec: GroupingFieldSchema.field accepts a padded field name that three objectui readers bucket wrong — refuse non-trimmed names at the producer (ruling C on objectui#7347) #17360's ruling). The rule is a native .regex(), so the published JSON Schema states it as pattern instead of adding an x-dropped-refinements site.
  • Compiler (packages/spec/src/ui/view-grouping-query.ts). compileListViewGroupQuery adds one node per ordered level, min over orderBy for order: 'asc' and max for 'desc', aliased by columnSummaryAlias (min_period_start). Each group sits where its first row would sit if the rows were sorted by orderBy.
    • The grouping key stays the only groupBy column, so there is still one header per label.
    • A column summary declaring the same min is the same node.
    • An alias landing on a grouped field's column is alias_collision.
    • A blank orderBy is grouping_order_by_blank (INVALID_QUERY / 400).
    • In a multi-level grouping the outer level folds exactly (the min of mins, the max of maxes).
    • depth limits the nodes to the grouped levels.
    • A new export, listViewGroupOrderColumn(grouping, level), names the header-row column a level sorts by.
  • Lint (packages/lint/src/validate-list-view-field-refs.ts). grouping.fields[].orderBy is judged the way the server judges it, on os validate, os build and the runtime view save door:
    • an unknown name is list-view-field-unknown (error);
    • a dotted path is list-view-field-dotted (error), on a new aggregate axis;
    • a type min / max cannot order on every backend is the new list-view-group-order-unorderable (error). The pair is asked of isAggregateCompatibleWithFieldType, as the engine's type door asks it, so the string classes (collation-dependent) and the virtual formula are refused, along with every other type outside the numeric, temporal and boolean classes.
    • The rule has an os explain entry.

What the server needs (measured, as the order asked)

No new server code. The group sort stays the consumer's (EngineAggregateOptions carries no orderBy, and order was already applied by the consumer over the header rows). What the header rows lacked was the value to sort by, and the existing aggregate door already serves it:

  • POST /api/v1/data/:object/query → protocol.findData → engine.aggregate answers min / max over date and datetime operands on both tiers. That is driver-sql pushdown, and the in-memory applyInMemoryAggregation behind a driver that does not push down.
  • The values are identical in value and typeof per column on both tiers, asc and desc. This is pinned in packages/rest/src/list-view-grouping-query-door.test.ts §11, with the tier asserted by the fork counters, not assumed.
  • The door refuses what the lint refuses, 400 INVALID_FIELD:
    • an unknown or dotted operand at findData's ingress (assertAggregationFieldsExist);
    • an unorderable type at engine.aggregate's type door.
    • §11 pins a text operand and a dotted period_start.year.
  • Paged groups: the header query is compiled unpaged, so the consumer sorts the whole header set. A limit on the header query is sliced by findData in emission order before any sort, so a paged header set honours neither order nor orderBy. That is the limit already recorded on GroupingConfigSchema ("Header cardinality is unbounded"), unchanged here; bounding it is orderBy + limit on the aggregate verb, an engine-contract card of its own.

Evidence

Final head 21dc9ad7. main was merged twice: once before the review (6679644a), and again after the sibling spec card #25755 landed (abebc4e1). There were no conflicts, and the whole workspace was rebuilt each time (pnpm build, 76/76).

  • Tests on the final head:
    • @objectstack/spec: full suite, 800 files, 23119 passed / 1 todo.
    • @objectstack/lint: full suite, 163 files, 7331/7331.
    • @objectstack/rest: list-view-grouping-query-door.test.ts, 53/53 (9 new in §11).
  • Ablations, each through scripts/ablation-replace.mjs, with the file restored to its HEAD blob and git diff HEAD empty afterwards:
    • lint verdict forced silent (|| true on the compatibility check): 8 red;
    • compiler order nodes dropped (...orders.slice(0, 0)): 5 red;
    • the padded-name regex widened to /[\s\S]*/: 6 red.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands on the final diff derived 114 commands. All 114 ran on the final head, each exit code recorded before any pipe: 114 × exit 0. --ran reconciles to "114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN".
    • Two prerequisites were met first: a full pnpm build, and the self-test fixture commit fetched into the shallow clone.
    • Also run, all exit 0: the five roster gates whose roster sits under a path of this diff (check-changeset-fixed, check:meta-url-spelling, check:authz-resolver, check:error-code-casing, check:filter-alias-parity), check:error-code-provenance, and typecheck for spec, lint and rest.
  • Dogfood battery: n/a. No ! in the title, and Clause-② carries no (narrowing) arm.

Review

An independent full review of the whole diff (a fresh reviewer at the contract-review tier) returned CHANGES-REQUIRED with five findings. All five are fixed in 5d64c378, 6a3bf131 and d37faf8f, and the delta re-review's one nit in 5df57e7f:

  • a padded orderBy was accepted by the schema;
  • the unknown and dotted refusals were attributed to the wrong door;
  • the paged-header case was not stated;
  • the tie direction was unspecified;
  • the refused-type list was narrower than the table.

The delta re-review verdict is posted as a comment on this PR.

Acceptance notes

  • objectui reads nothing yet. orderBy is declared and compiled, but the console grid ignores it until the objectui child lands: render the label from field, order the headers by listViewGroupOrderColumn, nulls last, ties by key in the same direction. That child card is filed in objectui with Blocked-by: this PR. HotCRM's one-line view edit (dropping its outer period_start level) comes after it, and is not this PR.
  • A dotted grouping.fields[].field is not judged by lint (pinned silent in validate-list-view-field-refs.test.ts), while the data door refuses it: 400 INVALID_FIELD, "Unknown field 'period_start.year' … a related record's column cannot be a grouping target", measured through the §11 fixture. This predates the PR and is out of scope here; it is listed in the report.
  • field keeps its superRefine and its dropped-refinements.baseline.json row, while orderBy publishes the same non-padded rule as a JSON Schema pattern. Moving field to the same .regex() would shrink the ledger, but that is a separate change.

Generated by Claude Code

claude added 10 commits October 25, 2026 13:45
…r field (orderBy)

WIP: GroupingFieldSchema.orderBy, the header query's min/max order node,
listViewGroupOrderColumn, and their pins. Lint, door pin, artifacts and
changeset follow.

Claude-Session: https://claude.ai/code/session_01E1GNboWQRhh5vidwh2hxTY
Co-authored-by: Claude <noreply@anthropic.com>
…r does

WIP: existence, a dotted path at the aggregate axis, and the new
list-view-group-order-unorderable for a type min/max cannot order;
explanations, barrel export, changeset, authorable surface.

Claude-Session: https://claude.ai/code/session_01E1GNboWQRhh5vidwh2hxTY
Co-authored-by: Claude <noreply@anthropic.com>
… doors; state the whole-set sort

An orderBy name is compiled verbatim into the header query's min / max node,
so a padded spelling is refused by name, as a padded grouping field is. The
unknown / dotted refusals are the data door's ingress gate, the type refusal
is engine.aggregate's type door; the prose now says which. The header set is
sorted whole: a limit on the header query slices before any sort. Equal order
values fall back to the group key in the same direction.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1GNboWQRhh5vidwh2hxTY
… Schema publishes it

A superRefine is dropped by z.toJSONSchema and would have added eighteen
x-dropped-refinements sites; a native .regex() publishes the same rule as
the schema's pattern, which a validator reading packages/spec/json-schema
then refuses too.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1GNboWQRhh5vidwh2hxTY
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Independent full review: VERDICT ACCEPT (review 1 CHANGES-REQUIRED → review 2 ACCEPT-WITH-NITS → nit closed)

The review was run by an isolated reviewer subagent at CONTRACT_REVIEW_TIER. It was given the card, the dispatch order, AGENTS.md and the code, not the author's conclusions, and it read the whole diff origin/main...HEAD (15 files). It was read-only: it edited nothing and posted nothing. This is the review of record the order asks for before the seat's own contract review.

Review 1 on the tree at 5032316f returned CHANGES-REQUIRED with five findings:

  1. major. orderBy accepted a padded name (' period_start', ' '), while its sibling field refuses one by spec: GroupingFieldSchema.field accepts a padded field name that three objectui readers bucket wrong — refuse non-trimmed names at the producer (ruling C on objectui#7347) #17360's ruling. The name is compiled verbatim into the min / max node. On the in-memory tier, a direct engine.aggregate caller would get null for every group.
  2. minor. The prose attributed the unknown and dotted refusals to engine.aggregate. They are refused at findData's ingress (assertAggregationFieldsExist). The type door only judges declared names.
  3. minor. The paged-header case was measured but not stated: findData slices a limit before any sort.
  4. nit. The tie-break direction was unspecified.
  5. nit. The explanation's list of refused types was narrower than the table.

Fixes, in 5d64c378, 6a3bf131 and d37faf8f:

  • F1: orderBy is now .min(1).regex(NON_PADDED_FIELD_NAME_PATTERN, …) with a by-name refusal. A native regex rather than a superRefine, so the published JSON Schema carries the rule as pattern and dropped-refinements.baseline.json does not grow by 18 sites. It is pinned by six new schema tests, including one that the JSON Schema publishes the pattern. Ablation: 6 red, file restored to its HEAD blob.
  • F2: both doors are named in the changeset, the lint module note, the three os explain entries and the spec module note.
  • F3: "the header set is sorted whole" is stated in the changeset, the module note and the TSDoc.
  • F4: the tie-break now falls back to the key "in the same direction".
  • F5: the explanation now reads "every other type, among them …".

Review 2 on the delta 5032316f..d37faf8f returned ACCEPT-WITH-NITS. All five findings are closed, with each new sentence checked against protocol-query-validation.ts, aggregate-field-type-door.ts and protocol.ts. It raised one nit: record why field keeps its superRefine. That is answered in 5df57e7f (its refusal shipped as issue code custom, a .regex() answers invalid_format, so moving it is consumer-visible and a card of its own). The reviewer confirmed the nit closed.

Not reviewed: HotCRM's period_start type (that repo is not attached to the session), and PostgreSQL/MySQL temporal presentation of the min / max aliases (the rest door test runs on better-sqlite3; driver-sql's own aggregate temporal-output tests cover the presentation).


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/api/data-api.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/api/error-catalog.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/api/wire-format.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/automation/flows.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/data-modeling/queries.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/deployment/troubleshooting.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/getting-started/common-patterns.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/kernel/contracts/data-engine.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/kernel/runtime-services/data-service.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/protocol/kernel/http-protocol.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/protocol/objectql/index.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/protocol/objectql/query-syntax.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/ui/actions.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/releases/v17/17-5.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))
  • content/docs/releases/v18/18-1.mdx (via orderBy (literal, a string literal in GROUPING_ENTRY_POSITIONS; a string literal in groupOrderNode; a string literal in groupOrderNodes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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 feat(app-shell): the AI usage indicator renders the reset line for the 5-hour pace window (objectui#11415) objectui#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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 144 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 abebc4e14d43d8518e2dade88eeaa07e536c3b17 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a0bdbb71d26ce7b322a4b2181545e3c67241d8f7 — the merge of head 21dc9ad78953f57f991b69c25800a2db82db8d9d into base abebc4e14d43d8518e2dade88eeaa07e536c3b17, 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 a0bdbb71d26ce7b322a4b2181545e3c67241d8f7 && git checkout a0bdbb71d26ce7b322a4b2181545e3c67241d8f7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin abebc4e14d43d8518e2dade88eeaa07e536c3b17 21dc9ad78953f57f991b69c25800a2db82db8d9d && git checkout -B drift-repro abebc4e14d43d8518e2dade88eeaa07e536c3b17 && git merge --no-ff 21dc9ad78953f57f991b69c25800a2db82db8d9d

node scripts/docs-audit/affected-docs.mjs --json abebc4e14d43d8518e2dade88eeaa07e536c3b17

⚠️ 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 abebc4e14d43d8518e2dade88eeaa07e536c3b17 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 21dc9ad78953f57f991b69c25800a2db82db8d9d
Local-runs: none
Scope: packages/spec/src/ui/view-grouping-query.ts, packages/spec/src/ui/view.zod.ts + packages/lint/src/validate-list-view-field-refs.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/metadata-protocol/src/protocol.ts (findData's aggregate branch), packages/metadata-protocol/src/protocol-query-validation.ts (assertAggregationFieldsExist), packages/objectql/src/aggregate-field-type-door.ts, packages/spec/src/data/aggregate-field-type-compatibility.ts, packages/spec/src/ui/view.test.ts, packages/spec/src/ui/view-grouping-query.test.ts, packages/lint/src/validate-list-view-field-refs.test.ts, packages/rest/src/list-view-grouping-query-door.test.ts, .changeset/25786-grouping-order-by.md, packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/export-origins/ui.json, content/docs/references/ui/view.mdx, content/docs/references/ui/component.mdx

Read against card #25786 (triage 6397346626, claim 6398321686, os-dev-report 6400232700), the PR body, the independent verdict 6400202111, the net diff origin/main...21dc9ad7 (15 files, +972/-20), the head's check-runs, the objectui child #25858, and the sibling objectui clone at the pinned .objectui-sha (b41ed7f68956, fetched read-only). Nothing built or run.

① Derived judgments

The key — right. GroupingFieldSchema (view.zod.ts) gains orderBy: z.string().min(1).regex(NON_PADDED_FIELD_NAME_PATTERN).optional(), on the same strictObject as field / order / collapsed. The card asked for "default = the grouping field"; the PR answers it semantically — absent, or equal to field, compiles no node and the level orders by its own key — and materialises no default. That choice is load-bearing on the consumer side (see ③): objectui's pinned tests assert GroupingConfigSchema.parse(emitted) comes back key-for-key equal and that a bare level reads back with exactly order: 'asc', collapsed: false; a defaulted orderBy would have broken both. "A real field of the same object" is not a question the schema can answer and the PR does not pretend it does: existence is judged where the object graph is, in lint (validateListViewFieldRefs, new GROUPING_ENTRY_POSITIONS row, severity error) on os validate, os build and the runtime view-save door, as the lint test's three runAuthoringRules / validateReferenceIntegrity cases pin. The padded-name refusal is field's rule (#17360) restated as a native .regex() so the JSON Schema carries it as pattern, and the test pins both the issue code (invalid_format) and the published pattern. Keeping field on its superRefine (issue code custom) was the right call, not a tidy-up skipped: objectui's grouping-field-padded-refused-7347.test.ts at the pin asserts custom at field.

The compiler — right, and conservative in the right places. groupOrderNode emits one min (asc) or max (desc) node over orderBy, aliased by the existing columnSummaryAlias, so min_period_start is the same column a column summary min would mint; compileListViewGroupQuery dedups against the summaries by alias and refuses an alias that lands on a grouped field's own column (alias_collision), the same two rules the summaries already obey. groupBy stays grouping.fields[].field alone — one header per label, which is the whole point. Every level's orderBy is validated (blank → grouping_order_by_blank, INVALID_QUERY / 400, path ['grouping','fields',i,'orderBy']) even past depth, while only grouped levels contribute a node; listViewGroupOrderColumn(grouping, level) is the one name both ends read and refuses an out-of-range level with the existing depth_out_of_range. The "first row's position" rule (min for asc, max for desc) is stated once and tested on a field that is not a function of the group key (owners by deal dates), and multi-level folding is tested as min-of-mins against the depth-1 query. The view-grouping-query.ts module note, the schema TSDoc and the changeset agree on the consumer's obligations (nulls last both directions, ties by key in the same direction).

The measurement claim — verified in the cited code. "The server needs no new code" holds. findData (protocol.ts, the hasGroupBy || hasAggregations branch) hands where / groupBy / aggregations / having / search straight to engine.aggregate with no orderBy and no sort, then applies limit as records.slice(0, options.limit) on the engine's emission order. So (a) the header rows now carry the order value because aggregations carries the node, nothing else is needed; (b) the paged-groups statement is exactly right and the PR states it: a limit on the header query is sliced before any sort, so a paged header set honours neither order nor orderBy, and compileListViewGroupQuery emits no limit — the consumer sorts the whole header set, the "Header cardinality is unbounded" limit already recorded on GroupingConfigSchema. The two refusal doors are attributed correctly after review 1's fix: an unknown or dotted operand is refused at the ingress by assertAggregationFieldsExist (INVALID_FIELD / 400, with the "a related record's column cannot be aggregated" hint for a dotted name whose head is known), and a refused type by aggregate-field-type-door.ts, which asks isAggregateCompatibleWithFieldType and skips a field with no declared type or a type outside FieldType.options — the two non-verdicts the lint's checkGroupOrder mirrors line for line. The table's min / max rows are numeric + temporal + boolean, as the lint rule and its os explain entry say. The rest door test §11 measures the whole path on both tiers (driver-sql pushdown and in-memory) over date and datetime, with the null group and a shared-start tie in the fixture, asserts tier parity by canonical() equality, and pins the two door refusals (min over text, dotted period_start.year) as 400 INVALID_FIELD.

Observations, none blocking.

  • The type door also refuses on its declaration half (multiple: true on a multi-capable type) and checkGroupOrder reads meta.type only. For min / max the multi-capable types are the option and reference classes, which the type half already refuses, so no accepted pair slips through today; if a numeric type ever becomes multi-capable the lint would fall a hair behind the door. Not a defect at this head.
  • listViewGroupOrderColumn(grouping, level) for a level at or beyond a depth-scoped query's depth names a column that header set does not carry. The consumer contract (objectui#13546) calls it per depth, so it is an API sharp edge, not a wrong answer.
  • The docs diff (view.mdx, component.mdx) is the generated reference table only; no release-owned page is touched. The Docs Drift bot's 14 "may need re-verification" rows all anchor on the literal orderBy colliding with the query AST's orderBy; none of those pages describes grouping.

② Semver level

minor on @objectstack/spec and @objectstack/lint, the two published packages whose source moves; @objectstack/rest changes only a test file and correctly carries no entry. The changeset and the PR body both carry Clause-②: yes and Metadata-protocol: added, which is what an additive authorable key is. The ledgers move with it: authorable-surface/ui.json gains ui/GroupingField:orderBy; api-surface/ui.json and export-origins/ui.json gain listViewGroupOrderColumn; dropped-refinements.baseline.json is unchanged by design (the .regex() instead of a superRefine). Purely additive: a grouping declared today parses to the same output and compiles to byte-identical queries, pinned by the "orders by its own key compiles no node" test.

③ Boundary flags

  • Dev / feature flags: none. No environment gating, no (narrowing) arm, no ! in the title; the dogfood battery is correctly n/a. The one new public export is on the ui entry and ledgered.
  • Console Pin Gate / objectui at .objectui-sha b41ed7f68956 — will this spec change make objectui's build or tests fail: no. Searched the pinned tree for the feat(spec,cli): positions and permission sets are translatable — positions and permissions in the translation bundle #25701 failure class (an exhaustive Record or satisfies Record keyed by a spec type, which is what TranslationPreview.tsx was and why chore(console): move the objectui pin to b41ed7f68956 so Console Pin Gate builds again #25735 had to move the pin). The satisfies Record tables in objectui key on ObjectMapConfig, SpecListView['gantt' | 'timeline' | 'gallery']; none keys on GroupingField, GroupingConfig or SpecListView['grouping']. objectui holds no mirror of the grouping level: @object-ui/types takes GroupingConfigSchema from the spec by reference (stripImportedDefaults(SpecGroupingConfigSchema) on grid, kanban and record-details; the walker rebuilds only default-bearing nodes, so an optional string with a regex check passes through untouched), plugin-grid's UsableGroupingField is GroupingConfig['fields'][number], and app-shell's URL-state parser takes the level schema as GroupingConfigSchema.shape.fields.element. The grouping editor's GroupingFieldEntry interface is its own value type, not a parity-checked mirror. Tests at the pin that touch the level shape: userFilterUrlState.listState-11860.test.ts refuses the sample key extra, not orderBy, and its round-trip equality holds because the PR adds no default; grouping-field-padded-refused-7347.test.ts asserts field's custom issue code, which the PR deliberately left alone. Console Pin Gate is skipped on this PR by its if: (pull_request excluded), so the answer is derived from the tree, not observed; the gate builds (tsc) and does not run objectui's tests, and nothing at the pin would fail either.
  • Cross-layer sequencing: objectui reads nothing yet; the child plugin-grid: order a grouped grid's groups by the level's orderBy (spec grouping.fields[].orderBy), the label still from field #25858 is filed with Blocked-by: this PR and restates the consumer contract exactly as the schema TSDoc does (label from field, order by listViewGroupOrderColumn, nulls last, ties by key in the same direction, header set sorted whole). HotCRM's one-line view edit is correctly out of scope.
  • Check-runs on the head (46): 0 failure. Still running at the time of this record: Test Core shards 1–7 of 8 and Type Check · workspace. Green: Build Core, Build Docs, Test Core 8/8, Type Check (source gates, debt ledger, consumer gates), every Lint job including PM dispatch-gates and query-options erasure, Check Changeset, Spec property liveness, Governed Surface Queue Guard, the three claim guards. Skipped by filter: Console Pin Gate, the UI jobs, Temporal Conformance, Dogfood shards. The verdict below is on the diff and the cited code; the running shards are the dev report's local 114-command run and full suites, not re-measured here.

Implemented-by: claude/project-thread-zciswd
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么
列表视图的分组字段新增可选键 orderBy:组标题仍显示 field 的值,但各组的先后顺序可以按同一对象的另一个字段排(例如按「周期起始日期」给「Q4 2026 / Sep 2026」这类周期标签排序)。不写或等于 field 时,行为和今天完全一样。os validate 会检查 orderBy 指向的字段存在、类型可排序。服务器不用改:分组表头查询本来就能带上每组的最小/最大值,由前端整体排序。

为什么改
HotCRM 的销售预测列表为了按时间排周期,只好再套一层分组,结果六条预测占了 18 行。

风险与代价(含回滚)
纯新增,已有分组编译结果逐字节不变。objectui 需要另做渲染(已立卡 objectui#13546,在本 PR 之后),但它现在的代码不会因为本 PR 编译失败。回滚 = 撤销合并。

席位意见
建议合并:开发者的 Fable 独立复核两轮(第一轮的 5 条都已修),席位的 Fable 契约复核通过,CI 全绿,不在受治理路径上,席位直接经队列落地。

你要做的
无。

席位核对

Seat domain:spec#4 (#18917) · session_01K2Tat8JEt4AhLASV4V4QWE · head 21dc9ad78953 · 2026-10-25T17:00Z · verdict ACCEPT (Tier S; the seat lands it through the merge queue).

  1. Shape. Base main, Fixes #25786, Clause-②: yes, Metadata-protocol: added; 15 files, +972/−20; check-governed-merges --pr 25828: 0 of 15 paths governed, under the 3000-line threshold; mergeable_state clean.
  2. Independent full review (the owner's rule 「spec skills adr 都需要fable 完整的复核」): round 1 changes-required (5 findings, all fixed), round 2 accept-with-nits (nit fixed, confirmed closed); verdict 6400202111.
  3. Contract review of record: PASS on this head at CONTRACT_REVIEW_TIER, 6400331596. The measurement holds in the cited code (findData passes the aggregations with no sort and slices to the limit; the consumer sorts the whole header set). Changeset minor on @objectstack/spec and @objectstack/lint. At the current .objectui-sha objectui holds no exhaustive mirror of the grouping level, so this does not turn main's Console Pin Gate red.
  4. Gates. Head-pinned check-runs on 21dc9ad78953: 48 completed, 38 success, 10 skipped, none failed. Dev dispatch-gates --commands 114/114 exit 0, --ran 0 NOT-MEASURED.
  5. Consumer. objectui#13546 carries the rendering, Blocked-by this PR.
  6. Landing. pr_ready + automerge_enable through the relay in this pass; the seat confirms MERGED.

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/l tests tooling

Projects

None yet

2 participants