Skip to content

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) #9257

Description

@yinlianghui

Filed from objectui#3950's implementation (objectui PR #4965). Recorded, not claimed.

The gap

packages/lint/src/validate-searchable-fields.ts rejects a searchableFields entry naming a formula field at authoring time, at error level. Its own docblock states the reason in the general form:

once the REST read path validates that override against the object (#4254), a stale declaration the engine had been silently skipping becomes a 400 INVALID_FIELD on every list search for that object — a request-time break whose cause is an authoring typo made long before.

The SORT axis now has exactly that shape and no such rule. After #6994 (ingress, assertSortFieldsExist) and #7095 (engine, assertOrderByIsMaterializable), an orderBy naming a formula field is a hard 400 INVALID_SORT. But nothing reads a view's sort declaration at authoring time:

Net effect: a list view authored with sort: 'expected_revenue desc' (a formula field) validates, publishes, and reports valid — then fails on first load, every load, with a 400 the author cannot connect to the declaration. That is strictly worse than the search-axis case the existing rule gates, because a refused sort is the view's initial fetch, not one optional interaction.

Why this is not objectui's to fix, and why it is not covered by objectui#3950

objectui#3950 / PR #4965 closes the affordance half in the renderer: the column header no longer offers a sort on a formula column. It deliberately does not filter a DECLARED sort out of the outgoing query — silently dropping an author's declaration would hide the authoring error, which is the fallback-in-the-consumer move AGENTS.md #0.1 forbids. So the loud runtime failure is the intended behaviour on that side, and it is intended precisely because the producer is supposed to reject it first. Right now nothing does.

The sort picker in objectui's list toolbar also keeps such a field listed when the current sort already names it — that row is the only way for a user to REMOVE the offending sort. So the runtime is already built assuming the declaration can exist and must be fixable; the missing piece is refusing it where it is written.

Suggested shape

A lint rule mirroring validate-searchable-fields, judging by the same spec predicate the other three doors read (isVirtualSearchField / SEARCH_VIRTUAL_TYPES from @objectstack/spec/data — the storage fact, pinned to formula alone), over every author-written sort position:

Deliberately NOT COMPUTED_VALUE_TYPES — summary and autonumber have real stored columns and sort correctly; the trap is pinned by name in the engine's own conformance suite.

Level is the open question worth deciding rather than guessing: error matches the search-axis precedent and the consequence here is larger (the view's first fetch), but it would newly fail lint for metadata that exists today and currently only fails at runtime.

Not verified by me

Whether any shipped example app or corpus view already declares such a sort. objectui#3950's own corpus sweep found formula fields RENDERED as columns (crm_opportunity.expected_revenue, showcase project.budget_remaining) but did not find one named in a sort declaration — so this may be a gate with no current violations, which is the cheap moment to add it.

Refs: #6994 (ingress refusal), #7095 (engine refusal), #6674 (the SEARCH axis' twin, with the lint rule), #8296 (the FILTER axis), objectui#3950 / objectui PR #4965 (the renderer-affordance half).

Activity

  1. added theissue type on Aug 17, 2026
  2. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Triage: lands in packages/lint (a new authoring rule mirroring validate-searchable-fields.ts, judging the same spec predicate over author-written sort positions); rationale: the runtime already refuses (assertSortFieldsExist #6994 at ingress, assertOrderByIsMaterializable #7095 in the engine) — the missing half is the authoring-time gate, the exact shape #6674 closed for the SEARCH axis. → pm:queue + domain:devx (package anchoring: packages/lint; consumes @objectstack/spec/data predicates but does not touch packages/spec), type Task.

    On the card's open level question: recommend error, matching the #6674 precedent — gated on a corpus check first. The filer's sweep found no current sort declarations naming a formula field, which makes this the cheap moment to add the gate at error; if the corpus check does find violations, fix them in the same PR rather than shipping warning. Dispatch note: a spec-consuming lint change must run the consumer-package test sweep (fixture-triage rule). Size/model suggestion: M, opus.

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  3. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Claim: PM loop round 3
    Session: session_01XqDQYVU5smx29ts9pAErja · Branch: claude/issue-9257-sort-axis-authoring-gate · Worktree: objectstack-issue-9257 · Domain: domain:devx
    File surface: packages/lint/src/ (a new rule + its registration and tests) — stop on breach; explain in the report
    Container & model: M, mode:subagent, model: opus — triage's suggestion, adopted. No path-derived mandate. Clause ② considered and does not fire: the rule narrows authoring to what the runtime already refuses (assertSortFieldsExist #6994 at ingress, assertOrderByIsMaterializable #7095 in the engine), i.e. it restores declared = enforced rather than widening any accept set. ⛔ It also does not touch packages/spec — it consumes @objectstack/spec/data predicates.
    Serial constraints cleared: ⚠️ packages/lint carries a standing freeze on two files — seat post #6023 records packages/lint/src/{data-model-rules,validate-security-posture.test}.ts as gated on #8772 direction 1, held until v18: ⛔ do not change severity there, ⛔ do not retreat the :434 pin. A new rule file is outside that fence; if your change would need to touch either of those two files, stop and report rather than proceeding. Batch siblings #9243, #9265 (content/docs/**) and #9230 (scripts/docs-audit/) are disjoint.

    Premise re-check, from a fresh worktree asserted equal to origin/main (fab693bee)

    premise measured
    no sort/order rule exists in packages/lint/src/ ✅ none — the gap is real
    the template rule exists ✅ packages/lint/src/validate-searchable-fields.ts present

    Ruled by triage (5313806321)

    • Level error, matching the A virtual formula field declared in searchableFields clears lint and the #4254 gate, then silently matches nothing — the same fail-open shape #4254 closed one axis over #6674 SEARCH-axis precedent — but gated on a corpus check first. The filer's sweep found no current sort declaration naming a formula field, which makes this the cheap moment to land at error. If the corpus check does find violations, fix them in the same PR rather than shipping warning. ⛔ Do not silently downgrade the level to make the corpus pass — that trades a loud authoring refusal for a runtime 400 the author cannot trace.
    • Judge by the same spec predicate the other three doors read — isVirtualSearchField / SEARCH_VIRTUAL_TYPES from @objectstack/spec/data, pinned to formula alone. ⛔ Not COMPUTED_VALUE_TYPES: summary and autonumber have real stored columns and sort correctly, and the engine's own conformance suite pins that distinction by name.
    • Cover every author-written sort position the card names: a list view's sort (object listViews.* and standalone view metadata), a saved report's query.orderBy (forwarded verbatim into engine.find by plugin-reports), and flow-node / dashboard-widget sort config if those carry one — verify rather than assume.

    ⚠️ Dispatch note carried from triage, and it is the one most easily skipped: a spec-consuming lint change must run the consumer-package test sweep (the fixture-triage rule). A change in packages/lint can make another package's fixture assert the opposite; nothing inside packages/lint can see that. Name the consumer packages you ran and give their real readings.

    Gates (from the fresh tree, this moment)

    pnpm check:cross-package-test-inputs · node scripts/check-cross-package-test-inputs.mjs · node scripts/docs-audit/check-affected-docs.mjs (+ check:nul-bytes; adding tests triggers the convention gates). A lead, not a spec — re-derive from your actual changed paths.

    Changeset: packages/lint is a published package ⇒ a diff that lands in it needs a real changeset, ⛔ not skip-changeset. A new error-level rule narrows the accept set — price it accordingly and say what you chose.

    Premise-first

    premise_still_valid: false is legitimate — e.g. if the corpus sweep shows the shape is already unreachable, or if the predicate does not generalise to the report/flow sort positions.


    Generated by Claude Code

  4. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator
    {
      "issue": 9257,
      "status": "done",
      "branch": "claude/issue-9257-sort-axis-authoring-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9314",
      "premise_still_valid": true,
      "summary": "Added packages/lint/src/validate-sortable-fields.ts, the SORT-axis twin of validate-searchable-fields.ts, wired into REFERENCE_INTEGRITY_RULES so it runs on os validate/lint/compile at once. Two error-level verdicts: sort-field-unknown (judged on the head segment, matching assertSortFieldsExist's own rule so the doors cannot disagree) and sort-field-unsortable (judged by isVirtualSearchField / SEARCH_VIRTUAL_TYPES from @objectstack/spec/data, pinned to formula alone — never COMPUTED_VALUE_TYPES). Ruling 2's corpus check ran BEFORE settling on error: 56 sort declarations reachable across app-showcase, app-crm, app-todo and platform-objects, 0 violations, so no corpus fix was needed and no level downgrade was considered. Three of ruling 4's four sort positions were VERIFIED rather than assumed and are documented as deliberately not walked, with evidence: a saved report's query.orderBy is not an authoring surface at all (sys_saved_report is a platform object whose envelope lives in its query_json COLUMN; the stack's own ReportSchema had its inline query REMOVED by the ADR-0021 single-form cutover and declares order[].by, a dataset dimension/measure already refined by checkReportOrder) — this narrows the card's stated scope but does not falsify its premise, since the list-view half it is really about is fully real; flow nodes carry no sort config at all (the record-reading node declares limit only); the dashboard widget's options.sortBy names a dataset dimension/measure lowered into DatasetSelection.order, not an object field, so a field-type predicate cannot judge it. packages/spec was not touched. Neither frozen file (data-model-rules.ts, validate-security-posture.test.ts) was touched — the freeze never came into play.",
      "tests": "All readings at final commit d7e823085 (tree clean, local == remote). CORPUS CHECK (ruling 2), rule run over real shipped metadata: app-showcase 21 objects / 6 view containers -> 0; app-crm 6/3 -> 0; app-todo 1/1 -> 0; platform-objects 45/0 -> 0. 'TOTAL sort declarations reachable by the rule: 56 / TOTAL corpus violations: 0'. HARNESS LIVENESS PROBE on the same run (a green corpus from a rule that never fires proves nothing): mutating a real corpus object in memory — crm_opportunity given a list view sorting by its own expected_revenue formula field — produced exactly 1 finding, '[sort-field-unsortable] objects[4].listViews.forecast.sort'. REVERSE VERIFICATION, both legs, pinned as tests rather than only measured — leg 1 FIRES on a formula sort in the structured form, the legacy string form, the -field shorthand and the comma-separated multi-key string; leg 2 DOES NOT FIRE on summary or autonumber sorts, with the predicate boundary itself pinned beside them (SEARCH_VIRTUAL_TYPES === ['formula'], COMPUTED_VALUE_TYPES contains both others) so the two must-not-flag cases cannot quietly stop meaning anything; also silent on created_at, on an object with no readable field map, and on an object the stack does not define. Direction observed was the ordinary one (red without the fix, green with it) — no inversion. PACKAGE: pnpm --filter @objectstack/lint test -> 'Test Files 74 passed (74) / Tests 2086 passed (2086)'; typecheck (tsc --noEmit) clean. CONSUMER-PACKAGE SWEEP, direction = DOWNSTREAM DEPENDENTS (prefix filter '...@objectstack/lint'): the three direct dependents named and run individually — @objectstack/metadata-protocol 117 files / 1617 tests passed; @objectstack/cli 124 files / 1367 tests passed; @objectstack/platform-objects 25 files / 419 tests passed — plus the full transitive dependent closure via pnpm --filter '...@objectstack/lint' test, exit 0 (packages/qa/dogfood 110 files / 780 tests passed). GATE UNION re-run on d7e823085 after the final commit, 13/13 PASS: check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs.mjs, check-empty-changeset, docs-audit/check-affected-docs, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage; plus check:type-check-debt --re-measure OK (33 ledger entries, none above its recorded number) run on a fully built workspace closure — its first invocation THREW on an unbuilt closure, which is 'not measured', so the closure was built (turbo build, 70 tasks) and it was re-run. Gates beyond the dispatch's lead were re-derived from the actual changed paths via scripts/pm/dispatch-gates.mjs; NEW versus the dispatch list and run here: check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, 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). check-adr-0087-registration FAILED on the first run and was fixed, not routed around: the changeset declares BREAKING, so a disposition was owed — answered as 'not-required (already-registered engine-find-formula-order-by-refused)', since this rule refuses no shape the runtime accepts and that ledger entry already carries the identical FROM/TO prescription.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9313 (sub-issue of the open P2 tracker #4716, label `finding`, unassigned): both field-resolution gates for a list view — validateSearchableFields and the new validateSortableFields — are CLI-only, because the reference-integrity suite's runtime publish dispatch is runtimeTypes: ['flow']. A standalone list view written through PUT /api/v1/meta/view (the only door a Studio tenant or MCP/AI author has) carries `sort` and `searchableFields` on ViewMetadataSchema's flattened list-overlay member and gets neither gate. Not fixed here on purpose: authoring-rules.ts records widening the gate's dispatch surface as #4463 P2's decision, not a joining rule's. Note the two halves are independent — the metadata walk would also need an overlay rung, so widening runtimeTypes alone would be a silent no-op that reads as coverage."
      ]
    }

    Generated by Claude Code

  5. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    PM review — ✅ ACCEPT. domain:devx seat (#6023), session session_01XqDQYVU5smx29ts9pAErja, 2026-08-17T12:4xZ (date -u). PR #9314.

    Verified against GitHub, ⛔ not the report

    ✅ draft · base main · Fixes #9257 · 6 files, +906 / −0 · size/l.
    ✅ Real changeset present (.changeset/sort-axis-authoring-gate.md) — ⛔ not skip-changeset. Correct: packages/lint is published and this narrows the accept set.
    ✅ Neither frozen file touched — data-model-rules.ts and validate-security-posture.test.ts are absent from the diff. The #8772/v18 freeze was respected without needing to invoke the stop clause.
    ✅ #9313 filed, finding, unassigned.

    ⭐ Ruling 2 was executed in the right order, and the liveness probe is why the result is believable

    The corpus check ran before settling on error: 56 sort declarations reachable across app-showcase, app-crm, app-todo and platform-objects, 0 violations ⇒ no corpus fix owed, and no level downgrade was ever considered. That is exactly the sequence the ruling demanded.

    ⭐ But the part that makes it trustworthy is the harness liveness probe, and the dev's own framing of it: "a green corpus from a rule that never fires proves nothing." It mutated a real corpus object in memory — crm_opportunity given a list view sorting by its own expected_revenue formula field — and got exactly one finding, [sort-field-unsortable] objects[4].listViews.forecast.sort. ⇒ The zero is a measured zero, not an unwired rule reporting silence. That distinction is the whole difference between this gate and a decorative one.

    ✅ Predicate pinned correctly: SEARCH_VIRTUAL_TYPES (formula alone), ⛔ never COMPUTED_VALUE_TYPES — and the boundary itself is pinned as a test (SEARCH_VIRTUAL_TYPES === ['formula'], COMPUTED_VALUE_TYPES contains the others), so the must-not-flag cases for summary / autonumber cannot quietly stop meaning anything. Leg 1 fires across four sort spellings (structured, legacy string, -field shorthand, comma-separated multi-key); leg 2 stays silent on summary, autonumber, created_at, an unreadable field map, and an undefined object.

    ✅ sort-field-unknown judged on the head segment, matching assertSortFieldsExist's own rule — so the authoring door and the ingress door cannot disagree. That is the right way to add a gate in front of an existing refusal.

    ⭐ Ruling 4 was verified, not assumed — and it narrowed the card with evidence

    I asked for the other three sort positions to be checked rather than assumed. All three came back not applicable, each with a reason:

    • a saved report's query.orderBy is not an authoring surface at all — sys_saved_report's envelope lives in a query_json column, and ReportSchema's inline query was removed by the ADR-0021 single-form cutover; what it declares is order[].by, a dataset dimension/measure already refined by checkReportOrder;
    • flow nodes carry no sort config (the record-reading node declares limit only);
    • the dashboard widget's options.sortBy names a dataset dimension/measure lowered into DatasetSelection.order — not an object field, so a field-type predicate cannot judge it.

    ⇒ This narrows the card's stated scope without falsifying its premise: the list-view half it is really about is fully real. ✅ Correctly reported as a narrowing rather than quietly skipped.

    Verification discipline

    ✅ Consumer-package sweep done in the right direction — downstream dependents, not dependencies: metadata-protocol (1617 tests), cli (1367), platform-objects (419) run individually, plus the full transitive closure via ...@objectstack/lint. That was the dispatch note most likely to be skipped, and it was the one done most thoroughly. Package itself: 2086 tests, typecheck clean.
    ✅ Gate union 13/13, with ten families re-derived beyond my dispatch list and run.
    ✅ check-adr-0087-registration failed first and was fixed, not routed around — a BREAKING changeset owed a disposition, answered as not-required (already-registered engine-find-formula-order-by-refused) because this rule refuses no shape the runtime accepts and that ledger entry already carries the identical FROM/TO prescription. ⛔ Not a bypass.
    ✅ check:type-check-debt threw on an unbuilt closure and was recorded as "not measured", then re-run against a built workspace — ⛔ not read as a pass. That is the 🆕G discipline applied unprompted.

    ⚠️ #9313 is the finding that matters, and it limits what this gate covers

    Both field-resolution gates for a list view — the existing validateSearchableFields and this new validateSortableFields — are CLI-only, because the reference-integrity suite's runtime publish dispatch is runtimeTypes: ['flow']. A standalone list view written through PUT /api/v1/meta/view — the only door a Studio tenant or an MCP/AI author has — gets neither gate.

    ⭐ And the trap it names is the valuable half: widening runtimeTypes alone would be a silent no-op that reads as coverage, because the metadata walk would also need an overlay rung. ⇒ Correctly left out of this PR (authoring-rules.ts records that widening as #4463 P2's decision, not a joining rule's), filed as a sub-issue of the open P2 tracker #4716.

    Landing

    packages/lint ⇒ no maintainer-merge fork. Heavy suite still converging (Test Core, Dogfood Regression, Temporal Conformance, Build Core, ESLint, TSC), none non-green. Arming on convergence.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions