Skip to content

Commit d2bc644

Browse files
fix(core,objectql,plugin-security): the RLS write check judges a lone scalar on a declared multi-valued field as the list it is stored as (#21253)
Fixes #21238 Clause-②: yes (widening) The row-level write `check` now judges a lone scalar written to a declared multi-valued field as the one-member list the write door stores it as. The wrap rule moves into `@objectstack/core` as `multiValueStorageForm`, beside `temporalStorageForm`. objectql's record validator calls it, and `storedFormCheckJudge` (from #21235) folds it. That is one rule for the write door and the check, with no second copy. This is triage's route A (comment 5940327789), built on claim 5941247880. ### Cross-lane files (`domain:engine`), named before the change list - `packages/core/src/utils/multi-value-storage-form.ts`, its test, and the export line in `packages/core/src/index.ts`: the moved rule. - `packages/objectql/src/validation/record-validator.ts`: `normalizeMultiValueFields` calls the moved rule. What the write door stores does not change (proof below). ### Premise, re-measured on `main` at `be5a83cf` The harness is the one #21235's pins use: `ObjectQL.insert` + `SecurityPlugin` + a SQL driver, a member resolving a permission set, with `using` and `check` set to the same predicate. Each row was measured on both driver families (`driver-sql` on better-sqlite3, and `driver-sqlite-wasm`) with the same result. "Stored" and "read" come from a system write of the same value. | `check` | member writes | write on `main` | stored | read | |---|---|---|---|---| | `record.tags.contains('x')` | `'x'` | 403 `PERMISSION_DENIED` | `["x"]` | shown | | `record.tags.contains('x')` | `'xy'` | 403 | `["xy"]` | hidden | | `record.tags.contains('x')` | `['x']` | admitted | `["x"]` | shown | | `!record.tags.contains('x')` | `'x'` | **admitted** | `["x"]` | **hidden** | | `record.owners.contains('x')` (`select`, `multiple: true`) | `'x'` | 403 | `["x"]` | shown | | `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown | | `record.tags.contains('x')`, a predicate update | `'x'` | admitted | `["x"]` | shown | The premise holds. The fourth row goes further than the card's grading. The card calls the split fail-closed, but the negated form fails OPEN: a policy that forbids a member from tagging a row `x` is passed by sending `'x'` instead of `['x']`. The insert seam runs before the write door wraps, so the scalar is judged and the list is stored. The predicate-update row was already right on `main`, because the engine wraps before that seam. ### Changes - `@objectstack/core`: new `multiValueStorageForm(value)`. It wraps a string, a number or a boolean into `[value]`. It returns every other value as the same value: a list, `null` / `undefined`, a blank string (read as missing), an object, a `Date`. This is the validator's per-value rule, moved as it was. The rule lives in core. The declared-type predicate stays the spec's `isMultiValueField`. - `@objectstack/objectql`: `normalizeMultiValueFields` keeps its column selection (`SKIP_FIELDS`, `system`, `readonly`, `isMultiValueField`) and calls the core rule for the value. - `@objectstack/plugin-security` (`rls-check-stored-form.ts`): `declaredMultiValueColumns(columns)` names the columns the declaration (`declaredComparisonColumns`'s `type` + `multiple`) calls multi-valued. Types come only from the declaration, never from values. `storedFormImage` puts those columns' post-image values through the core rule, copy-on-write. `storedFormCheckJudge` does this on every image it judges: the insert seam, the by-id image, and both update seams. - **Comparands are left as written**, on purpose. The read pairs none with the wrap. `$contains` / `$notContains` take one member, and the read refuses every scalar comparison on such a column (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`, `INVALID_FILTER` / 400). The card's pins need only the image. - Changeset: `@objectstack/core` minor (one new root export; this export is the widening the `Clause-②: yes (widening)` line declares), `@objectstack/plugin-security` minor (the accept set widens: rows 1, 5 and 6 above are now admitted; row 4 is now refused; a security-floor behaviour change, not Clause-②), `@objectstack/objectql` patch (no behaviour change). ### What the fold moves under a scalar-comparison policy A policy that compares a multi-valued field with `==`, `!=`, `in` or an ordering is refused by the read (`INVALID_FILTER` / 400) on both families. On `main` the write check gave `'x'` the OPPOSITE of the verdict `['x']` got. Now both get the verdict of the list the store holds. Measured at base and at head on both families: | `check` (read: 400) | `'x'` on `main` | `['x']` on `main` | `'x'` now | `['x']` now | |---|---|---|---|---| | `record.tags == 'x'` | admitted | 403 | 403 | 403 | | `record.tags != 'x'` | 403 | admitted | admitted | admitted | | `record.tags in ['x']` | admitted | 403 | 403 | 403 | | `!(record.tags in ['x'])` | 403 | admitted | admitted | admitted | | `record.tags > 'a'` | admitted | 400 | 400 | 400 | That the write check evaluates these at all, while the read refuses them, is a separate read/write split. It is in the Acceptance notes and in the dev report, and this PR does not fix it. ### The write door, byte for byte - The validator's own tests, before and after: `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/validation/` gave 17 files / 917 passed with `record-validator.ts` restored from `be5a83cf`, and 17 files / 917 passed at the change. The restore was proven: blob == HEAD, `git diff HEAD` empty. - A differential probe (scratch, not committed) ran the verbatim `be5a83cf` body of `normalizeMultiValueFields` against the new one. It covered 2750 cases: 5 schema shapes x 22 field names (every multi-capable type with and without `multiple`, `system` / `readonly`, `SKIP_FIELDS`, undeclared and prototype names) x 25 values (blank strings, `NaN`, `Infinity`, `0`, booleans, lists, objects, operator objects, `Date`, bigint, symbol, function). Result: **0 differences**, with the identity of a list left in place compared too. Positive control: one planted difference (a blank string wrapped) reads `diffs=1`. - The full objectql suite: 361 files / 7087 passed, in three chunks under the verify lock. ### Pins (`rls-check-stored-form.test.ts`, 38 cells before, 61 now) Both driver families. Each cell checks write == read on the same row, and a refusal is asserted as `{ code: 'PERMISSION_DENIED', status: 403 }` with nothing stored. - `tags: 'x'` under `contains('x')` is admitted, stored `["x"]` and shown. `tags: 'xy'` is refused and hidden. `tags: ['x']` is admitted. `!contains('x')` with `'x'` is refused and hidden. `owners` (`select`, `multiple: true`) gives the same `'x'` / `'xy'` pair. - Controls on a non-multi-valued `text` column: `title == 'x'` with `'x'`, and `title.contains('x')` with `'xy'`, are both admitted (substring, no wrap). - A by-id update and a predicate update: `'x'` is admitted and stored `["x"]`. `'xy'` is 403 and the row is unchanged. - Unit cells: `declaredMultiValueColumns` names exactly the declared multi-valued columns. The image wrap leaves lists (by reference), blanks, single-value `select` / `lookup` and `text` alone. A lone scalar gets its list's verdict under `contains`, `!contains`, `$notContains` and an `$or`, and the comparands come back as the same object. - #21235's 38 temporal cells are unchanged and green. - `packages/core/src/utils/multi-value-storage-form.test.ts`: 16 cells for the rule itself. ### Ablations Each ran on committed head `a049a417` through `scripts/ablation-replace.mjs` in WRAP mode. Every anchor hit 1 -> 0. Every restore was proven: blob == HEAD and `git diff HEAD` empty. - **A1, the fold removed** (the judge passes no multi-valued columns): 9 red. That is the scalar-admitted `tags` / `owners` cells x2, `!contains` x2, the by-id update x2, and 1 unit cell. The predicate-update cell stays green, because the engine already wraps before that seam. - **A2, the wrap applied to a non-declared `text` column**: 8 red. That is the three `text` controls x2, plus 2 unit cells. - **A3, the judge admits every image**: 25 red. That is the 12 negative pins x 2 families, plus 1 unit cell. Every negative pin can fail. - **A4, the core rule stops wrapping, rebuilt into `dist/`**: `ablation-dist-preflight.mjs` found the marker in `packages/core/dist/index.js` and `index.cjs`. Core unit: 7 red. objectql `record-validator.test.ts`: 1 red, reached through core's `dist/`. plugin-security: 16 red. Both faces read the one rule. Restore leg: core rebuilt, preflight `--absent` ok, the whole tree clean, and the three suites green again (16 / 126 / 61). ### Tests and gates - Core: 79 files / 2199 passed (both vitest projects). Typecheck for core, objectql and plugin-security: all three exit 0, and each `check:test-typecheck` is OK. - plugin-security full suite: 157 files, 3406 passed / 23 skipped. - These ran at `242331e8`. The final head `8f7c43bf` differs from it only in the changeset's text. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `8f7c43bf` derives 70 families. All 70 ran and exited 0. `--ran` reports 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. - The first sweep (at `242331e8`) had three non-zero exits, all cleared. `check:engine-split-ratio` refused on the shallow clone (exit 2) and passed after the deepening its remedy names. `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT MET) and passed after a full turbo build (72/72). - Narrowed lint at `8f7c43bf`: `eslint --no-inline-config --format json` over the 6 changed `.ts` files gives 6 files, 0 errors, 0 warnings. The population is `eslint.config.mjs:971` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`). `eslint.config.mjs:327-329` enables no type-aware linting, so no untouched file's verdict can move. The full `pnpm lint` is CI's. - No control bytes in the 7 changed files. `AGENTS.md` stayed unmodified throughout. ### Docs I grepped `content/docs/**` (outside `releases/` and `references/`) and `skills/**` for the scalar wrap (`single-element array`, `lone scalar`, `wrap … scalar`, `normalizeMultiValueFields`), and for `contains` / multi-valued under a `check`. No sentence is made false. Positive controls: the grep reaches `content/docs/data-modeling/validation-rules.mdx:473` ("A lone scalar value is coerced into a single-element array" is still true) and `content/docs/permissions/rls.mdx:64` (the `check` judges each row an insert or update writes, which is still true). ## Acceptance notes 1. **Scalar comparisons on a multi-valued field: the write check evaluates them, and the read refuses them (400).** The table above shows it. Under `record.tags != 'x'`, a member's write is admitted and stored, while every read under that policy answers `INVALID_FILTER`. The formula matcher (the write check) does not consume `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, the set `driver-sql`, `driver-memory` and objectql's per-aggregation filter refuse by. This is reported to the seat as a finding and is not fixed here. 2. `security-plugin.ts:3294` (the comment above the `storedFormCheckJudge` call) still names only `date` / `datetime` / `time`. It points to `rls-check-stored-form.ts` for what the step carries, which now says it. The file is outside this claim's surface (carrier: none). 3. Boundary: the write door does not wrap a `system` / `readonly` multi-valued column, and the check's declaration does not carry those flags. A caller's value there is stripped by the engine before its seams. Only a hook's own scalar write to such a column is judged wrapped while it is stored as written. That is the boundary the insert seam already states for platform-owned values. 4. The `Clause-②: yes (widening)` line declares the new `@objectstack/core` root export `multiValueStorageForm`. The RLS admission change is a security-floor behaviour change, not Clause-②. The claim's original `no` was revised on #21238 (Clause-② claim revision), and the changeset carries the same line from `e559c9ac`, where the changeset and ADR-0087 families and `check:nul-bytes` were re-run green. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30c530e commit d2bc644

7 files changed

Lines changed: 324 additions & 35 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/plugin-security': minor
5+
---
6+
7+
fix(plugin-security): a row-level `check` judges a lone scalar written to a declared multi-valued field as the one-member list it is stored as, so the write and the read the same policy scopes give one answer for one row (#21238)
8+
9+
Clause-②: yes (widening)
10+
11+
The write door stores a lone scalar sent to a multi-valued field (`tags`, `multiselect`, `checkboxes`, or a `select` / `lookup` / `user` / `file` / `image` flagged `multiple: true`) as a one-member list: `tags: 'x'` is stored as `["x"]`. The row-level write `check` judged the value as sent on the insert and on a by-id update, because both images are formed before the write door runs. Measured through `ObjectQL.insert` with `SecurityPlugin` on two SQLite driver families, as a member resolving a permission set, with the same predicate as `using` and `check`:
12+
13+
| `check` | written | write, before | stored | read |
14+
|---|---|---|---|---|
15+
| `record.tags.contains('x')` | `'x'` | 403 | `["x"]` | shown |
16+
| `!record.tags.contains('x')` | `'x'` | admitted | `["x"]` | hidden |
17+
| `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown |
18+
19+
Now the image's value on every field the object declares multi-valued goes through the same rule the write door stores it by, before the check is judged. The first and third rows are admitted. The second is refused: a policy that forbids a member from tagging a row `x` can no longer be passed by sending `'x'` instead of `['x']`. A lone scalar now gets exactly the verdict its stored list gets, on the insert, a by-id update and a predicate update. That includes a policy that compares such a field with a scalar comparison (`==`, `!=`, `in`, an ordering), which the read refuses with `INVALID_FILTER` / 400: there `'x'` used to get the opposite of the verdict `['x']` got, and now gets the same one.
20+
21+
Unchanged: a field the object does not declare multi-valued is judged as written; a list, `null`, a blank string and an object are judged as written, as the write door leaves them; the check's comparands are left as written, since `contains` takes one member; and refusals keep their code and status (`PERMISSION_DENIED` / 403).
22+
23+
**`@objectstack/core`** (one new root export, so `minor`; this export is the widening the `Clause-②: yes (widening)` line declares): `multiValueStorageForm(value)`, the rule itself. It wraps a string, a number or a boolean into a one-member list and returns every other value as the same value. `@objectstack/objectql`'s `normalizeMultiValueFields` now calls it, with no change in what the write door stores (`patch`). `@objectstack/plugin-security` is `minor` because the set of writes its check admits widens (the first and third rows above); that is a security-floor behaviour change, not the declared widening.

‎packages/core/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export * from './utils/temporal-comparand.js';
126126
// do not depend on each other, and each driver used to carry its own copy.
127127
export * from './utils/temporal-storage-form.js';
128128

129+
// [#21238] …and the storage form of a value written to a declared multi-valued
130+
// column (a lone scalar stored as a one-member list). `@objectstack/objectql`'s
131+
// record validator applies it at the write door, and `@objectstack/plugin-security`'s
132+
// row-level write check applies it to the image it judges — one rule, here
133+
// because those two do not depend on each other at runtime.
134+
export * from './utils/multi-value-storage-form.js';
135+
129136
// [#21007] …and the refusal a scalar comparison gets on a field stored as a
130137
// JSON column: the operator set and the words. `driver-sql` refuses it on
131138
// `where`, and `@objectstack/objectql` on the per-aggregation `filter` it
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21238] `multiValueStorageForm` — the ONE rule that puts a value written to a
4+
// declared multi-valued column into its stored form: objectql's record
5+
// validator applies it at the write door (`normalizeMultiValueFields`, pinned in
6+
// `record-validator.test.ts`), and plugin-security's write check applies it to
7+
// the image it judges (`rls-check-stored-form.test.ts`). This file pins the rule
8+
// itself.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { multiValueStorageForm } from './multi-value-storage-form.js';
12+
13+
describe('multiValueStorageForm — a lone scalar becomes a one-member list', () => {
14+
const cases: ReadonlyArray<readonly [string, unknown]> = [
15+
['a string', 'x'],
16+
['a string with surrounding space, kept as written', ' x '],
17+
['a number', 1],
18+
['zero', 0],
19+
['NaN, a number', Number.NaN],
20+
['true', true],
21+
['false', false],
22+
];
23+
for (const [name, input] of cases) {
24+
it(name, () => expect(multiValueStorageForm(input)).toEqual([input]));
25+
}
26+
});
27+
28+
describe('multiValueStorageForm — everything else is returned as the SAME value', () => {
29+
const list = ['x', 'y'];
30+
const object = { nested: true };
31+
const date = new Date('2026-01-05T00:00:00Z');
32+
const cases: ReadonlyArray<readonly [string, unknown]> = [
33+
['a list, already in the form', list],
34+
['an empty list', []],
35+
['undefined', undefined],
36+
['null', null],
37+
['the empty string, read as missing', ''],
38+
['a string of whitespace, read as missing', ' \t '],
39+
['a plain object, left for the validator to refuse', object],
40+
['a Date, not a scalar the rule reads', date],
41+
['a bigint, not a scalar the rule reads', 1n],
42+
];
43+
for (const [name, input] of cases) {
44+
it(name, () => expect(multiValueStorageForm(input)).toBe(input));
45+
}
46+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21238] THE storage form of one value written to a declared multi-valued
5+
* column — one rule, read by the write door that stores it and by the check
6+
* that judges the row it stores.
7+
*
8+
* A multi-valued column (`@objectstack/spec/data`'s `isMultiValueField`: an
9+
* inherently-multi option type such as `tags` or `multiselect`, or a
10+
* multi-capable type flagged `multiple: true`) persists a LIST. A client may
11+
* still send one lone scalar — a legacy console bulk-edit sent
12+
* `{ labels: 'frontend' }` at a multiselect (#2552) — and the write door stores
13+
* it as a one-member list, so `tags: 'x'` is stored as `['x']`.
14+
*
15+
* ## Two faces, one rule
16+
*
17+
* - `@objectstack/objectql`'s record validator applies it on every write
18+
* (`normalizeMultiValueFields`), before validation, so the row it hands the
19+
* driver holds the list.
20+
* - `@objectstack/plugin-security`'s row-level write `check` applies it to the
21+
* image it judges (`storedFormCheckJudge`), because some of the images it
22+
* judges are formed before the write door has run: the insert seam and the
23+
* by-id update image. Judged raw, `tags: 'x'` under
24+
* `record.tags.contains('x')` was refused while the stored `['x']` was shown
25+
* by the read the same policy scopes, and admitted under
26+
* `!record.tags.contains('x')` while the read hid it.
27+
*
28+
* The two packages do not depend on each other at runtime, and a copy each is
29+
* how one write comes to get two answers. So the rule lives here, beside
30+
* `temporalStorageForm`, which the same two faces share for the same reason.
31+
* Which columns it applies to is the caller's: the declared-type predicate
32+
* stays the spec's, and the write door also skips the columns the engine owns.
33+
*
34+
* ## The rule
35+
*
36+
* - A string, a number or a boolean is wrapped: `[value]`.
37+
* - A list is already in the form, and is returned as it is.
38+
* - `null`, `undefined`, the empty string and a string of whitespace are not
39+
* wrapped: the write door reads a blank as missing, never as a member.
40+
* - Anything else (an object, a `Date`, a function) is returned unchanged, so
41+
* the validator refuses it as a value that is not a list. ⛔ No shape is
42+
* guessed into a list.
43+
*
44+
* It returns the SAME value when there is nothing to wrap, so a caller can
45+
* tell "already in the form" by identity.
46+
*/
47+
export function multiValueStorageForm(value: unknown): unknown {
48+
if (value === undefined || value === null) return value;
49+
if (typeof value === 'string' && value.trim() === '') return value;
50+
if (Array.isArray(value)) return value;
51+
const t = typeof value;
52+
if (t === 'string' || t === 'number' || t === 'boolean') return [value];
53+
return value;
54+
}

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import {
9595
classifyFilterToken,
9696
} from '@objectstack/spec/data';
9797
import type { FieldErrorCode } from '@objectstack/spec/api';
98-
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand } from '@objectstack/core';
98+
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand, multiValueStorageForm } from '@objectstack/core';
9999
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
100100
import {
101101
renderValidationMessage,
@@ -592,24 +592,26 @@ function valueMayBeAnObject(def: FieldDef): boolean {
592592
* without this the scalar used to be stored verbatim, silently corrupting
593593
* the column's shape for every consumer that expects an array.
594594
*
595-
* Only unambiguous scalars (string/number/boolean) are wrapped; anything
596-
* else (plain objects, nested garbage) is left untouched so that
597-
* `validateRecord` can reject it with `invalid_type`.
595+
* [#21238] What a value becomes is `@objectstack/core`'s
596+
* `multiValueStorageForm`, the one rule the row-level write `check` also puts
597+
* the image it judges through: only unambiguous scalars (string/number/boolean)
598+
* are wrapped, a blank is left as missing, and anything else (plain objects,
599+
* nested garbage) is left untouched so that `validateRecord` can reject it with
600+
* `invalid_type`. WHICH columns it is applied to is this door's: a declared
601+
* multi-valued field (`isMultiValueField`), never a lifecycle column or one the
602+
* engine owns (`system` / `readonly`).
598603
*/
599604
export function normalizeMultiValueFields(
600605
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
601606
data: Record<string, unknown> | undefined | null,
602607
): void {
603608
if (!objectSchema?.fields || !data) return;
604609
for (const [name, value] of Object.entries(data)) {
605-
if (SKIP_FIELDS.has(name) || isMissing(value)) continue;
610+
if (SKIP_FIELDS.has(name)) continue;
606611
const def = objectSchema.fields[name];
607612
if (!def || def.system || def.readonly || !isMultiValueField(def)) continue;
608-
if (Array.isArray(value)) continue;
609-
const t = typeof value;
610-
if (t === 'string' || t === 'number' || t === 'boolean') {
611-
data[name] = [value];
612-
}
613+
const stored = multiValueStorageForm(value);
614+
if (stored !== value) data[name] = stored;
613615
}
614616
}
615617

‎packages/plugins/plugin-security/src/rls-check-stored-form.test.ts‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@
1818
* | `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
1919
* | `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
2020
*
21+
* [#21238] The same holds for a lone scalar written to a declared multi-valued
22+
* column, which the write door stores as a one-member list (`tags`, and a
23+
* `select` flagged `multiple`; the insert, a by-id update and a predicate
24+
* update). Measured on `main` before the fold:
25+
*
26+
* | `check` | written | write, before | stored | read |
27+
* |---|---|---|---|---|
28+
* | `record.tags.contains('x')` | `'x'` | 403 | `["x"]` | shown |
29+
* | `!record.tags.contains('x')` | `'x'` | admitted | `["x"]` | hidden |
30+
* | `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown |
31+
*
2132
* ## formula's whole-day copy is out of reach here
2233
*
2334
* `@objectstack/formula`'s matcher carries its own copy of the whole-day upper
@@ -39,7 +50,13 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
3950
import { PermissionSetSchema } from '@objectstack/spec/security';
4051
import { SecurityPlugin } from './security-plugin.js';
4152
import { defaultPermissionSets } from './objects/default-permission-sets.js';
42-
import { declaredTemporalColumns, storedFormCheckFilter, storedFormImage } from './rls-check-stored-form.js';
53+
import {
54+
declaredMultiValueColumns,
55+
declaredTemporalColumns,
56+
storedFormCheckFilter,
57+
storedFormCheckJudge,
58+
storedFormImage,
59+
} from './rls-check-stored-form.js';
4360

4461
/** A plain object — a filter node or an operator map. */
4562
function isPlain(value: unknown): value is Record<string, unknown> {
@@ -123,6 +140,8 @@ async function boot(makeDriver: () => Driver, predicate: string) {
123140
due_on: { name: 'due_on', type: 'date' },
124141
due_at: { name: 'due_at', type: 'datetime' },
125142
start_time: { name: 'start_time', type: 'time' },
143+
tags: { name: 'tags', type: 'tags' },
144+
owners: { name: 'owners', type: 'select', multiple: true, options: [{ label: 'X', value: 'x' }, { label: 'XY', value: 'xy' }] },
126145
},
127146
},
128147
],
@@ -199,6 +218,16 @@ const CELLS: Cell[] = [
199218
// Control: a TEXT column is judged as written, whatever its value looks like.
200219
{ predicate: "record.title == '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: false },
201220
{ predicate: "record.title > '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: true },
221+
// [#21238] A declared multi-valued column: a lone scalar is stored as a one-member list.
222+
{ predicate: "record.tags.contains('x')", column: 'tags', value: 'x', stored: ['x'], admitted: true },
223+
{ predicate: "record.tags.contains('x')", column: 'tags', value: 'xy', stored: ['xy'], admitted: false },
224+
{ predicate: "record.tags.contains('x')", column: 'tags', value: ['x'], stored: ['x'], admitted: true },
225+
{ predicate: "!record.tags.contains('x')", column: 'tags', value: 'x', stored: ['x'], admitted: false },
226+
{ predicate: "record.owners.contains('x')", column: 'owners', value: 'x', stored: ['x'], admitted: true },
227+
{ predicate: "record.owners.contains('x')", column: 'owners', value: 'xy', stored: ['xy'], admitted: false },
228+
// Control: a TEXT column keeps its scalar, and `contains` stays a substring test.
229+
{ predicate: "record.title == 'x'", column: 'title', value: 'x', stored: 'x', admitted: true },
230+
{ predicate: "record.title.contains('x')", column: 'title', value: 'xy', stored: 'xy', admitted: true },
202231
];
203232

204233
describe("formula's whole-day copy is out of reach in this file", () => {
@@ -209,7 +238,7 @@ describe("formula's whole-day copy is out of reach in this file", () => {
209238
});
210239

211240
for (const [driverName, makeDriver] of DRIVERS) {
212-
describe(`${driverName}: the write check and the read give one answer for one temporal row`, () => {
241+
describe(`${driverName}: the write check and the read give one answer for one stored row`, () => {
213242
for (const cell of CELLS) {
214243
const verdict = cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
215244
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
@@ -235,6 +264,29 @@ for (const [driverName, makeDriver] of DRIVERS) {
235264
.toEqual(DENIED);
236265
expect((await r.storedRow('u'))?.due_on).toBe('2026-01-05');
237266
});
267+
268+
it("[#21238] a by-id update judges a lone scalar on a multi-valued column as its stored list: 'x' admitted, 'xy' 403 and unchanged", async () => {
269+
const r = await boot(makeDriver, "record.tags.contains('x')");
270+
await r.engine.insert(r.OBJ, { id: 'u', tags: ['x', 'z'] }, { context: SYS_CTX } as never);
271+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never)))
272+
.toBe('admitted');
273+
expect((await r.storedRow('u'))?.tags).toEqual(['x']);
274+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'xy' }, { where: { id: 'u' }, context: r.caller } as never)))
275+
.toEqual(DENIED);
276+
expect((await r.storedRow('u'))?.tags).toEqual(['x']);
277+
expect(await r.shownTo('u')).toBe(true);
278+
});
279+
280+
it("[#21238] a predicate update judges a lone scalar on a multi-valued column as its stored list: 'x' admitted, 'xy' 403 and unchanged", async () => {
281+
const r = await boot(makeDriver, "record.tags.contains('x')");
282+
await r.engine.insert(r.OBJ, { id: 'p', title: 'batch', tags: ['x'] }, { context: SYS_CTX } as never);
283+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
284+
.toBe('admitted');
285+
expect((await r.storedRow('p'))?.tags).toEqual(['x']);
286+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'xy' }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
287+
.toEqual(DENIED);
288+
expect((await r.storedRow('p'))?.tags).toEqual(['x']);
289+
});
238290
});
239291
}
240292

@@ -286,3 +338,52 @@ describe('the stored-form step reads the declaration, never the values', () => {
286338
expect(storedFormImage({ due_on: new Date('2026-01-05T15:00:00Z') }, COLUMNS)).toEqual({ due_on: '2026-01-05' });
287339
});
288340
});
341+
342+
describe('[#21238] the multi-valued half reads the declaration, never the values', () => {
343+
const DECLARED = {
344+
fields: {
345+
tags: { type: 'tags', multiple: false },
346+
labels: { type: 'multiselect', multiple: false },
347+
owners: { type: 'select', multiple: true },
348+
watchers: { type: 'user', multiple: true },
349+
status: { type: 'select', multiple: false },
350+
owner: { type: 'lookup', multiple: false },
351+
title: { type: 'text', multiple: false },
352+
due_on: { type: 'date', multiple: false },
353+
},
354+
};
355+
const MULTI = declaredMultiValueColumns(DECLARED);
356+
const TEMPORAL = declaredTemporalColumns(DECLARED);
357+
358+
it('names exactly the declared multi-valued columns, and none without a declaration', () => {
359+
expect([...MULTI]).toEqual(['tags', 'labels', 'owners', 'watchers']);
360+
expect(declaredMultiValueColumns(undefined).size).toBe(0);
361+
});
362+
363+
it('stores a lone scalar on a multi-valued column as a one-member list, and nothing else', () => {
364+
const image = { tags: 'x', owners: 'x', labels: ['a'], watchers: '', status: 'x', owner: 'x', title: 'x', due_on: '2026-01-05T15:00:00Z' };
365+
const before = JSON.stringify(image);
366+
const stored = storedFormImage(image, TEMPORAL, MULTI);
367+
expect(stored).toEqual({ tags: ['x'], owners: ['x'], labels: ['a'], watchers: '', status: 'x', owner: 'x', title: 'x', due_on: '2026-01-05' });
368+
expect(stored.labels).toBe(image.labels);
369+
expect(JSON.stringify(image)).toBe(before);
370+
const settled = { tags: ['x'], title: 'x', owners: null };
371+
expect(storedFormImage(settled, TEMPORAL, MULTI)).toBe(settled);
372+
});
373+
374+
it('gives a lone scalar the verdict its stored list gets, and leaves the comparands as written', () => {
375+
for (const part of [
376+
{ tags: { $contains: 'x' } },
377+
{ $not: { tags: { $contains: 'x' } } },
378+
{ tags: { $notContains: 'x' } },
379+
{ $or: [{ tags: { $contains: 'y' } }, { owners: { $contains: 'x' } }] },
380+
]) {
381+
const judge = storedFormCheckJudge([part], DECLARED);
382+
expect(judge({ tags: 'x', owners: 'x' })).toBe(judge({ tags: ['x'], owners: ['x'] }));
383+
expect(judge({ tags: 'xy', owners: 'xy' })).toBe(judge({ tags: ['xy'], owners: ['xy'] }));
384+
expect(storedFormCheckFilter(part, TEMPORAL)).toBe(part);
385+
}
386+
const contains = storedFormCheckJudge([{ tags: { $contains: 'x' } }], DECLARED);
387+
expect([contains({ tags: 'x' }), contains({ tags: 'xy' })]).toEqual([true, false]);
388+
});
389+
});

0 commit comments

Comments
 (0)