Repository navigation
Commit 7aab759
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
File tree
- .changeset
- docs/design
- packages
- formula/src
- objectql/src
- plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
361 | | - | |
| 361 | + | |
362 | 362 | | |
363 | 363 | | |
364 | 364 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
144 | | - | |
145 | | - | |
146 | | - | |
147 | | - | |
148 | | - | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
149 | 150 | | |
150 | 151 | | |
151 | 152 | | |
152 | 153 | | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
153 | 177 | | |
154 | 178 | | |
155 | 179 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
13 | | - | |
14 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
15 | 23 | | |
16 | 24 | | |
17 | 25 | | |
18 | 26 | | |
| 27 | + | |
19 | 28 | | |
20 | 29 | | |
21 | 30 | | |
| |||
24 | 33 | | |
25 | 34 | | |
26 | 35 | | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
27 | 46 | | |
28 | 47 | | |
29 | 48 | | |
30 | | - | |
| 49 | + | |
31 | 50 | | |
32 | 51 | | |
33 | 52 | | |
| |||
64 | 83 | | |
65 | 84 | | |
66 | 85 | | |
67 | | - | |
| 86 | + | |
68 | 87 | | |
69 | 88 | | |
70 | 89 | | |
| |||
0 commit comments