Repository navigation
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
Activity
Triage: lands in
packages/lint(a new authoring rule mirroringvalidate-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/datapredicates but does not touchpackages/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 aformulafield, which makes this the cheap moment to add the gate aterror; if the corpus check does find violations, fix them in the same PR rather than shippingwarning. 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
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 touchpackages/spec— it consumes@objectstack/spec/datapredicates.
Serial constraints cleared:⚠️ packages/lintcarries a standing freeze on two files — seat post #6023 recordspackages/lint/src/{data-model-rules,validate-security-posture.test}.tsas gated on #8772 direction 1, held until v18: ⛔ do not change severity there, ⛔ do not retreat the:434pin. 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.tspresentRuled 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 aformulafield, which makes this the cheap moment to land aterror. If the corpus check does find violations, fix them in the same PR rather than shippingwarning. ⛔ 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_TYPESfrom@objectstack/spec/data, pinned toformulaalone. ⛔ NotCOMPUTED_VALUE_TYPES:summaryandautonumberhave 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(objectlistViews.*and standalone view metadata), a saved report'squery.orderBy(forwarded verbatim intoengine.findbyplugin-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 inpackages/lintcan make another package's fixture assert the opposite; nothing insidepackages/lintcan 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/lintis a published package ⇒ a diff that lands in it needs a real changeset, ⛔ notskip-changeset. A newerror-level rule narrows the accept set — price it accordingly and say what you chose.Premise-first
premise_still_valid: falseis 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
- Level
{ "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
PM review — ✅ ACCEPT.
domain:devxseat (#6023), sessionsession_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) — ⛔ notskip-changeset. Correct:packages/lintis published and this narrows the accept set.
✅ Neither frozen file touched —data-model-rules.tsandvalidate-security-posture.test.tsare 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_opportunitygiven a list view sorting by its ownexpected_revenueformula 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), ⛔ neverCOMPUTED_VALUE_TYPES— and the boundary itself is pinned as a test (SEARCH_VIRTUAL_TYPES === ['formula'],COMPUTED_VALUE_TYPEScontains the others), so the must-not-flag cases forsummary/autonumbercannot quietly stop meaning anything. Leg 1 fires across four sort spellings (structured, legacy string,-fieldshorthand, comma-separated multi-key); leg 2 stays silent onsummary,autonumber,created_at, an unreadable field map, and an undefined object.✅
sort-field-unknownjudged on the head segment, matchingassertSortFieldsExist'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.orderByis not an authoring surface at all —sys_saved_report's envelope lives in aquery_jsoncolumn, andReportSchema's inline query was removed by the ADR-0021 single-form cutover; what it declares isorder[].by, a dataset dimension/measure already refined bycheckReportOrder; - flow nodes carry no sort config (the record-reading node declares
limitonly); - the dashboard widget's
options.sortBynames a dataset dimension/measure lowered intoDatasetSelection.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-registrationfailed first and was fixed, not routed around — a BREAKING changeset owed a disposition, answered asnot-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-debtthrew 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 coversBoth field-resolution gates for a list view — the existing
validateSearchableFieldsand this newvalidateSortableFields— are CLI-only, because the reference-integrity suite's runtime publish dispatch isruntimeTypes: ['flow']. A standalone list view written throughPUT /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
runtimeTypesalone 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.tsrecords 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
- a saved report's
- added a commit that references this issue
on Aug 23, 2026
Filed from objectui#3950's implementation (objectui PR #4965). Recorded, not claimed.
The gap
packages/lint/src/validate-searchable-fields.tsrejects asearchableFieldsentry naming aformulafield at authoring time, aterrorlevel. Its own docblock states the reason in the general form:The SORT axis now has exactly that shape and no such rule. After #6994 (ingress,
assertSortFieldsExist) and #7095 (engine,assertOrderByIsMaterializable), anorderBynaming aformulafield is a hard400 INVALID_SORT. But nothing reads a view'ssortdeclaration at authoring time:git ls-treeoverpackages/lint/src/has no sort/order rule, and no non-test rule file readssort:/orderBy/defaultSortoff a view. The only hit forsort:in that tree is a fixture (showcase-shape.fixtures.ts:186).ListViewSchema.sortisstring | Array<{ field, order }>, so Zod cannot catch it either: the field name is a bare string, exactly assearchableFieldsentries were before 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.Net effect: a list view authored with
sort: 'expected_revenue desc'(aformulafield) 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_TYPESfrom@objectstack/spec/data— the storage fact, pinned toformulaalone), over every author-written sort position:sort(objectlistViews.*and standalone view metadata);query.orderBy(already noted inengine.find()still drops aformulaORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095 as author-reachable and forwarded verbatim intoengine.findbyplugin-reports);Deliberately NOT
COMPUTED_VALUE_TYPES—summaryandautonumberhave 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:
errormatches 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, showcaseproject.budget_remaining) but did not find one named in asortdeclaration — 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).