diff --git a/.changeset/sort-axis-provenance-warning.md b/.changeset/sort-axis-provenance-warning.md new file mode 100644 index 0000000000..f1ffefcdf8 --- /dev/null +++ b/.changeset/sort-axis-provenance-warning.md @@ -0,0 +1,75 @@ +--- +"@objectstack/lint": minor +--- + +The SORT axis now asks the #8116 provenance question about a name the blanket +`SYSTEM_FIELDS` union told it not to flag — new rule `sort-field-unprovisioned` +(#10474), the twin of `searchable-field-unprovisioned` on the identical index +(#8404). + +`validate-sortable-fields` consulted the union and stopped there, so a list view +ordering by a registry-injected anchor on an ADR-0015 `external` object was +skipped in silence. The #8999 consumer census recorded that gap with the reason +that such an object never reaches the union branch at all — skip (2) was believed +to catch it. **That reason was measured wrong.** `declaredFieldTarget` returns +`null` on exactly one condition (`fields` missing, unreadable, or naming +nothing) and nothing in it tests `external`, so the shipped shape — a federated +object that declares a mapped field map, as `examples/app-showcase`'s +`showcase_ext_customer` does — is indexed like any other object and lands +squarely in the skip. The census ledger entry now carries the correction rather +than the inherited reason. + +Why the authoring gate is the only door available for it: both runtime doors on +this axis judge `formula` alone (`UNMATERIALIZED_SORT_TYPES`) — the REST ingress +`assertSortFieldsExist` (#6994) and the engine's `assertOrderByIsMaterializable` +(#7095). An injected anchor is a `datetime` or `lookup`, it *is* in `gate.known` +because the registry injected it into the served schema, and it is undotted, so +it clears every verdict and reaches the driver. Measured with a real `SqlDriver` +over better-sqlite3, the object declared exactly as the showcase declares it, +against a remote `customers` table carrying `[id, name, email, region, +lifetime_value]` and none of the seven injected anchors: + +``` +orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses) +orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error +orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error +``` + +`asc` and `desc` byte-identical while the baseline reverses is what makes it a +dropped sort rather than a coincidence — the same signature this rule already +records for `formula`, reached by a second route, except that a formula sort is +refused at both doors and this one is not. A list view ordered by an anchor with +no storage answers `200` with the rows in the driver's arbitrary order, on the +view's first fetch and every fetch after it, which `limit`/`offset` then slice +into an arbitrary page. + +`warning`, never `error` and never gating (#4330's cost asymmetry, the call every +sibling makes): the remote schema is invisible to this pass, so the remote table +may genuinely carry a `created_at` of its own. Declaring that column — the first +remedy the shared hint prescribes — silences the finding, because +`unprovisionedInjectedColumnsFor` excludes an author-declared column of the same +name (#7859's security direction). The runtime publish gate sorts on severity, so +this lands as an advisory and refuses no write. + +Two deliberate narrowings, both pinned: + +- **Undotted names only** — the one place this axis departs from the SEARCH twin. + `resolveSearchFields` matches by exact string and drops a dotted entry like a + typo, but a dotted SORT name is refused by the ingress gate as its own verdict + (`400 INVALID_SORT`, loudly, on every fetch), so the silent degradation this + finding reports cannot happen there. Answering would give the SORT axis its own + dotted verdict, which is exactly the posture the rule shares with the FILTER + and PROJECTION axes (#4256 / #7532 / #7589) and declines to break. +- **`checkSortDeclaration`'s new anchor-index parameter is optional**, with the + same meaning `checkSearchableFieldList`'s carries: an out-of-repo caller that + never built the index keeps its pre-#10474 answers. Every in-repo caller passes + it. + +Also re-ruled, with fresh eyes and on evidence rather than inheritance: +`validate-translation-references` still correctly asks nothing. It reads the +union at exactly one site (the `fields.` orphan test), and the key it +decides about is derived from the *registered* metadata, into which the registry +injects the anchor on a federated object just as on a local one — so the key +resolves and the label renders. Warning there would flag a translation that +works. The blank-column consequence belongs to the surface that renders the +anchor (`validate-page-field-bindings`, #8340), not to the bundle that names it. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 6a8bccf7fb..0ce837204b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -383,6 +383,7 @@ export { checkSortDeclaration, SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE, + SORT_FIELD_UNPROVISIONED, } from './validate-sortable-fields.js'; export type { SortableFieldFinding, diff --git a/packages/lint/src/system-fields-consumers.test.ts b/packages/lint/src/system-fields-consumers.test.ts index f94f75314d..bb1e7cb8a8 100644 --- a/packages/lint/src/system-fields-consumers.test.ts +++ b/packages/lint/src/system-fields-consumers.test.ts @@ -422,13 +422,17 @@ const LEDGER: Record = { 'validate-sortable-fields.ts': { kind: 'rule', reach: ['direct'], - asksProvenance: false, + asksProvenance: true, why: 'Landed 2026-08-17 (#9314), after the #8996 sweep — it is the arrival that released this card\'s hold. ' + - 'Recorded as NOT asking: the rule returns before the union branch for any object with no authored field ' + - 'map (skip ②), which is where an ADR-0015 external object normally lands, so today there is no path on ' + - 'which the warning could fire. Whether an external object that DOES declare a mapped field map should ' + - 'get the sort-axis warning is a rule-shape question, not this census\'s to decide — filed separately.', + 'Wired by #10474, which also OVERTURNED the reason this row first carried. That reason claimed the rule ' + + '"returns before the union branch for any object with no authored field map (skip ②), which is where an ' + + 'ADR-0015 external object normally lands, so today there is no path on which the warning could fire". ' + + 'Measured on the shipped shape (examples/app-showcase\'s showcase_ext_customer, an external object that ' + + 'DOES declare a mapped field map): declaredFieldTarget returns NON-null for it, so it is indexed like any ' + + 'other object and reaches the union branch. The path existed and was shipped; only the warning was ' + + 'missing. Recorded here rather than silently corrected because a census whose stated reasons are not ' + + 'the reasons the code holds is the failure this ledger exists to prevent.', }, 'validate-translation-references.ts': { kind: 'rule', @@ -438,7 +442,14 @@ const LEDGER: Record = { 'Spreads the union into its own rule-local IMPLICIT_FIELDS, and has done since before #8340 — a spread ' + 'consumer no sweep ever listed. Recorded as NOT asking on purpose: a translation bundle supplies a LABEL ' + 'for a column, and never reads the value, so "this anchor has no storage" says nothing about whether the ' + - 'label resolves. The #8116 warning is about predicates and pointers over the value.', + 'label resolves. The #8116 warning is about predicates and pointers over the value. ' + + 'RE-RULED and UPHELD by #10474 with fresh eyes, on evidence rather than inheritance: the union is read at ' + + 'exactly ONE site (the fields. orphan test), the key it decides about is derived from the ' + + 'REGISTERED metadata, and the registry injects the anchor into that metadata on an external object just ' + + 'as it does on a local one — so the derived key resolves and the label renders. Asking provenance here ' + + 'would warn about a translation that works, which is the ADR-0072 D1 false finding the union exists to ' + + 'prevent. The blank-column consequence belongs to the surface that RENDERS the anchor ' + + '(validate-page-field-bindings, #8340), not to the bundle that names it.', }, 'validate-widget-bindings.ts': { kind: 'rule', diff --git a/packages/lint/src/validate-sortable-fields.test.ts b/packages/lint/src/validate-sortable-fields.test.ts index ab94a0df79..53dd4c2d2f 100644 --- a/packages/lint/src/validate-sortable-fields.test.ts +++ b/packages/lint/src/validate-sortable-fields.test.ts @@ -7,8 +7,10 @@ import { checkSortDeclaration, SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE, + SORT_FIELD_UNPROVISIONED, } from './validate-sortable-fields.js'; import { indexObjectSearchTargets } from './validate-searchable-fields.js'; +import { indexUnprovisionedAnchors } from './system-fields.js'; /** * The object the whole file judges against. It carries one field of each of the @@ -489,3 +491,199 @@ describe('checkSortDeclaration — the shared core', () => { ).toEqual([]); }); }); + +// ── [#10474] PROVENANCE — the SORT twin of #8404's SEARCH wiring ──────────── +// +// The census (#8999) recorded this rule as not asking the #8116 provenance +// question, on the reason that an ADR-0015 external object never reaches the +// union branch (skip ② was believed to catch it). That reason was measured +// wrong: `declaredFieldTarget` keys on "declares no field map", never on +// `external`, so the SHIPPED shape — an external object with a mapped field +// map — is indexed like any other and lands in skip ③. +// +// ⚠️ The LOCAL twin is asserted in every case below, and it is the load-bearing +// half. A wiring that warned on `created_at` for EVERY object would satisfy the +// positive direction alone while flagging the single most common list-view +// ordering in the platform's own objects — the ADR-0072 D1 false finding this +// package's whole system-fields indirection exists to prevent. Only the +// negative direction can catch that, so it is asserted every time. + +/** The showcase's own federated object: `external` + a mapped field map. */ +const externalObject = { + name: 'showcase_ext_customer', + datasource: 'showcase_external', + external: { remoteName: 'customers' }, + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + region: { type: 'text', label: 'Region' }, + }, +}; + +/** Its local twin — identical in every way EXCEPT `external`. */ +const localTwin = { + name: 'showcase_customer', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + region: { type: 'text', label: 'Region' }, + }, +}; + +/** Both objects, each with a list view ordering by the same injected anchor. */ +const twinStack = (sort: unknown) => ({ + objects: [ + { ...externalObject, listViews: { recent: { type: 'grid', sort } } }, + { ...localTwin, listViews: { recent: { type: 'grid', sort } } }, + ], +}); + +describe('validateSortableFields — the provenance verdict (#10474)', () => { + it('warns on a list-view sort ordering by an unprovisioned injected anchor', () => { + const findings = validateSortableFields(twinStack([{ field: 'created_at', order: 'desc' }])); + + expect(findings).toHaveLength(1); + const f = findings[0]; + expect(f.rule).toBe(SORT_FIELD_UNPROVISIONED); + // WARNING, not error: no runtime door refuses this, and the remote schema + // is invisible to this pass. The runtime publish gate sorts on severity — + // `error` would turn an unprovable suspicion into a refused write. + expect(f.severity).toBe('warning'); + expect(f.where).toBe('object "showcase_ext_customer" › listViews.recent'); + expect(f.path).toBe('objects[0].listViews.recent.sort[0]'); + expect(f.message).toContain('created_at'); + // The CAUSE clause is the package-shared sentence, not a re-typed one: + // a rule that re-words it drifts from the runtime guards whose verdict it + // reports (`unprovisionedAnchorCause`). + expect(f.message).toContain('injected system column with NO storage behind it'); + expect(f.message).toContain('ADR-0015'); + // The SORT-axis consequence, which is this rule's own half of the sentence. + expect(f.message).toContain('ORDER BY'); + expect(f.hint).toContain('columnMap'); + }); + + it('THE NEGATIVE DIRECTION: says nothing about the identical sort on the LOCAL twin', () => { + // `objects[1]` is the local twin and carries the identical declaration. + // The single finding above is proof enough only alongside this. + const findings = validateSortableFields(twinStack([{ field: 'created_at', order: 'desc' }])); + expect(findings.map((x) => x.path)).not.toContain('objects[1].listViews.recent.sort[0]'); + expect( + validateSortableFields({ + objects: [{ ...localTwin, listViews: { recent: { type: 'grid', sort: 'created_at desc' } } }], + }), + ).toEqual([]); + }); + + it('covers every anchor the injection registers, not just the audit family', () => { + // `owner_id` is the one no managed DDL ever creates either, so it is the + // clearest case; asserting the set keeps a narrowing of the derivation + // visible here rather than only in the spec's own test. + for (const anchor of ['created_at', 'created_by', 'updated_at', 'owner_id', 'organization_id']) { + const findings = validateSortableFields(twinStack([{ field: anchor, order: 'asc' }])); + expect(findings.map((x) => x.rule), anchor).toEqual([SORT_FIELD_UNPROVISIONED]); + expect(findings[0].message, anchor).toContain(anchor); + } + }); + + it('reads the legacy string sort form too, not only the structured array', () => { + const findings = validateSortableFields(twinStack('created_at desc')); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNPROVISIONED); + // The string form has no index suffix. + expect(findings[0].path).toBe('objects[0].listViews.recent.sort'); + }); + + it("SECURITY DIRECTION: an author-DECLARED anchor on the federated object is silent", () => { + // #7859's recorded reasoning — a federated object may expose a REAL remote + // `created_at`, which the author vouches for through the binding's + // columnMap. `unprovisionedInjectedColumnsFor` excludes it, so declaring + // the column is the first remedy the shared hint prescribes AND the thing + // that silences the finding. + const declared = { + ...externalObject, + fields: { ...externalObject.fields, created_at: { type: 'datetime', label: 'Remote Created' } }, + listViews: { recent: { type: 'grid', sort: [{ field: 'created_at', order: 'desc' }] } }, + }; + expect(validateSortableFields({ objects: [declared] })).toEqual([]); + }); + + it('respects the injection opt-outs — `systemFields: false` leaves no anchor to warn about', () => { + const optedOut = { + ...externalObject, + systemFields: false, + listViews: { recent: { type: 'grid', sort: [{ field: 'created_at', order: 'desc' }] } }, + }; + expect(validateSortableFields({ objects: [optedOut] })).toEqual([]); + }); + + it('DOTTED heads are NOT asked — the ingress gate already refuses them loudly', () => { + // The one place this axis departs from the SEARCH twin, deliberately: a + // dotted SORT name is a `400 INVALID_SORT` on every fetch, so the silent + // degradation this finding reports cannot happen there, and answering + // would give the SORT axis its own dotted verdict (the posture the module + // note records as shared with FILTER/PROJECTION). + const findings = validateSortableFields(twinStack([{ field: 'created_at.year', order: 'asc' }])); + expect(findings).toEqual([]); + }); + + it('is additive: the existence verdict on a real typo still fires beside it', () => { + const findings = validateSortableFields( + twinStack([{ field: 'created_at', order: 'desc' }, { field: 'nope', order: 'asc' }]), + ); + const external = findings.filter((x) => x.path.startsWith('objects[0]')); + expect(external.map((x) => x.rule)).toEqual([SORT_FIELD_UNPROVISIONED, SORT_FIELD_UNKNOWN]); + // …and the local twin still gets the typo, and ONLY the typo. + const local = findings.filter((x) => x.path.startsWith('objects[1]')); + expect(local.map((x) => x.rule)).toEqual([SORT_FIELD_UNKNOWN]); + }); + + it('reaches the `defineView` aggregate and standalone list-view rungs too', () => { + const base = { objects: [externalObject] }; + const sort = [{ field: 'created_at', order: 'desc' }]; + const rungs: Array<[string, unknown[]]> = [ + ['aggregate list', [{ name: 'v', objectName: 'showcase_ext_customer', list: { sort } }]], + ['aggregate listViews', [{ name: 'v', objectName: 'showcase_ext_customer', listViews: { a: { sort } } }]], + ['flattened overlay', [{ name: 'v', object: 'showcase_ext_customer', viewKind: 'list', sort }]], + ['ViewItem record', [{ name: 'v', object: 'showcase_ext_customer', viewKind: 'list', config: { sort } }]], + ]; + for (const [label, views] of rungs) { + const findings = validateSortableFields({ ...base, views }); + expect(findings.map((x) => x.rule), label).toEqual([SORT_FIELD_UNPROVISIONED]); + } + }); +}); + +describe('checkSortDeclaration — the provenance parameter is OPTIONAL (#10474)', () => { + const stack = { objects: [externalObject] }; + + it('asks nothing when the caller does not build the index (pre-#10474 behaviour)', () => { + // The exported core is public surface; an out-of-repo caller that never + // built the index must keep the answers it had. + expect( + checkSortDeclaration( + [{ field: 'created_at', order: 'desc' }], + 'showcase_ext_customer', + indexObjectSearchTargets(stack), + 'page "customers"', + 'pages[0].sort', + 'page sort', + ), + ).toEqual([]); + }); + + it('asks once the caller passes it', () => { + const findings = checkSortDeclaration( + [{ field: 'created_at', order: 'desc' }], + 'showcase_ext_customer', + indexObjectSearchTargets(stack), + 'page "customers"', + 'pages[0].sort', + 'page sort', + indexUnprovisionedAnchors(stack), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNPROVISIONED); + expect(findings[0].where).toBe('page "customers"'); + expect(findings[0].message).toContain('page sort'); + }); +}); diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts index 5414b16d6d..12607f8e7e 100644 --- a/packages/lint/src/validate-sortable-fields.ts +++ b/packages/lint/src/validate-sortable-fields.ts @@ -141,8 +141,19 @@ * * 1. An object this stack does not define — it may come from another package, * and a field map we cannot see cannot be judged. - * 2. An object that declares no field map at all — external objects and - * datasource-introspected schemas whose columns resolve at runtime. + * 2. An object that declares no field map at all — a datasource-introspected + * schema whose columns resolve at runtime. + * + * ⚠️ This skip is NOT an `external` test, and the sentence that used to + * say so was measured wrong (#10474). `declaredFieldTarget` + * (`validate-searchable-fields.ts`) returns `null` on exactly one + * condition — `fields` missing, unreadable, or naming nothing — and + * nothing in it looks at `external`. An ADR-0015 `external` object that + * DOES declare a mapped field map is the shipped shape (`examples/ + * app-showcase`'s `showcase_ext_customer`), and it is indexed like any + * other object and reaches skip 3 below. Measured on that object: + * `indexObjectSearchTargets(...).get('showcase_ext_customer')` is + * non-null with `names = [email, lifetime_value, name, region]`. * 3. Registry-injected system columns (`SYSTEM_FIELDS`, derived from the * spec's own declarations). `sort: [{ field: 'created_at' }]` is the * single most common list-view ordering in the platform's own objects and @@ -150,6 +161,68 @@ * skipped for VIRTUALITY too: their runtime metadata is registry-owned and * invisible here, and none of them is a formula. * + * Skip 3 answers EXISTENCE, and since #10474 it no longer ends the + * matter — see the provenance section below. + * + * ── PROVENANCE: the second question about a name skip 3 kept (#10474) ──── + * + * Skip 3 decided not to FLAG the name. On an ADR-0015 `external` object the + * platform registers its injected anchors and provisions no storage behind + * them (#7865 / #8116), so `created_at` there is addressable and backed by + * nothing. Existence rightly stays silent; PROVENANCE is a second question, + * asked only of the names skip 3 already kept and answered by the per-object + * index ({@link indexUnprovisionedAnchors}) rather than the object-independent + * union. This is the SORT twin of the SEARCH wiring #8404 added on the + * identical index, and it is the same shape the four filter/binding rules + * carry since #8340. + * + * WHY THIS AXIS NEEDS ITS OWN AUTHORING ANSWER — neither runtime door refuses + * it. Both key on `formula` ALONE (`UNMATERIALIZED_SORT_TYPES`): the ingress + * gate's third verdict (`assertSortFieldsExist`, #6994) and the engine's + * (`assertOrderByIsMaterializable`, #7095). An injected anchor is a `datetime` + * or `lookup`, it IS in `gate.known` (the registry injected it into the served + * schema), and it is undotted — so it passes all three verdicts and reaches + * the driver. Measured (#10474) with a real `SqlDriver` over better-sqlite3, + * the object declared exactly as `examples/app-showcase` declares it, against + * a remote `customers` table carrying `[id, name, email, region, + * lifetime_value]` and none of the seven injected anchors: + * + * ``` + * orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses) + * orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error + * orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error + * ``` + * + * `asc` and `desc` coming back byte-identical while the baseline reverses is + * what makes it a DROPPED sort rather than a coincidence — the same signature + * this rule's virtuality docblock records for `formula`, reached by a second + * route. The difference is that a formula sort is REFUSED at both doors and + * this one is not: a list view ordered by an anchor with no storage answers + * `200` with the rows in the driver's arbitrary order, on the view's first + * fetch and every fetch after it, and `limit`/`offset` then slice an arbitrary + * page out of it. + * + * WARNING, never `error`, and never gating — #4330's cost asymmetry, the same + * call every sibling makes. The remote schema is not visible to this pass, so + * the remote table may genuinely carry a `created_at` of its own; the finding + * describes a degradation the author must check against their remote schema, + * not a refusal this pass can prove. (An author-DECLARED column of the same + * name is the author's — #7859's security direction — and + * `unprovisionedInjectedColumnsFor` already excludes it, so vouching for the + * remote column by declaring it silences this finding, which is exactly the + * first remedy the shared hint prescribes.) + * + * NOT asked for a DOTTED name, which is the one place this axis departs from + * the SEARCH twin, and deliberately: `validate-searchable-fields` asks about + * dotted entries because `resolveSearchFields` matches by exact string and + * drops them like a typo, whereas a dotted SORT name is REFUSED by the ingress + * gate as its own second verdict (`400 INVALID_SORT`, loudly, on every fetch). + * The silent-degradation this finding reports therefore cannot happen on that + * path, and answering there would give the SORT axis its own dotted verdict — + * precisely what the paragraph below records this rule as declining to do. + * `validate-page-field-bindings` takes the same posture, skipping a dotted ref + * before either question (#8340). + * * A DOTTED name (`account.name`) is refused by the ingress gate as its own * second verdict, and this rule deliberately does not add a third finding for * it — it judges the head for existence and stops there, so a dotted path with @@ -160,7 +233,12 @@ */ import { isVirtualSearchField } from '@objectstack/spec/data'; -import { SYSTEM_FIELDS } from './system-fields.js'; +import { + SYSTEM_FIELDS, + indexUnprovisionedAnchors, + unprovisionedAnchorCause, + unprovisionedAnchorHint, +} from './system-fields.js'; import { indexObjectSearchTargets, type ObjectSearchTarget, @@ -168,11 +246,18 @@ import { export const SORT_FIELD_UNKNOWN = 'sort-field-unknown'; export const SORT_FIELD_UNSORTABLE = 'sort-field-unsortable'; +/** [#10474] The provenance verdict — a WARNING, unlike the two above. */ +export const SORT_FIELD_UNPROVISIONED = 'sort-field-unprovisioned'; export type SortableFieldSeverity = 'error' | 'warning'; export interface SortableFieldFinding { - /** Always `error` — both verdicts are a `400 INVALID_SORT` at request time. */ + /** + * `error` for the two verdicts the runtime REFUSES (`400 INVALID_SORT` at + * request time); `warning` for the #10474 provenance verdict, which no + * runtime door refuses and which this pass cannot prove against a remote + * schema it cannot see. + */ severity: SortableFieldSeverity; /** Diagnostic rule id. */ rule: string; @@ -303,6 +388,15 @@ export function checkSortDeclaration( where: string, path: string, subject: string, + // [#10474] `objectName -> its unprovisioned injected anchors` + // ({@link indexUnprovisionedAnchors}). OPTIONAL, and its absence means + // exactly one thing: this caller did not build the index, so the provenance + // question goes unasked and only existence/virtuality are answered — the + // pre-#10474 behaviour, preserved for out-of-repo callers of this exported + // core (this function is part of the package's public surface). The same + // posture, for the same reason, as `checkSearchableFieldList`'s own + // trailing parameter. Every in-repo caller passes it. + unprovisionedAnchors?: ReadonlyMap>, ): SortableFieldFinding[] { const findings: SortableFieldFinding[] = []; if (declared === undefined || declared === null) return findings; @@ -312,6 +406,7 @@ export function checkSortDeclaration( if (!target) return findings; // ② external / introspected — no authored field map const known = target.names; + const anchors = unprovisionedAnchors?.get(objectName); for (const key of readSortKeys(declared)) { const name = key.field; @@ -323,7 +418,42 @@ export function checkSortDeclaration( // authored `fields`. `created_at` is the platform's own most common list // ordering, and flagging it would be the false finding ADR-0072 D1 warns // about. - if (SYSTEM_FIELDS.has(head)) continue; + if (SYSTEM_FIELDS.has(head)) { + // [#10474] Existence answered "yes"; the membership test above keeps + // owning that first question. PROVENANCE is the second one — on a + // federated object this anchor is addressable and has no storage, and + // neither runtime door refuses an ORDER BY over it (both key on + // `formula` alone), so the view's first fetch answers 200 with the rows + // in the driver's arbitrary order. Emitted here, before the `continue` + // the skip has always ended with: an injected anchor has no entry in + // `target.fields`, so the virtuality check below is a no-op for it and + // there is nothing to fall through to. + // + // Undotted only — see the module note. A dotted SORT name is refused by + // the ingress gate as its own verdict, so the silent degradation this + // finding reports cannot happen on that path. + if (!name.includes('.') && anchors?.has(head)) { + findings.push({ + severity: 'warning', + rule: SORT_FIELD_UNPROVISIONED, + where, + path: `${path}${key.at}`, + message: + `${subject} orders by "${name}", which resolves on object ` + + `"${objectName}", but ${unprovisionedAnchorCause(objectName, name)} — ` + + `so the ORDER BY reaches the driver, finds no column, and is dropped. ` + + `Measured on a federated object over a real remote table, 'asc' and ` + + `'desc' return BYTE-IDENTICAL row order under a 200 while the same ` + + `query on a real column reverses. Unlike this rule's other two ` + + `verdicts nothing refuses it: the REST ingress (#6994) and the engine ` + + `(#7095) both judge only 'formula', so the view's FIRST fetch — and ` + + `every fetch after it — silently answers in an arbitrary order, which ` + + `'limit'/'offset' then slice into an arbitrary page.`, + hint: unprovisionedAnchorHint(objectName, name), + }); + } + continue; + } if (!known.has(head)) { const dotted = name.includes('.'); @@ -400,6 +530,9 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { ? Object.entries(stack.objects).map(([name, def]) => ({ name, ...(def as AnyRec) })) : []; const fieldsByObject = indexObjectSearchTargets(stack); + // [#10474] The provenance index alongside the existence one — same keying, + // asked on the path where the existence check stays silent. + const unprovisionedAnchors = indexUnprovisionedAnchors(stack); const check = ( declared: unknown, @@ -409,7 +542,15 @@ export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { subject: string, ) => { findings.push( - ...checkSortDeclaration(declared, objectName, fieldsByObject, where, path, subject), + ...checkSortDeclaration( + declared, + objectName, + fieldsByObject, + where, + path, + subject, + unprovisionedAnchors, + ), ); };