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
80 changes: 80 additions & 0 deletions .changeset/view-filter-rule-value-shaped-by-operator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
"@objectstack/spec": minor
---

feat(spec): a view filter rule's `value` must have the shape its OPERATOR can execute (#6227)

<!-- adr-0087: registered view-filter-rule-value-shaped-by-operator -->

`ViewFilterRuleSchema.value` was declared
`string | number | boolean | null | (string | number)[]` with **no coupling to
`operator`**, so every operator accepted every shape. A set operator carrying a
scalar — `{ field: 'stage', operator: 'not_in', value: 'won' }` — was a
spec-valid view filter rule. It published cleanly, and then failed when someone
opened the view.

That made the failure two-stage. #5869 / PR #6209 had already closed the runtime
half: `assertListComparandShapes` refuses the lowered `{ stage: { $nin: 'won' } }`
with a named `400 INVALID_FILTER` (a `500 DATABASE_ERROR` before it). Correct
refusal, wrong moment — by then the author is long gone, and the view had been
sitting in the store looking valid. The authoring surface now refuses the same
shapes at publish time, so the feedback reaches the person who can act on it.

**The tightening mirrors the runtime gate exactly — three constraints, one for
one, and deliberately nothing more:**

| operator | `value` must be | why |
|---|---|---|
| `in` / `not_in` (and the `nin` / `notIn` / `notin` spellings) | an array, any length | lowers to `$in` / `$nin` |
| `between` | exactly `[min, max]` | lowers to `$between` |
| everything else | unchanged | the query path does not judge them |

It goes no further on purpose. #5685 already ruled on the opposite error — a
schema stricter than the runtime "in ways the runtime deliberately allows" was
found to be the wrong side and was widened to match — so these all still parse:

- `in: []` — an empty list is a declared predicate ("matches nothing" /
"matches everything"), and both drivers say so.
- `equals: ['a', 'b']` — lowers to a deep-equality comparand every backend answers.
- `contains: 5` — no backend refuses it.
- `is_empty: ''` — the null predicates take their direction from the operator
**name**; `convertComparison` ignores the value position, and the ObjectUI
client deliberately sends a truthy placeholder there.

The refusal names the operator, the field, the shape received and the shape to
write:

```
Operator "not_in" on field "stage" requires an ARRAY of values. Received a
string ("won"). "not_in" tests membership of a list — write ["won"] for a single
value, or use "not_equals" to compare against it. An empty list [] is allowed and
is a real predicate. This is refused at authoring time because the query path
refuses it too (400 INVALID_FILTER, #5869).
```

**Migration.** A filter rule whose operator is `in`, `not_in` or `between` and
whose `value` is not an array of the right arity now fails to parse; `os validate`
and `os lint` report each one by path. Wrap a single value in a list
(`value: 'won'` → `value: ['won']`) or complete the range's second bound.

Two checks are worth doing where they look unnecessary. A rule reading
`operator: 'in', value: ''` is an **unfinished** row, not a filter — decide what
it was meant to select rather than mechanically rewriting it to `[""]`, which is
a real and different predicate. And a view that already carried one of these
shapes **was never returning filtered rows**: it answered `400 INVALID_FILTER` on
render, so re-check what the view is supposed to show rather than assuming the
old result set was correct.

**Metadata at rest is not rewritten, and there is no D2 conversion.** The read
path does not re-validate stored rows, so no stored view becomes unreadable; what
changes is that re-saving one is refused at the write gate, naming `value`. A
conversion was considered and rejected: this shape was never written by any
first-party producer (measured — every `in` / `not_in` rule across this repo,
`objectui` and `cloud` already carries an array) and has never executed, so
coercing it at load would be the platform guessing intent rather than replaying a
rename — and it cannot guess honestly, since `between: 5` has no defensible
second bound.

Two operator vocabularies are now exported —
`VIEW_FILTER_LIST_VALUE_OPERATORS` and `VIEW_FILTER_PAIR_VALUE_OPERATORS` — so a
producer can ask the question the schema asks instead of keeping its own copy.
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ View filter rule
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field name to filter on |
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator |
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value |
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. |

### Allowed Values: `ViewFilterRule.operator`

Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ The same descriptor loses a key in this step, and the pairing is the point (#674
- **`import-run-automations-declared-default-corrected`** — `api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as "off by default for bulk"; it is now default(true), which is what the server has always done` → an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing
- Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078.
- Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says (#6037).
- **`view-filter-rule-value-shaped-by-operator`** — `ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged` → an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" becomes value: ["won"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse
- Why not automatic: A publish-time gate catching up to a query-time one, not a new rule. #5869 / PR #6209 closed the RUNTIME half: `assertListComparandShapes` (@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered `{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because #5685 already ruled on the opposite error: a schema stricter than the runtime "in ways the runtime deliberately allows" was the WRONG side and was widened to match. So `in: []` is still accepted (a declared predicate both drivers implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality comparand), and `is_empty: ""` is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: "" would become the predicate [""] (a real filter on the empty string) rather than the "not filled in yet" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate naming `value`, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112.
- Done when: Grep your authored views, pages and related-list components for a filter rule whose operator is in, not_in or between (including the alias spellings nin / notIn / notin) and whose value is not an array of the right arity, then wrap or complete it. `os validate` / `os lint` now report each one by path with the operator, the received shape and the corrected shape, so the sweep is mechanical rather than by eye. Two checks are worth doing where it looks unnecessary: a rule reading `operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was meant to select rather than mechanically rewriting it to [""], which is a real and different predicate. And a view that already carried one of these shapes was never returning filtered rows: it answered 400 INVALID_FILTER on render (#5869), so re-check what the view is supposed to show rather than assuming the old result set was correct.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
* actually validates against.
*/
import { describe, it, expect } from 'vitest';
import { ViewMetadataSchema, VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui';
import {
ViewMetadataSchema,
VIEW_FILTER_OPERATORS,
VIEW_FILTER_OPERATOR_ALIASES,
VIEW_FILTER_LIST_VALUE_OPERATORS,
VIEW_FILTER_PAIR_VALUE_OPERATORS,
} from '@objectstack/spec/ui';
import { graftNormalizedOperators } from './protocol.js';

/** Graft through the real spec schema, the way `saveMetaItem` does. */
Expand All @@ -31,6 +37,30 @@ function graftThroughSchema(authored: unknown): unknown {
return graftNormalizedOperators(authored, parsed.data);
}

/**
* A `value` whose SHAPE the given canonical operator can carry (#6227).
*
* This suite's subject is the ALIAS FOLD, and the fold is only observable on a
* rule that PARSES. Since #6227 the spec couples `value` to `operator` — the
* three aliases that fold to `not_in` (`nin` / `notin` / `notIn`) need an array,
* and a range needs two bounds — so a single hard-coded scalar would be refused
* for a reason that has nothing to do with what is being tested.
*
* Read from the spec's own exported vocabularies rather than a local list, so a
* list-valued operator added later cannot silently reintroduce the breakage:
* that is the same "one declared vocabulary, not N dialects" rule the alias
* table itself exists to serve.
*/
function valueFor(canonicalOperator: string): unknown {
if ((VIEW_FILTER_LIST_VALUE_OPERATORS as readonly string[]).includes(canonicalOperator)) {
return ['x'];
}
if ((VIEW_FILTER_PAIR_VALUE_OPERATORS as readonly string[]).includes(canonicalOperator)) {
return ['x', 'y'];
}
return 'x';
}

/** Flattened runtime view overlay — the shape a console personalization PUT sends. */
const view = (filter: unknown, extra: Record<string, unknown> = {}) => ({
name: 'showcase_task.open',
Expand All @@ -53,9 +83,9 @@ describe('graftNormalizedOperators — through the real view metadata schema', (
it('canonicalizes every alias the spec still folds', () => {
const canonical = new Set<string>(VIEW_FILTER_OPERATORS);
const stillLegacy: string[] = [];
for (const alias of Object.keys(VIEW_FILTER_OPERATOR_ALIASES)) {
for (const [alias, foldsTo] of Object.entries(VIEW_FILTER_OPERATOR_ALIASES)) {
const out = graftThroughSchema(
view([{ field: 'status', operator: alias, value: 'x' }]),
view([{ field: 'status', operator: alias, value: valueFor(foldsTo) }]),
) as { filter: Array<{ operator: string }> };
if (!canonical.has(out.filter[0].operator)) stillLegacy.push(alias);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,10 @@
"UserFiltersParsed (type)",
"UserFiltersSchema (const)",
"VIEW_CONSOLE_ROW_DECORATIONS (const)",
"VIEW_FILTER_LIST_VALUE_OPERATORS (const)",
"VIEW_FILTER_OPERATORS (const)",
"VIEW_FILTER_OPERATOR_ALIASES (const)",
"VIEW_FILTER_PAIR_VALUE_OPERATORS (const)",
"VIEW_METADATA_BRANCHES (const)",
"VIEW_METADATA_MEMBERS (const)",
"VIEW_WRITE_PATH_IDENTITY_KEYS (const)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,10 @@
"UserFiltersParsed": "src/ui/view.zod.ts#UserFiltersParsed (type)",
"UserFiltersSchema": "src/ui/view.zod.ts#UserFiltersSchema (const)",
"VIEW_CONSOLE_ROW_DECORATIONS": "src/ui/view.zod.ts#VIEW_CONSOLE_ROW_DECORATIONS (const)",
"VIEW_FILTER_LIST_VALUE_OPERATORS": "src/ui/view.zod.ts#VIEW_FILTER_LIST_VALUE_OPERATORS (const)",
"VIEW_FILTER_OPERATORS": "src/ui/view.zod.ts#VIEW_FILTER_OPERATORS (const)",
"VIEW_FILTER_OPERATOR_ALIASES": "src/ui/view.zod.ts#VIEW_FILTER_OPERATOR_ALIASES (const)",
"VIEW_FILTER_PAIR_VALUE_OPERATORS": "src/ui/view.zod.ts#VIEW_FILTER_PAIR_VALUE_OPERATORS (const)",
"VIEW_METADATA_BRANCHES": "src/ui/view.zod.ts#VIEW_METADATA_BRANCHES (const)",
"VIEW_METADATA_MEMBERS": "src/ui/view.zod.ts#VIEW_METADATA_MEMBERS (const)",
"VIEW_WRITE_PATH_IDENTITY_KEYS": "src/ui/view.zod.ts#VIEW_WRITE_PATH_IDENTITY_KEYS (const)",
Expand Down
Loading
Loading