Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/sort-axis-provenance-warning.md
Original file line numberDiff line numberDiff line change
@@ -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.<name>` 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.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -383,6 +383,7 @@ export {
checkSortDeclaration,
SORT_FIELD_UNKNOWN,
SORT_FIELD_UNSORTABLE,
SORT_FIELD_UNPROVISIONED,
} from './validate-sortable-fields.js';
export type {
SortableFieldFinding,
Expand Down
23 changes: 17 additions & 6 deletions packages/lint/src/system-fields-consumers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -422,13 +422,17 @@ const LEDGER: Record<string, LedgerRow> = {
'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',
Expand All@@ -438,7 +442,14 @@ const LEDGER: Record<string, LedgerRow> = {
'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.<name> 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',
Expand Down
198 changes: 198 additions & 0 deletions packages/lint/src/validate-sortable-fields.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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');
});
});
Loading
Loading