Skip to content

Commit 7aab759

Browse files
fix(formula,plugin-security)!: delete formula's whole-day copy of the bare-day bound (lteBound); the RLS seam refuses a non-number on a numeric column (#21336)
Fixes #21242 Clause-②: no (narrowing) This deletes `@objectstack/formula`'s own copy of the whole-day upper bound (`lteBound` in `matches-filter.ts`). Ruling `5933322270` (letter A, on #21109) decided the deletion once the RLS write check judged the stored form (PR #21235). Its precondition, #21255, landed as `c2cd65154`, as the seat's answer B (`5942310644`) sequenced. The deletion opened one fail-closed write/read split, a number column against a day string. The engine's `where` door already refuses that comparand, so the RLS compile seam in `plugin-security` now runs the same spec verdict. That surface was declared on the services seat post (`5945507305`) and in claim amendment `5945512244` before the edit. Base `5f7d84769a` (the fast-forward of the branch to `origin/main`). Head `f6a9373da4` (the contract review 5946309118 judged `be721ef6fb`; the review round added `dd75c24d11` and `f6a9373da4`, see below). #20822 is closed by the seat at landing. This PR does not close it. ## What changes - **`packages/formula/src/matches-filter.ts`.** `lteBound`, its docblock, the `addWholeDays` bullet that named it, and the `nextUtcCalendarDay` / `isUnboundedAbove` imports are deleted. `$lte` and a `$between` maximum now compare as written, with the same `order()` every other ordering operator uses. A docblock on the `$lte` arm records where the whole day is applied now and gives the remedy for a caller that passes no seam: `lowerFilterCondition(filter, { isDatetimeColumn })`. - **`packages/plugins/plugin-security/src/rls-compiler.ts`.** - `RlsFieldGuard` gains `number`: each declared column's `type`, and a formula's `returnType`. - `judgeCompiledComparands` runs `narrowPolicyNumberComparands` after the shape door and before the comparand-type door, the engine's `where` order. - The walk only routes the spec's answers: `numberComparandFieldVerdict` / `numberComparandDoorVerdict` decide, `NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` / `NUMBER_COMPARAND_DOOR_LIST_OPERATORS` name the positions, and `numberComparandRefusalMessage` gives the words. No rule is restated. - A refusal leaves through the existing `refused-comparand` route: the read gets the deny sentinel and the write a 403. A numeric string is narrowed copy-on-write, and the provenance mark is carried. - **`packages/plugins/plugin-security/src/security-plugin.ts`.** `numberComparandFieldsCache` is filled in the same `loadObjectFieldNames` pass as the `datetime` cache and cleared with it. Both guard construction sites pass it. - **Tests and changesets.** The formula direct-call cases move (H5 below). The objectql aggregate pins are updated and extended, and `plugin-security` gains a new pin file. There are two changesets: `formula` and `plugin-security`, each `minor`, BREAKING, `Clause-②: no (narrowing)`. ## Caller table (H1), re-run at `5f7d84769a` `lteBound` was at `matches-filter.ts:902`, `$lte` used it at `:688` and `$between` at `:693`, as H1 said. The three commits between `c2cd65154` and the base touch none of these files. There are **4 production call sites in 2 packages** (`objectql`, `plugin-security`), not 3: `formula` is the definer. Every other non-test hit is a re-export, a type or prose. | package | call site (head) | does a non-storage bound reach it? | evidence | |---|---|---|---| | `objectql` | `having-filter.ts:1657` `compareWithReference` (`having` and `aggregations[i].filter`, every `{ $field }` comparison) | **yes**: a per-row referent holding a bare day. No seam can lower a referent, and literal bounds are lowered by the engine seam before the walker | probes A1-A6 below | | `plugin-security` | `rls-check-stored-form.ts:316` `storedFormCheckJudge` (the RLS write check) | **yes**, on columns that are not `datetime`. The compile seam lowers `datetime` only, and the stored-form step covers `date` / `datetime` / `time` | R1-R4 below | | `plugin-security` | `explain-engine.ts:972` `matchUnderDeclaredColumns` (the explain row verdict) | **yes**: the same compiled `using` filter, on stored rows | the explain column of R1-R4 | | `plugin-security` | `security-plugin.ts:3622` (`:3611` at base) `clearsWall` (the tenant wall) | **no**. `tenantLayer0FilterOf` (`tenant-layer.ts:173-179`) emits only `{ organization_id: X }`, `{ organization_id: { $in } }` or the deny-id equality | source read | ## The two registry-less pins (H2) H2 said the deletion moves both pins by design. Measured, it moves neither as written: no row of their fixtures closes on its due day, so the as-written comparison gives the same answer. Each pin is kept, and each is extended with one boundary row so that it now records the move. The reverse check reddens both. | pin | before | after | why | |---|---|---|---| | `engine-aggregate-filter.test.ts`, registry-less `closed_at $lte { $field: 'due_on' }` over ORDERS | `picked: 3` | `picked: 3` | o1, o2 and o5 close days before their due day, so both readings count them | | …the same, with `o7` closed on its due day at 15:00 (added) | `picked: 4` of 7 | `picked: 3` of 7 | as written, `'2026-01-05T15:00:00.000Z'` sorts above `'2026-01-05'`, which is ADR-0053 D-D1 item 5 for a caller that passes no seam | | `engine-aggregate-having-comparand-shape.test.ts`, registry-less `last_closed $lte { $field: 'first_due' }` over DT_ROWS | `['c1']` | `['c1']` | no group closes on its first due day | | …the same, with group `c4` closed on its due day at 15:00 (added) | `['c1', 'c4']` | `['c1']` | the same reason | ## Residual rows (H3, #21299) and one more mainline row Measured through `engine.aggregate`, which REST `POST /api/v1/data/:object/query` calls, on both driver doors, with the before and after formula dists swapped in turn. Every changed answer is pinned. | row | door | before | after | the `where` twin | |---|---|---|---|---| | audit-opt-out object (`systemFields: { audit: false }`): row-carried `created_at` / `updated_at` `$lte { $field: 'due_on' }`, per-aggregation `filter` | `engine.aggregate` | 3 of 4 | 1 of 4 | refused 400 on an audit-on object, both at this position (`INVALID_FILTER` / 400) and in `SqlDriver`'s `where` (measured on an audit-on object; the audit-opt-out `where` was not driven) | | …the same pair at `having` | `engine.aggregate` | unreachable: `max(created_at)` on an undeclared field is `INVALID_FIELD` / 400 | the same | (n/a) | | direct `applyInMemoryAggregation`, `closed_at $lte { $field: 'due_on' }`, with or without a field map | the published `@objectstack/objectql` export | 3 of 4 | 1 of 4 | (no seam; the class rule belongs to `engine.aggregate`) | | **not anticipated:** two declared `text` columns of one class, `code $lte { $field: 'label' }` with `'2026-01-05 noon'` against `'2026-01-05'` | `engine.aggregate` per-aggregation `filter`, and `having` over two groupBy columns | counted / kept | not counted / dropped | `SqlDriver` `where` keeps `b2` only; `SqlDriver` `engine.aggregate` went from `picked: 2` to `1`, now matching the twin | The last row moves toward `where`. It is the aggregate twin of the RLS text-to-text cell (R3), and it is pinned in both test files. ## The RLS write check (H4) Measured through `ObjectQL.insert` + `SecurityPlugin` + `SqlDriver` (better-sqlite3), as a member, with the policy's `using` equal to its `check`: | cell | before (base) | after the deletion | after the seam step (head) | |---|---|---|---| | R1 `title` text `$lte '2026-01-05'`, `'2026-01-05T15:00:00Z'` | admitted / read hidden / explain visible | 403 / hidden / hidden | 403 / hidden / hidden | | R2 the same, `'2026-01-05 noon'` | admitted / hidden / visible | 403 / hidden / hidden | 403 / hidden / hidden | | R3 `title $lte { $field: 'code' }`, `code` = `'2026-01-05'` | admitted / hidden / visible | 403 / hidden / hidden | 403 / hidden / hidden | | R4 `amount` number `$lte '9999-12-31'`, 5 | admitted / **shown** / visible | 403 / **shown** / hidden | 403 / hidden / hidden; policy dropped `refused-comparand` on both clauses | | controls: `date` / `datetime` on the boundary day, `datetime $lte '9999-12-31'`, text earlier day, number vs number, date vs date reference, text vs `'9999-12-31'` | all admitted / shown / visible | unchanged | unchanged | The `where` door question, measured: `engine.find` with `where { amount: { $lte: '9999-12-31' } }` returns `INVALID_FILTER` / 400 ("compares a declared number field against "9999-12-31" … which is not a number"). The same holds for `'abc'`, while `'10'` is narrowed (1 row). So the seam refuses the same comparand, per answer B. ## Direct-call cases (H5) H5 said "the storage-form lowering" means `@objectstack/core`'s `temporalStorageForm`. Measured: `formula` depends on `@objectstack/spec` alone (`package.json`), so its tests cannot reach `core` without a new dependency edge, a vitest alias and a lockfile change. The lowering the seams apply to a bound is spec's `lowerFilterCondition` (ADR-0053 D-D1 item 4), and `temporalStorageForm` is what the write check applies to the image, pinned in `rls-check-stored-form.test.ts` (green). So the cases move to the seam's lowering, or to storage-form literal inputs. None is deleted: - **`matches-filter.test.ts`, calendar-day block.** These 8 assertions changed answer: the instants at 00:00, 09:15 and 23:59:59.999 under `$lte '2026-07-28'`, `$between` at 21:40, the last day at 23:59:59.999, a `Date` at 10:00, the same-day `$between`, and `'9999-12-30T10:00Z'`. Each is now asserted as written (false), and its whole-day answer is asserted through `lowerFilterCondition(…, { isDatetimeColumn })` (true), together with the lowered shape (`$lt` the next day; `{ $null: false }` on the last day). The other assertions are kept, and run through the lowering where that is meaningful. One case is new: a number against a day string is false. - **`matches-filter-field-reference-offset.test.ts`.** "A calendar day stays a calendar day" moves to the stored `date` form (`done: '2026-03-02'`). The old instant-against-day pair is now pinned as written (false at `addDays: 1`, true at `2`), and handed the declared columns it is refused `INVALID_FILTER` / 400. - **`matches-filter-temporal-conformance.test.ts`.** Every `TEMPORAL_CASES` filter runs through `lowerFilterCondition` with the declared `{ at: datetime, on: date }` map, the RLS compile seam's input, as the `driver-mongodb` and `driver-memory` conformance suites already do. The expected rows are unchanged. ## Filter-semantics faces (`compile-surfaces.md`) - **1 `driver-sql`:** already conformant. It compiles a bare-day bound as written on a column that is not `datetime`. Measured: `where { title: { $lte: '2026-01-05' } }` hides the instant text, and `title $lte { $field: 'code' }` keeps `b2` only. There is no whole-day arm in `sql-driver.ts` (one comment hit). - **2 turso RemoteTransport:** already conformant by source (0 `nextUtcCalendarDay` hits in `remote-transport.ts`). The transport tests that import the matcher pass (103). - **3 analytics read-scope-sql:** not touched; 0 hits in `read-scope-sql.ts`. `read-scope-sql-conformance` (which imports the matcher) is green. - **4 analytics filter-normalizer:** out of scope. That face's interim copies (`native-sql-strategy.ts:1761`, `objectql-strategy.ts:566`, `preview-evaluator.ts:106`) are removed by its own deletion (ADR-0053 D-D1 item 9), not by this card. `native-sql-filter-logic-conformance` is green. - **5 `formula`:** **changed** (this PR). - **Half-face objectql `having-filter`:** answers changed, source untouched. `{ $field }` pairs that no seam lowers are compared as written, and both aggregate test files pin the change. - **Unfrozen `driver-memory` / `driver-mongodb`:** already conformant. Their filter compilers carry no copy (0 hits in `memory-matcher.ts` / `mongodb-filter.ts`). The cube face's `dateRange` window (`memory-analytics.ts:1236`) is D-D1 item 8 and out of scope. ## Review round (head `f6a9373da4`, after contract review PASS 5946309118) Section added by the seat (`domain:engine#1`), from the dev's review-round report: - **The `plugin-security` changeset.** Two sentences are corrected: - one published type gains a member: `RlsFieldGuard`, the optional `fieldGuard` argument of the root-exported `RLSCompiler.compileFilter`, gains the optional `number` member. It is additive and not an accept-set change; - the read leg is stated as measured on SQLite only. - **The WARN detail** names its clause, and names PostgreSQL's server error only for `using`. Both clauses are pinned. - **An end-to-end text-column cell** at the `plugin-security` door: a bare-day `$lte` on a text column now refuses the write (403) and hides the row on read, with the controls admitted. It goes red with the old `formula` dist swapped in. - **The design doc's F7 row** reads retired. No other row moved. - **`check:dispatcher-error-vocabulary` (red on `dd75c24d11`)** is fixed by spelling the one call site's code and status as literals (`INVALID_FILTER`, 400), the gate's preferred remedy. - **Verification at `f6a9373da4`:** the `plugin-security` full suite passes (3,423); the whole `Lint & Repo Gates` job, run locally from `lint.yml`, is 185 of 185 steps green; `dispatch-gates` is 67 derived, 67 run, 0 not measured. ## Verification (head `be721ef6fb`, through `scripts/pm/os-verify-lock.sh`) - **Build.** `turbo run build --filter='@objectstack/dogfood^...'` (63 of 63) on the base tree. After the change, `formula` and `plugin-security` were rebuilt. The `formula` dist has 0 `lteBound` hits (3 before). - **Full suites:** - `@objectstack/formula`: 43 files, 1257 passed; - `@objectstack/plugin-security`: 158 files, 3418 passed and 23 skipped, including PR #21235's `rls-check-stored-form` pins; - `@objectstack/objectql`: `test` 360 files, 7113 passed, and `test:repo` 1 file, 5 passed; - `@objectstack/dogfood`, against the rebuilt `formula` / `plugin-security` dists (0 `lteBound` hits in the `formula` dist, 4 `narrowPolicyNumberComparands` hits in the `plugin-security` dist): 164 files passed and 1 skipped, 1354 tests passed and 3 skipped, exit 0. - **Typecheck** `formula`, `plugin-security` and `objectql`: exit 0, `check:test-typecheck` OK. - **Test-only consumers of the matcher:** - `driver-sql`: 8 files, 481 passed and 4 skipped; - `driver-sqlite-wasm`: 6 files, 217 passed; - `driver-turso`: 2 files, 103 passed; - `driver-memory`: 18 passed; - `driver-mongodb`: 37 skipped (no `mongod` here); - `platform-objects`: 18 passed; - `service-analytics`: 2 files, 72 passed; - `rest` `aggregation-filter-where-doors`: 20 passed. - **Gates.** `dispatch-gates --commands` derived 66 commands. All 66 were run and all exited 0. `dispatch-gates --ran` reports "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN". Two gates first hit my own 280 s wrapper timeout (`check:query-options-erasure`, `check:type-check-debt`) and were re-run to exit 0. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) because 8 unrelated packages had no `dist/`. They were built (41 of 41 tasks from cache), and the gate then exited 0. Lint is narrowed, not the full `pnpm lint`. The checked population is the 9 `.ts` files of the diff, linted with `eslint --no-inline-config --format json`: 9 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, header at `:327`) and no `import` plugin, so this diff cannot move the verdict on any file it does not touch. The full `pnpm lint` is left to CI. - **Reverse check 1, the seam.** `ablation-replace` turned the number door off at its one call site (`judgeCompiledComparands(…, fieldGuard?.number)` without the third argument), on-disk anchor 1 to 0. `rls-number-comparand-door.test.ts` then went **4 failed** / 7 passed: the three refusal pins and the `== '10'` narrowing pin. The restore was proven by blob equal to HEAD and an empty `git diff HEAD`. - **Reverse check 2, the deletion.** - The whole-day reading was put back into `$lte` with a `'ablation-21242'` marker (on disk 1; anchor 1 to 0). The `formula` suites went **9 failed**. 4 of those are the as-written pins: the bare-day block, the last-day as-written case, the `9999-12-30` control's as-written leg, and the instant-against-shifted-day pair. The other 5 come from the ablation's simpler day arithmetic on an impossible day and on `9999-12-31` (two temporal-conformance `date` cases, the impossible-day case, and one leg each of the seam and no-instant cases), which `lteBound` handled with its calendar check and `UNBOUNDED_ABOVE`. - Rebuilt, `ablation-dist-preflight` found the marker in `dist/`. The `objectql` pins went **6 failed** of 286: both registry-less boundary pins, the text/text, audit-opt-out and `applyInMemoryAggregation` pins, and the `having` text/text pin. - The restore leg was proven: `--restore`, a rebuild, `--absent` preflight exit 0, blob `f963059621` equal to HEAD, and an empty `git diff HEAD`. ## Acceptance notes (out of the claim's surface; nothing filed) - `plugin-security/src/rls-check-stored-form.test.ts:32-41, :233`. The header "formula's whole-day copy is out of reach here" and the `withoutWholeDayCopy` matcher wrapper are now redundant: the wrapper changes no answer at head, and the pins stay green. Carrier: the services seat when it next touches the file (otherwise 承接者:无). - `docs/design/predicate-compilation-convergence.md:361`: the F7 row still lists `lteBound` (2 sites). Stale prose, 承接者:无. - `driver-sql/src/cross-field-conformance-cases.ts:145`: the note names "memory `lteBound`". It was already stale before this PR, 承接者:无. - The RLS seam's number-refusal WARN detail is written for the clause it refused: the path is rooted at the clause (`check.amount.$lte` / `using.amount.$lte`), and it names PostgreSQL's server error only for `using`, the clause a driver binds; `check` is evaluated in-process. Both clauses' wording is pinned in `rls-number-comparand-door.test.ts`. - The `plugin-security` changeset names one facet that admits more: after narrowing, `record.amount == '10'` admits a write of `10`. The check used to refuse that write, compared as text, while the read showed the row. That facet is read/write agreement, and it ships under the `no (narrowing)` line. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3911901 commit 7aab759

12 files changed

Lines changed: 855 additions & 97 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
fix(formula)!: `matchesFilterCondition` compares a bare-day upper bound as written; its own whole-day copy is deleted (ADR-0053 D-D1 items 5 and 9)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a change of how one runtime evaluator answers an ordering comparison, not of anything an author writes: no spec key, spelling, export or stored shape moves. FilterConditionSchema, every RLS policy, object and query definition parse and save as before, @objectstack/formula exports the same names with the same types, and no stored row is read or rewritten. What moves is the answer for a bare-day upper bound that reaches the evaluator without the shared lowering, which the seams already apply, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter's bound semantics and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what the RLS write check admits on columns that are not `datetime`, and moves a few `engine.aggregate` answers that no seam lowers. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What is deleted.** `matchesFilterCondition` no longer reads a bare `YYYY-MM-DD` `$lte`, or a `$between` maximum, as "through that whole day", and no longer drops the bound on `9999-12-31`. It compares the value as written, as every other ordering operator here does, and as `driver-sql` compares it on the read. The whole day is applied once, at the seams that feed this evaluator, by the shared `lowerFilterCondition` (`@objectstack/spec/data`): the RLS compile seam lowers every policy filter on the object's declared `datetime` columns, the engine lowers `having` and `aggregations[i].filter` the same way, and the RLS write check judges a declared `date`, `datetime` or `time` column in its stored form. So a `check` on a `date` or `datetime` column answers exactly as before.
14+
15+
**The RLS write check now agrees with the read on other columns.** Measured through `ObjectQL.insert` and `SecurityPlugin` on `SqlDriver` (better-sqlite3), as a member whose policy has the same `using` and `check`:
16+
17+
- a `text` column under `record.title <= '2026-01-05'`, written as `'2026-01-05T15:00:00Z'` or `'2026-01-05 noon'`: the write was admitted while the read hid the stored row. It is now refused `PERMISSION_DENIED` / 403, and the read still hides it;
18+
- two `text` columns, `record.title <= record.code`, with `code` holding `'2026-01-05'`: the same, admitted before and 403 now, with the read hiding the row;
19+
- a `number` column under `record.amount <= '9999-12-31'`: the write was admitted because an epoch number read as an instant on the last supported day. A number is not less than a day string, so it is now 403. The engine refuses the same comparison in a `where` (`INVALID_FILTER` / 400: a day string is not a number).
20+
21+
The access explanation (`explain`) judges a stored row with this evaluator, so its row verdict moves the same way: for the two `text` cells it now says hidden, as the read does.
22+
23+
**`engine.aggregate` answers that no seam lowers.** A `{ $field }` referent is per row, so no seam can lower it. These positions are now compared as written:
24+
25+
- two declared `text` columns of one class, at a per-aggregation `filter` or between two `having` group columns: `'2026-01-05 noon'` against `'2026-01-05'` is no longer counted or kept, which is what the same comparison answers in a `where`;
26+
- the pairs the class rule cannot judge because a side has no declaration: an object the registry does not declare, and an audit-opt-out object's row-carried `created_at` / `updated_at` against a `date`. An instant on the due day is no longer counted against that bare day;
27+
- a direct `applyInMemoryAggregation` call, which applies no class rule.
28+
29+
**The remedy.** Compare a `datetime` with a `datetime` and a `date` with a `date`. A `datetime` against a calendar day has no single answer across SQL and memory, and a declared pair of the two is already refused. A number compared with a day string has no answer at all: compare a number with a number. A caller that evaluates a filter on a `datetime` column without passing a seam lowers it first with `lowerFilterCondition(filter, { isDatetimeColumn })` to get the whole-day reading.
30+
31+
**Unchanged.** A `check` on a declared `date`, `datetime` or `time` column, a `{ $field }` pair of two `date` or two `datetime` columns (with or without `addDays`), a full-ISO bound, `$gte` / `$gt` / `$lt` and `$eq`.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
fix(plugin-security)!: a row-level policy that compares a numeric column with a comparand that is not a number is refused at the RLS compile seam, read and write alike, as the engine's `where` refuses the same comparison
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a compiled policy comparand at the RLS compile seam, the same comparand the engine's where door already refuses: no authorable key, spelling, export or stored shape moves. RowLevelSecurityPolicySchema and every permission set parse and save as before, the predicate's text is untouched, @objectstack/plugin-security exports the same names, and no stored row is read or rewritten. Which number the author meant is not something a ledger entry can decide, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand's type and this diff adds none (not registered / already-registered); and the change is runtime behaviour plus one ADDITIVE optional member (number) on the published RlsFieldGuard type, with no published interface or type narrowed or removed (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows which row-level policies the RLS compile seam hands to its two consumers, the read and the write check. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed. One published type gains a member: `RlsFieldGuard`, the type of the optional `fieldGuard` argument of the root-exported `RLSCompiler.compileFilter`, gains the optional `number` member (each declared column's `type`, and a formula's `returnType`). It is an additive optional member, not a change of what is accepted.
12+
13+
**What was accepted before.** A policy such as `record.amount <= '9999-12-31'` on a `number` column compiled, and its `using` and `check` both reached their consumers unjudged. Measured through `ObjectQL.insert` and `SecurityPlugin` on `SqlDriver` (better-sqlite3), as a member: the write of `amount: 5` was admitted (`@objectstack/formula`'s deleted whole-day copy read the number as an instant), and the read showed the stored row, because SQLite orders an integer before any text. That read was measured on SQLite only; PostgreSQL was not run for this change. The same comparison in a caller's `where` is refused `INVALID_FILTER` / 400 by the engine's number-comparand door.
14+
15+
**What is refused now.** The seam runs the spec's number-comparand verdict (`numberComparandDoorVerdict`, `@objectstack/spec/data`), the one the engine's `where` door consults, on every compiled policy filter, after the shape door and before the comparand-type door. On a column the object declares numeric, a comparand that is not a number (a string the platform's numeric grammar does not read, such as `'9999-12-31'` or `'abc'`, a boolean, a `Date` or a list) drops the policy through the existing fail-closed route: the read is filtered by the deny sentinel and returns no rows, the write is refused `PERMISSION_DENIED` / 403, and a WARN line names the policy, the clause and the comparand. The line's detail is written for the clause it refused: for `check`, which the write check evaluates in-process, it names no driver bind. A granting sibling policy still grants.
16+
17+
**Narrowed, as in `where`.** A numeric string (`'10'`, `'1e3'`) is replaced by the number it names before either consumer runs. So `record.amount == '10'` now matches a stored `10` on the write check, which compared the text with the number and refused it, while the read showed the row.
18+
19+
**The remedy.** Compare a numeric column with a number: `record.amount <= 9999`, not `record.amount <= '9999-12-31'`.
20+
21+
**Unchanged.** A numeric literal, a column that is not numeric, a `{ $field }` reference, and an object whose declaration cannot be read (nothing is judged without one).

‎docs/design/predicate-compilation-convergence.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ A ruling that adds a new predicate *kind* or a dialect construct still costs one
358358
| F4 reference matcher | — (no production caller) | no | keep as test oracle, or retire (D6) | 20 test files | — |
359359
| F5 cube face | the new `normalizeFilters` door | **yes**: doors + lowering, and widen `$or` / `$not` / `$null` | whole-day calls (5 sites) | its own suites; not in `check:driver-conformance` | an accept-set widening, so a changeset with its Clause-② line |
360360
| F6 `driver-mongodb` | the engine seam | no | whole-day calls (4 sites) | mongodb filter-logic, text, temporal, comparand-type | the server answer was NOT MEASURED here |
361-
| F7 `formula` | the RLS compile seam (policies); the engine (via F8) | no | `lteBound` (2 sites), only under D4 (b): F7 is also a public evaluator (explain engine, test doubles) | matches-filter not-null-safe, or-semantics, temporal | — |
361+
| F7 `formula` | the RLS compile seam (policies); the engine (via F8) | no | **retired** (#21242): `lteBound` and its 2 sites are deleted; a bound that reaches F7 unlowered is compared as written (D-D1 item 5) | matches-filter not-null-safe, or-semantics, temporal | — |
362362
| F8 `having` | the engine seam | no | whole-day calls (4 sites) | having filter-logic, text, temporal | — |
363363
| F9 read scope | the new call at its entry | **yes**: the lowering call | polarity quartet (67 lines), `assertDefinedComparands` | read-scope-sql conformance, read-scope not-null-safe | fixes §2.5 item 1, which changes the rows a scope returns, so it needs a changeset |
364364
| F10 `where` → tree | the analytics `where` door | no | polarity quartet (90 lines), `assertDefinedComparands`, the `$between` lowering | native-sql filter-logic, text, temporal | echo fidelity (R6) |

‎packages/formula/src/matches-filter-field-reference-offset.test.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,39 @@ describe.each(PAIRS)('[#14104] addDays on the $label pair', ({ target, base }) =
141141
});
142142

143143
describe('[#14104] the shape of the shifted value', () => {
144-
it('a calendar day stays a calendar day, so a `$lte` still covers the whole shifted day', () => {
145-
// A `date` deadline shifted by one day is 03-02 (the whole day), so a
146-
// datetime completion at 23:59 on 03-02 is still on time — the #3777
147-
// half-open rule the bare bound already has.
148-
const rec = { done: '2026-03-02T23:59:00.000Z', due: '2026-03-01' };
144+
it('a calendar day stays a calendar day, so a stored day on the shifted day is on time', () => {
145+
// A `date` deadline shifted by one day is 03-02, a calendar day. A `date`
146+
// completion is stored as its calendar day (the RLS write check judges the
147+
// stored form), so completing on 03-02 is on time under `$lte`, and the
148+
// unshifted deadline 03-01 is missed.
149+
const rec = { done: '2026-03-02', due: '2026-03-01' };
149150
expect(m(rec, { done: { $lte: { $field: 'due', addDays: 1 } } })).toBe(true);
150151
expect(m(rec, { done: { $lte: { $field: 'due', addDays: 0 } } })).toBe(false);
151152
});
152153

154+
it('[#21242] an instant against a shifted calendar day is compared as written, and refused once the columns are declared', () => {
155+
// A `datetime` completion against a `date` deadline is a cross-class pair.
156+
// This face kept a whole-day copy that read the shifted day 03-02 as
157+
// "through 03-02" here; it is deleted (ADR-0053 D-D1 items 5 and 9), so
158+
// the pair is compared as written: the instant's text sorts above the bare
159+
// day it falls on.
160+
const rec = { done: '2026-03-02T23:59:00.000Z', due: '2026-03-01' };
161+
expect(m(rec, { done: { $lte: { $field: 'due', addDays: 1 } } })).toBe(false);
162+
expect(m(rec, { done: { $lte: { $field: 'due', addDays: 2 } } })).toBe(true);
163+
// Handed the declaration, as the RLS write check hands it, the pair is
164+
// refused before any comparison, as driver-sql's read refuses it.
165+
let err: { code?: unknown; status?: unknown } | undefined;
166+
try {
167+
m(rec, { done: { $lte: { $field: 'due', addDays: 1 } } }, {
168+
fields: { done: { type: 'datetime' }, due: { type: 'date' } },
169+
});
170+
} catch (e) {
171+
err = e as { code?: unknown; status?: unknown };
172+
}
173+
expect(err?.code).toBe('INVALID_FILTER');
174+
expect(err?.status).toBe(400);
175+
});
176+
153177
it('an instant keeps its time of day', () => {
154178
const rec = { done: '2026-03-03T12:00:00.000Z', due: '2026-03-01T12:00:00.000Z' };
155179
expect(m(rec, { done: { $eq: { $field: 'due', addDays: 2 } } })).toBe(true);

‎packages/formula/src/matches-filter-temporal-conformance.test.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,22 @@
99
* prevent. Adding a case there adds it here.
1010
*
1111
* This evaluator is type-blind: it sees a bare record with no schema, so both
12-
* the `datetime` and `date` cases run against the raw string values. That both
13-
* kinds pass unmodified is itself the assertion — it is what lets the rule be
14-
* applied without a field-type lookup on this surface.
12+
* the `datetime` and `date` cases run against the raw string values.
13+
*
14+
* [#21242 · ADR-0053 D-D1 items 5 and 9] It keeps no whole-day copy of the
15+
* bare-day upper bound any more. What reaches it is what a TYPED seam hands
16+
* it: the RLS compile seam lowers every policy filter with the shared
17+
* `lowerFilterCondition`, reading the object's declared `datetime` columns, so
18+
* each case's filter runs through that lowering here, against the declared
19+
* field map below. The expected rows are the shared table's, unchanged: the
20+
* whole-day cells are answered by the lowered filter, as on every seam-fed
21+
* face, and the `date` column is not lowered, because its stored calendar-day
22+
* text orders exactly as the day.
1523
*/
1624

1725
import { describe, it, expect } from 'vitest';
1826
import {
27+
lowerFilterCondition,
1928
TEMPORAL_CASES,
2029
TEMPORAL_ROWS,
2130
TEMPORAL_TIME_CASES,
@@ -24,10 +33,20 @@ import {
2433

2534
import { matchesFilterCondition } from './matches-filter';
2635

36+
/** The declared field map a typed seam reads for the shared rows. */
37+
const CONFORMANCE_FIELDS: Readonly<Record<string, { type: string }>> = { at: { type: 'datetime' }, on: { type: 'date' } };
38+
39+
/** What the RLS compile seam hands this face: the case's filter, lowered on the declared `datetime` columns. */
40+
const lowered = <T,>(filter: T): T =>
41+
lowerFilterCondition(filter, {
42+
isDatetimeColumn: (column) =>
43+
Object.prototype.hasOwnProperty.call(CONFORMANCE_FIELDS, column) && CONFORMANCE_FIELDS[column]!.type === 'datetime',
44+
});
45+
2746
describe('matchesFilterCondition — temporal conformance', () => {
2847
for (const c of TEMPORAL_CASES) {
2948
it(c.name, () => {
30-
const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, c.filter)).map((r) => r.id);
49+
const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, lowered(c.filter))).map((r) => r.id);
3150
expect(got, c.note).toEqual(c.expected);
3251
});
3352
}
@@ -64,7 +83,7 @@ describe('matchesFilterCondition — temporal conformance on a native-writer pos
6483

6584
for (const c of TEMPORAL_CASES) {
6685
it(c.name, () => {
67-
const got = nativeRows.filter((r) => matchesFilterCondition(r as any, c.filter)).map((r) => r.id);
86+
const got = nativeRows.filter((r) => matchesFilterCondition(r as any, lowered(c.filter))).map((r) => r.id);
6887
expect(got, c.note).toEqual(c.expected);
6988
});
7089
}

0 commit comments

Comments
 (0)