Skip to content
43 changes: 43 additions & 0 deletions .changeset/24728-filterable-fields-residue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/lint': minor
'@objectstack/metadata-protocol': patch
'@objectstack/metadata-core': patch
---

fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. -->

**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.

`@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:

- **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
- **The react-page props check** read `<ListView filterableFields={[…]}>` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.

Both now refuse the key by name, as an `error`, whatever it names:

- a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
- `<ListView filterableFields>` on a react page reports `react-prop-retired`.

The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.

### FROM → TO

| before | after |
| --- | --- |
| `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
| `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
| `<ListView filterableFields={['status']} />` on a react page: passed every authoring command | `react-prop-retired` |

**The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.

### The residue readers that go

- **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
- **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
- **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.

⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,15 @@ export type {
// [#24668] Its FOURTH: a list view judged against the whole collection (a
// runtime write that names no package) whose bound object exists nowhere —
// refused the same way; `wholeCollection` is the statement.
// Its FIFTH: a list view still carrying a key the spec retired, reported by
// name with the tombstone's prescription (`os lint` never parses).
export {
validateListViewFieldRefs,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_FIELD_DOTTED,
LIST_VIEW_OBJECT_OUTSIDE_CLOSURE,
LIST_VIEW_OBJECT_UNKNOWN,
LIST_VIEW_KEY_RETIRED,
} from './validate-list-view-field-refs.js';
export type {
ListViewFieldRefFinding,
Expand Down
138 changes: 138 additions & 0 deletions packages/lint/src/list-view-filterable-fields-retired.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os lint` reports the list view's retired `filterableFields` BY NAME.
*
* @objectstack/spec 18 retired the key with a `retiredKey()` tombstone, so
* `os validate` and `os build` refuse it at the parse. `os lint` never parses:
* it judges the `normalizeStackInput` output, and the D2 conversion that
* strips the key is retired from the load path, so on a non-`defineStack`
* source the key reaches the list-view field-reference rule as written. That
* rule used to walk it as a field position — a valid name passed `os lint`,
* and a stale one was named as an unknown FIELD. Now it reports the
* retirement, once per list view, with the tombstone's own prescription.
*
* This file authors the retired key as a key on purpose, so the spec's
* tree-scoped absence walk (`view-list-filterable-fields-retirement.test.ts`)
* excludes it by name, as a member of the retirement kit.
*/

import { describe, expect, it } from 'vitest';

import { runAuthoringRules } from './authoring-rules.js';
import {
LIST_VIEW_FIELD_DOTTED,
LIST_VIEW_FIELD_UNKNOWN,
LIST_VIEW_KEY_RETIRED,
LIST_VIEW_RETIRED_KEYS_REPORTED,
listViewRetiredKeyPrescription,
validateListViewFieldRefs,
} from './validate-list-view-field-refs.js';
import { retiredKeyRemedy } from './validate-retired-permission-residue.js';

type AnyRec = Record<string, unknown>;

const task = {
name: 'duly_task',
fields: {
title: { type: 'text' },
status: { type: 'select', options: [{ label: 'Open', value: 'open' }] },
owner: { type: 'lookup', reference: 'duly_person' },
},
};
const person = { name: 'duly_person', fields: { name: { type: 'text' } } };

const listWith = (extra: AnyRec): AnyRec => ({
type: 'grid',
data: { provider: 'object', object: 'duly_task' },
columns: ['title'],
...extra,
});

const stackWithList = (list: AnyRec, objects: unknown[] = [task, person]): AnyRec => ({
objects,
views: [{ name: 'task', objectName: 'duly_task', list }],
});

const retiredOf = <F extends { rule: string }>(findings: F[]): F[] => findings.filter((f) => f.rule === LIST_VIEW_KEY_RETIRED);

describe('the list view\'s retired `filterableFields` is reported by name, never as a field position', () => {
const prescription = listViewRetiredKeyPrescription('filterableFields');

it('the tombstone publishes its prescription, and the rule reports exactly the retired key it used to walk', () => {
expect(prescription).not.toBeNull();
expect(LIST_VIEW_RETIRED_KEYS_REPORTED).toEqual(['filterableFields']);
// A key that is not a tombstone has no prescription to read.
expect(listViewRetiredKeyPrescription('columns')).toBeNull();
expect(listViewRetiredKeyPrescription('no_such_key')).toBeNull();
});

it('a VALID field name is reported as the retirement, where it used to pass in silence', () => {
const findings = validateListViewFieldRefs(stackWithList(listWith({ filterableFields: ['status'] })));
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
rule: LIST_VIEW_KEY_RETIRED,
severity: 'error',
path: 'views[0].list.filterableFields',
});
expect(findings[0]!.message).toContain('`filterableFields`');
// Read from the tombstone, never retyped.
expect(findings[0]!.hint).toBe(retiredKeyRemedy(prescription!));
});

it('a stale or dotted name is reported as the retirement, never as a field reference', () => {
const findings = validateListViewFieldRefs(
stackWithList(listWith({ filterableFields: ['nope_field', 'owner.name'] })),
);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_KEY_RETIRED]);
expect(findings.filter((f) => f.rule === LIST_VIEW_FIELD_UNKNOWN || f.rule === LIST_VIEW_FIELD_DOTTED)).toEqual([]);
});

it('is reported at every list-view rung the rule walks', () => {
const lv = listWith({ filterableFields: [] });
const stack: AnyRec = {
objects: [{ ...task, listViews: { mine: lv } }, person],
views: [
{ name: 'task', objectName: 'duly_task', list: lv, listViews: { open: lv } },
{ name: 'duly_task.flat', object: 'duly_task', viewKind: 'list', ...lv },
{ name: 'duly_task.item', object: 'duly_task', viewKind: 'list', config: lv },
],
};
expect(retiredOf(validateListViewFieldRefs(stack)).map((f) => f.path).sort()).toEqual([
'objects[0].listViews.mine.filterableFields',
'views[0].list.filterableFields',
'views[0].listViews.open.filterableFields',
'views[1].filterableFields',
'views[2].config.filterableFields',
]);
});

it('does not depend on the binding: an unresolvable object, a non-object provider and an empty graph all report it', () => {
const unbound = validateListViewFieldRefs(
stackWithList(listWith({ data: { provider: 'object', object: 'no_such_object' }, filterableFields: ['x'] })),
);
expect(retiredOf(unbound)).toHaveLength(1);
const inline = validateListViewFieldRefs(
stackWithList(listWith({ data: { provider: 'value', items: [] }, filterableFields: ['x'] })),
);
expect(retiredOf(inline)).toHaveLength(1);
const noObjects = validateListViewFieldRefs(stackWithList(listWith({ filterableFields: ['x'] }), []));
expect(retiredOf(noObjects)).toHaveLength(1);
});

it('lit control: a list view without the key reports no retirement, and its surviving filter positions are still judged', () => {
const findings = validateListViewFieldRefs(
stackWithList(listWith({ userFilters: { element: 'dropdown', fields: [{ field: 'nope_field' }] } })),
);
expect(retiredOf(findings)).toEqual([]);
expect(findings.map((f) => f.rule)).toEqual([LIST_VIEW_FIELD_UNKNOWN]);
});

it('reaches `os lint`\'s registry run on the normalized (unparsed) stack, as an error', () => {
const stack = stackWithList(listWith({ filterableFields: ['status'] }));
const findings = runAuthoringRules('lint', { normalized: stack });
const retired = findings.filter((f) => f.rule === LIST_VIEW_KEY_RETIRED);
expect(retired).toHaveLength(1);
expect(retired[0]).toMatchObject({ severity: 'error', path: 'views[0].list.filterableFields' });
});
});
4 changes: 2 additions & 2 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3123,7 +3123,7 @@ const LIST_VIEW_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = {
paragraphs: [
// The positions are held to `listViewWalkedPositions()` by the rule's test.
'Every field a list view names must be a field of its bound object: its `columns` (and a ' +
'column\'s `summary` and `prefix`), `filterableFields`, `hiddenFields`, `fieldOrder`, ' +
'column\'s `summary` and `prefix`), `hiddenFields`, `fieldOrder`, ' +
'`grouping`, `rowColor` and `userFilters`, the keys of its `filter` and of each ' +
'`userFilters.tabs[].filter`, and every binding inside a `kanban`, `calendar`, `gantt`, ' +
'`timeline`, `gallery`, `map` or `tree` block. Nothing resolves the name at render time: the ' +
Expand Down Expand Up @@ -3160,7 +3160,7 @@ const LIST_VIEW_FIELD_DOTTED_EXPLANATION: RuleExplanation = {
'dotted entry whatever its head (`assertProjectionHasNoDottedPaths` on the engine boundary, ' +
'`assertProjectionFieldsExist` at the REST ingress, both `400 INVALID_FIELD`), so the view\'s ' +
'FIRST fetch is refused, not rendered.',
'Filter: the view\'s `filter`, its `userFilters.tabs[].filter`, `filterableFields` and ' +
'Filter: the view\'s `filter`, its `userFilters.tabs[].filter` and ' +
'`userFilters.fields` are judged by the head\'s class, through the classifier the door ' +
'itself uses (`classifyDottedFilterHead`). A relation head stores the related record\'s id, ' +
'not an embedded document; a virtual head is computed on read, so no driver materialises a ' +
Expand Down
4 changes: 3 additions & 1 deletion packages/lint/src/validate-field-consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,9 @@ const PROSE_KEYS: ReadonlySet<string> = new Set([
/** Inside a display root (and everywhere else), these path segments make a site behavioural. */
const BEHAVIOUR_SEGMENTS: ReadonlySet<string> = new Set([
'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'where', 'criteria', 'conditions',
'condition', 'defaultFilter', 'userFilters', 'quickFilters', 'filterableFields',
// `filterableFields` is not here: @objectstack/spec 18 retired the key and no
// list view reads it, so a field it names is not read by it either.
'condition', 'defaultFilter', 'userFilters', 'quickFilters',
'sort', 'sortBy', 'defaultSort', 'grouping', 'groupBy', 'groupByField', 'groupField',
'startField', 'endField', 'dateField', 'startDateField', 'endDateField', 'coverField',
'titleField', 'colorField', 'latitudeField', 'longitudeField', 'locationField',
Expand Down
10 changes: 4 additions & 6 deletions packages/lint/src/validate-list-view-field-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ const FULL_LIST_VIEW: AnyRec = {
{ field: 'period_key', summary: { field: 'estimate' }, prefix: { field: 'status' } },
],
filter: [{ field: 'visible_from', operator: 'equals', value: '2026-01-01' }],
filterableFields: ['status'],
hiddenFields: ['business_unit'],
fieldOrder: ['title', 'status'],
grouping: { fields: [{ field: 'business_unit' }] },
Expand Down Expand Up @@ -297,7 +296,6 @@ describe('#14107 — every other walked position', () => {
'views[0].list.columns[0].prefix.field',
'warning',
],
[{ filterableFields: [BAD] }, 'views[0].list.filterableFields[0]', 'error'],
[{ hiddenFields: [BAD] }, 'views[0].list.hiddenFields[0]', 'warning'],
[{ fieldOrder: [BAD] }, 'views[0].list.fieldOrder[0]', 'warning'],
[{ rowColor: { field: BAD } }, 'views[0].list.rowColor.field', 'warning'],
Expand Down Expand Up @@ -735,19 +733,19 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
});

it('the two positions that DECLARE end-user filterable names are judged', () => {
it('the position that DECLARES end-user filterable names is judged', () => {
// objectui folds the resulting conditions into the fetched query
// (`buildEffectiveFilter`), so these names become filter keys.
// (`buildEffectiveFilter`), so these names become filter keys. Its retired
// sibling `filterableFields` is reported by name instead
// (`list-view-filterable-fields-retired.test.ts`).
const findings = validateListViewFieldRefs(
stackWith(
mutate({
filterableFields: ['owner.name'],
userFilters: { fields: [{ field: 'parent.title' }] },
}),
),
);
expect(idsOf(findings).sort()).toEqual([
'views[0].list.filterableFields[0]',
'views[0].list.userFilters.fields[0].field',
]);
expect(findings.every((f) => f.rule === LIST_VIEW_FIELD_DOTTED)).toBe(true);
Expand Down
Loading
Loading