Skip to content

feat(lint): add the SORT axis authoring gate — refuse a list-view sort naming a formula field (#9257) - #9314

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-9257-sort-axis-authoring-gate
Aug 17, 2026
Merged

feat(lint): add the SORT axis authoring gate — refuse a list-view sort naming a formula field (#9257)#9314
os-steve merged 2 commits into
mainfrom
claude/issue-9257-sort-axis-authoring-gate

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9257

The SORT axis had a runtime refusal on both doors and no authoring gate. This adds the missing half — the exact shape #6674 closed for the SEARCH axis one axis over.

The gap

ListViewSchema.sort is z.union([z.string(), Array of { field, order }]), so the field name is a bare string and Zod validates only the shape — exactly as searchableFields entries were before #6674. A list view authored with sort: 'expected_revenue desc' (a formula field) validated, published, and reported valid, then answered 400 INVALID_SORT on first load and every load. A declared sort is the view's initial fetch, not one optional interaction, so the whole view fails with a status nothing traces back to the declaration.

Both runtime doors already refuse it and neither can reach the author:

What lands

packages/lint/src/validate-sortable-fields.ts, joined to REFERENCE_INTEGRITY_RULES so it runs on os validate, os lint and os compile at once rather than being wired per command. Two verdicts, both error:

  • sort-field-unknown — the name resolves to no field on the bound object. Judged on the head segment, which is the ingress gate's own rule (!gate.known.has(f.split('.')[0])), so the two doors cannot disagree about which names are unknown.
  • sort-field-unsortable — the name is a real field whose type is virtual: computed on read, no stored column, nothing for any driver to ORDER BY.

Surfaces walked are exactly validateSearchableFields': objects[].listViews.*.sort, views[].list.sort, views[].listViews.*.sort.

The predicate, and the set this must not use

Virtuality is judged by isVirtualSearchField / SEARCH_VIRTUAL_TYPES (@objectstack/spec/data), pinned to formula alone — the same spec storage fact the search ingress gate, the engine's search resolution and the FILTER axis' dotted-head classifier (#8296) already read. It is deliberately notCOMPUTED_VALUE_TYPES: that set is the WRITE contract ("never client-written") and gating a sort with it would refuse the two types that sort correctly (summary is a table.float the engine maintains, autonumber a table.string it assigns). This PR touches nothing in packages/spec — it consumes the predicate.

Ruling 2's corpus check — run, and the count either way

Measured with the rule itself over the real shipped metadata (objects and view containers loaded from their own modules):

corpusobjectsview containersfindings
examples/app-showcase2160
examples/app-crm630
examples/app-todo110
packages/platform-objects4500

56 sort declarations reachable by the rule, 0 violations — so error ships without failing any metadata that exists today, and no corpus fix was needed.

A green corpus from a rule that never fires would be worth nothing, so the harness was proved live on the same run: mutating a real corpus object in memory (crm_opportunity given a list view ordering by its own expected_revenue formula field) produced exactly one finding, [sort-field-unsortable] objects[4].listViews.forecast.sort.

Reverse verification, both legs

The second leg is what stops this from becoming a rule that refuses correct metadata, so it is pinned as a test rather than only measured:

  • fires on a formula sort, in the structured form, the legacy string form, the -field shorthand and the comma-separated multi-key string;
  • does not fire on a summary sort or an autonumber sort — and the predicate boundary those two stand on is pinned alongside them (SEARCH_VIRTUAL_TYPES is exactly ['formula'], COMPUTED_VALUE_TYPES contains both others), so the two "must not flag" cases cannot quietly stop meaning anything;
  • does not fire on a registry-injected system column (created_at — the platform's own most common list ordering), an object with no readable field map, or an object this stack does not define.

Positions verified rather than assumed

Ruling 4 named three further sort positions. Each was checked against the schema; the module docblock records all three with their evidence.

  • A saved report's query.orderBy is not an authoring surface at all.sys_saved_report is a platform OBJECT and the envelope lives in its query_json column — a runtime record written through the reports API, never a key in stack metadata. The stack's own reports[] is ReportSchema, whose ADR-0021 single-form cutover removed the inline query; what it declares instead is order[].by, a dataset dimension or measure, already refined by checkReportOrder. So no stack rule can reach it, which is precisely why engine.find() still drops a formula ORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095 added the engine door for that surface.
  • Flow nodes carry no sort config. The record-reading node in automation/builtin-node-config.zod.ts declares limit and no ordering key; no schema under packages/spec/src/automation/ declares sort or orderBy.
  • Dashboard widget sort config exists but is out of this predicate's domain.DashboardWidgetOptionsSchema.sortBy names a dimension or measure the widget selects and lowers into a DatasetSelection.order — an ADR-0021 semantic-layer name resolved against a dataset, not an object field. Judging it needs the dataset's measure index, which is validateChartBindings' family.

Two surfaces are left to their owners and named in the docblock: page/component sort (walkPageComponents' territory) and the flattened standalone list overlay the metadata door accepts, which reaches the runtime publish gate — where the suite's dispatch is runtimeTypes: ['flow'] today. Widening that is #4463 P2's decision, and the SEARCH axis has the identical gap. Recorded as #9313 (a sub-issue of the open P2 tracker #4716) rather than ridden in here — out of scope: #9313 is not addressed by this PR.

Verification

Union re-run on the final commit d7e823085, all green: check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs (+ the scripts/ form), check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, docs-audit/check-affected-docs, and the convention-triggered set the new test files move — check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:type-check-debt --re-measure (33 ledger entries, none above its recorded number, run on a fully built workspace closure).

packages/lint: 74 test files, 2086 tests passed; typecheck clean.

Consumer-package sweep (a spec-consuming lint change can make another package's fixture assert the opposite, and nothing inside packages/lint can see it) — the three direct dependents of @objectstack/lint, plus the whole transitive dependent closure:

consumerreading
@objectstack/metadata-protocol117 files, 1617 tests passed
@objectstack/cli124 files, 1367 tests passed
@objectstack/platform-objects25 files, 419 tests passed
pnpm --filter '...@objectstack/lint' test (full dependent closure)exit 0; packages/qa/dogfood 110 files / 780 tests passed

Changeset

packages/lint is published, so this carries a real changeset at minor — a BREAKING accept-set narrowing on a published authoring surface, shipped as minor under the same lockstep launch-window convention the sibling filter-preset-comparand refusal used. Its ADR-0087 disposition is not-required (already-registered engine-find-formula-order-by-refused): this rule refuses no shape the runtime accepts, it moves an existing refusal earlier, and that entry already carries the identical FROM to TO prescription with summary explicitly unaffected.

Generated by Claude Code


Generated by Claude Code

…t naming a formula field (#9257)
The runtime already refuses both verdicts with 400 INVALID_SORT —
assertSortFieldsExist (#6994, REST ingress) and assertOrderByIsMaterializable
(#7095, engine boundary) — and neither door can reach the author. A list view's
declared `sort` is its FIRST fetch, so a `formula` entry breaks the whole view
on every load with a status nothing traces back to the declaration.
`validate-sortable-fields.ts` mirrors `validate-searchable-fields.ts` one axis
over: `sort-field-unknown` (resolves to no field, judged head-first exactly as
the ingress gate does) and `sort-field-unsortable` (a real field with no stored
column). Virtuality is judged by the spec's own storage predicate
`isVirtualSearchField` / SEARCH_VIRTUAL_TYPES, pinned to `formula` alone —
never COMPUTED_VALUE_TYPES, which is the write contract and would refuse the
`summary` and `autonumber` sorts that work correctly.
Level `error`, gated on a corpus sweep first: 56 reachable sort declarations
across app-showcase, app-crm, app-todo and platform-objects, 0 violations.
Wired into REFERENCE_INTEGRITY_RULES so it runs on validate, lint and compile
at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…9257)
The changeset declares BREAKING (an accept-set narrowing on a published
authoring surface), so `check-adr-0087-registration` requires the ledger
question to be answered. The honest answer is `already-registered`: this rule
refuses no shape the runtime accepts, it moves the EXISTING refusal earlier.
`engine-find-formula-order-by-refused` (semantic, protocol 17) already carries
the identical FROM -> TO prescription, and the `sort-field-unknown` half is
`assertSortFieldsExist` (#6994), already shipped at the REST ingress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 16 documentable anchor(s).

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

  • content/docs/automation/index.mdx(via crm_opportunity (literal))
  • content/docs/permissions/index.mdx(via crm_opportunity (literal))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 33 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 4 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 origin/mainpackageMentionDocs.

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 origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 17, 2026
@os-steve
os-steve marked this pull request as ready for review August 17, 2026 13:09
@os-steve
os-steve enabled auto-merge August 17, 2026 13:09
@os-steve
os-steve disabled auto-merge August 17, 2026 22:57
@os-steve
os-steve added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 42d8990Aug 17, 2026
28 checks passed
@os-steve
os-steve deleted the claude/issue-9257-sort-axis-authoring-gate branch August 17, 2026 23:16
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A view's declared sort naming a formula field clears lint, then 400s on every load — the SORT axis has no authoring gate, unlike SEARCH (#6674)

2 participants

@os-steve@claude