Commit 2c31070
Fixes #20347
Clause-②: yes (narrowing)
The spec half of the #20347 triage split (`5862073027`), dispatched on
claim `5863797885`. Base `eee09742`, head `bef47d1d`. The engine half is
#20355, which stays open and reads the export this PR adds. The
changeset declares `Clause-②: yes (narrowing)`, BREAKING, `minor` on
both `@objectstack/spec` (new exports, a widening) and
`@objectstack/lint` (a new authoring refusal, a narrowing).
## What changes
- **One classification, exported once**
(`packages/spec/src/data/filter-cross-field-comparison-class.ts`,
re-exported from `@objectstack/spec/data`, beside
`filter-text-operator-declared-type.ts`).
- Six classes (`CROSS_FIELD_COMPARISON_CLASSES`: `numeric`, `text`,
`boolean`, `date`, `datetime`, `time`) and three families with none
(`CROSS_FIELD_NO_CLASS_REASONS`: `list-or-object`, `file`, `formula`).
- `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType`
member exactly once, by reference to the existing `field-value.zod.ts`
sets. Nothing is re-listed.
- Two pure verdicts. `crossFieldColumnVerdict(field)` answers one
declared column; `multiple: true` on a multi-capable type holds a list.
`crossFieldComparisonVerdict(left, right)` answers two: `comparable`,
`cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`.
- It is lifted case for case from driver-sql's module-private
`crossFieldComparisonClass` (the #5222 boundary). `sql-driver.ts` is
untouched: #20355 rewires it, and PR #20372 holds that file.
- **Parity with driver-sql, run against both**
(`packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts`).
One object declares every `FieldType` member (49), plus the 6
multi-capable members flagged `multiple: true`. Every ordered pair (55 ×
55 = 3,025) is compiled as `{ a: { $eq: { $field: b } } }` on a real
`:memory:` SQLite driver. The driver's admit or refuse must equal
`crossFieldComparisonVerdict(a, b) === 'comparable'` on every pair. A
refusal counts only in the cross-field boundary's own withheld
`INVALID_FILTER` / 400 form (`withheldFilterDiagnosticOf` non-null),
never by prose.
- **The authoring door** (`packages/lint`).
- `validateRlsPredicateEnforceability` gains a cross-class arm.
`crossClassComparisons` reads the lowered filter's `{ $field }` sites
against the declared field map. It reports `rls-predicate-unenforceable`
for every comparison whose two columns are not `comparable`: `==`, `!=`,
`>`, `>=`, `<`, `<=`, either side, under `!` too.
- It covers `using` and `check` on every operation.
- `validateSharingRuleEnforceability` reads the same function and
reports `sharing-rule-unlowerable-condition` on a sharing rule's lowered
`condition`.
- A comparison against a list or an object stays the existing #19886
arm's finding, so no comparison is reported twice. The new arm runs
ahead of the engine-judge pass, like the list arm: one defect, one
finding.
- The finding names each comparison, each column's declared type and
class (or why it has none), and the clause's measured run-time
consequence. The hint lists every class with the declared types it
holds, derived from the spec table.
## Measured before (lint as on `main`), then after
Real `os validate` (`packages/cli/bin/run-dev.js validate` on a probe
stack), plus the real plugin-security + ObjectQL on driver-sql
(`better-sqlite3` `:memory:`, one RLS policy on a `text` / `number` /
`image` / `formula` object).
| predicate | `os validate` before | `find` (`using`) | insert (`check`)
| insert (`using` as check) | by-id update / delete (`using`) | `os
validate` after |
|:--|:--|:--|:--|:--|:--|:--|
| `record.status != record.amount` (text vs number) | valid, exit 0 |
`INVALID_FILTER` / 400 | admitted, stored | admitted, stored | 403 / 403
| `rls-predicate-unenforceable`, exit 1 |
| `record.status != record.photo` (text vs image) | valid, exit 0 | 400
| admitted, stored | admitted, stored | 403 / 403 | refused, exit 1 |
| `record.status != record.is_open` (text vs formula; the card's NOT
MEASURED cell) | valid, exit 0 | 400 | admitted, stored | admitted,
stored | 403 / 403 | refused, exit 1 |
| `record.amount > record.status` (number vs text) | — | 400 | 403 (JS
`5 > 'open'` is false) | 403 | 403 / 403 | refused (lint unit and door
pins) |
| control `record.status != record.note` (text vs text) | valid, exit 0
| rows `[r1]` | admitted | admitted | updated / deleted | valid, exit 0
|
The `check` rows on `insert` read the same at `os validate`: valid
before, `rls-predicate-unenforceable` after. Sharing-rule conditions,
measured at the real `os validate`, first with the arm ablated (the
before-state) and then restored: `record.status != record.amount` and
`record.status != record.photo` went from valid (exit 0) to
`sharing-rule-unlowerable-condition` (exit 1). The control
`record.status != record.note` stayed valid. At run time the seeded
rule's criteria query meets the same driver-sql refusal the list-holding
class meets (#20375 measured that path).
The write-check answer is whatever JavaScript's comparison of the two
raw values gives, so the permissive side of the policy is the write.
That half is #20355's.
## Census (expected 0): 0
A script over `git ls-files examples packages` (tests, fixtures, docs,
generated bundles excluded; 3,140 files at `bef47d1d`) extracts every
`using` / `check` / `condition` string literal: 163. It lowers each
through the real `compileCelToFilter` (RLS through `sqlPredicateToCel`
first); 105 lower. It then lists every `{ $field }` comparison: 2.
- `examples/app-showcase/src/data/hooks/index.ts:88`: `record.spent >
record.budget`, a hook condition, both `number`.
- `packages/lint/scripts/check-doc-formula-expressions.mjs:1396`:
`record.a > record.b`, a gate fixture.
Neither is an RLS predicate or a sharing-rule condition, and both are
same-class. The only programmatic predicate constant is
`OWNERSHIP_FLOOR_PREDICATE` (`created_by == current_user.id`), which is
not field-to-field. So no shipped policy or sharing condition moves, and
nothing re-grades to p1. The cloud repository was not in this session:
NOT MEASURED.
## Ablation (one-time proof, committed state `bef47d1d`)
Two ablations, both run from the committed state `bef47d1d`, each
through `scripts/ablation-replace.mjs`. That tool landed each mutation
(anchor count 1 to 0, blob changed) and restored it (the blob equals
`HEAD`, and `git diff HEAD` is empty). A shell `trap` re-checked each
restore by hash. The direction observed is the normal one: red.
1. **The lint arm.** The guard line in `crossClassComparisons` was
replaced with an unconditional `continue`, so the arm reports nothing.
`ablation-dist-preflight` found the marker in 4 built
`@objectstack/lint` files, so the mutation reached the `dist/` the CLI
consumes.
- lint unit, the four cross-class and list-holding files: **525 failed /
485 passed** of 1,010. Restored: **1,010 / 1,010 passed**.
- CLI integration `rls-policy-authoring-admission.test.ts`: **6 failed /
33 passed**. The 6 are exactly the new REFUSED rows. Restored: **39 / 39
passed**.
- Real `os validate`, 9 cells. Ablated: all nine exit 0 with no finding,
which is the before-state, sharing cells included. Restored: the 3 RLS
`using` cells, the 2 RLS `check` cells and the 2 sharing cells exit 1,
each with exactly one finding; both controls exit 0.
- On restore, `ablation-dist-preflight --absent` passed its `dist/`
reading (the marker is absent from all 14 built files). Its tree reading
exited 3 only because two untracked scratch files were present at that
moment; both are deleted now.
2. **The driver half of the parity pin.** Temporarily, never committed:
in `sql-driver.ts`'s `crossFieldComparisonClass`, `if (type === 'time')
return 'time'` was changed to return `'datetime'`. The parity test
imports driver source, so no build was needed. Result: **2 failed / 54
passed**. The two are `f_datetime` and `f_time`, naming exactly
`f_datetime vs f_time: spec says cross-class, driver admitted` and its
mirror. Restored: **56 / 56 passed**, blob equal to `HEAD`.
## Tests (at `bef47d1d`)
All at `bef47d1d`, after the last commit, on a shared box.
- `@objectstack/spec`
- `vitest run --project local src/data`: 103 files, **3,458 passed**, 1
todo. The new classification test contributes 19.
- `typecheck` (tsc, scripts and the test layer): exit 0.
- `@objectstack/lint`
- `pnpm test`: 115 files, **5,314 passed**.
- `typecheck` (with the test layer): exit 0.
- `@objectstack/driver-sql`
- The parity test plus the two existing cross-field suites
(`sql-driver-cross-field-reference`,
`sql-driver-cross-field-conformance`): **221 passed**, 2 skipped. The
parity test alone: 56 passed, one test per probe column (55 × 55 pairs),
plus the coverage pin.
- `typecheck`: exit 0.
- `@objectstack/cli`
- `--project integration test/rls-policy-authoring-admission.test.ts`,
the only CLI file touched (integration tier): **39 passed**, 9 of them
new.
- `typecheck`: exit 0.
- The unit tier is declared to CI: no CLI source file and no unit-tier
file changed.
- Real `os validate` over the examples: `app-crm`, `app-multi-package`
and `app-todo` exit 0, with 0 `rls-predicate-*` / `sharing-rule-*`
findings. `app-showcase` is NOT MEASURED this way: its config imports
`@objectstack/connector-mcp`, which is outside this worktree's build
closure. Its security files are in the text census above.
- Spec generated artifacts: `check:generated` named `api-surface/` and
`export-origins/` stale, both additive only. Both were regenerated with
their generators, and `check:api-surface` and `check:export-origins` are
green.
- Gates: `dispatch-gates --ran` accounts for 88 of 88 derived families.
86 exited 0. Two are NOT MEASURED, and CI owns both:
- `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET: it needs a
full `pnpm build`.
- `check:type-check-debt`: its `--re-measure` passed the 400 s local
timeout. The kill left `packages/spec/dist` without declarations, so the
spec was rebuilt (64 `.d.ts`) before every lint, driver-sql and cli
reading above.
- The derivation warned that the tree is behind `origin/main` by one
family file (`scripts/cross-package-test-inputs.mjs`).
`check:cross-package-test-inputs` was run from this tree and is green.
## Decisions
- **Formula has no class, whatever its `returnType`.** That is
driver-sql's answer: a formula is virtual, with no column to reference.
The text-operator door reads `returnType`, but a column-to-column
comparison needs a column on both sides. The measured runtime agrees
(400 on the read).
- **The file family is refused by name.** That is driver-sql's answer
too (the ADR-0104 dual-encoding window), so `image == image` is refused
as well.
- **A type outside `FieldType` is `unjudged`.** A driver's aliases
(`integer`, `object`, the absent-type `string` default) stay layered in
the driver, as `field-value.zod.ts`'s header says every alias does.
#20355's rewire keeps those aliases above the export. At the door, an
out-of-vocabulary type is Zod's to refuse, and the arm reports nothing.
- **Registry-injected columns are judged** by the definition the
registry provisions. `record.status != record.created_at` is refused
(text vs datetime), because the driver sees the same column. `id` has no
definition in the graph, so it is not judged.
- **Same rule ids as the list arm.** The author's edit is the same kind:
rewrite which two columns are compared.
- **Two existing pins changed**, one in each #19886 list-holding test.
"A single-valued `file` field is one value" asserted *no finding at all*
for `record.status != record.subject` with `subject` a single `file`.
driver-sql refuses that comparison (the file family has no class), so
the no-finding reading was never the runtime's. Each pin now asserts
that the list arm stays silent and the class arm refuses once. `select`
/ `lookup` / `user` keep the no-finding pin.
- **File surface beyond the claim, both required by the dispatch.** The
driver-sql parity test: the classification can only be run "against
both" there, and it adds no line to `sql-driver.ts`. And
`validate-sharing-rule-enforceability.ts` plus its tests: the direction
covers sharing conditions, and that rule is where they are judged.
## Acceptance notes
- `listHoldingComparisons` still reads `STRUCTURED_JSON_TYPES` +
`isMultiValueField` directly. That is the same family as the export's
`list-or-object` reason, and the two agree by construction (pinned in
the spec test), but it is two spellings. Converging it onto
`crossFieldColumnVerdict` is the natural edit for whoever next touches
that function (carrier: #20355 or the next #19886-family change). Noted,
not filed.
- The metadata save door for a `sharing_rule` does not run
`validateSharingRuleEnforceability`, as #20375 recorded. The new sharing
arm therefore shows at `os validate` / `os build` / `os lint` only, like
the list arm. Noted, not filed.
- The `check` consequence sentence describes today's write check, which
admits by raw comparison. When #20355 moves the write check onto this
classification, that sentence changes in the same change (a code comment
at `crossClassConsequence` says so).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent dcd3bce commit 2c31070
14 files changed
Lines changed: 1583 additions & 6 deletions
File tree
- .changeset
- packages
- cli/test
- drivers/driver-sql/src
- lint/src
- spec
- api-surface
- export-origins
- src/data
Lines changed: 25 additions & 0 deletions
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
66 | 67 | | |
67 | 68 | | |
68 | 69 | | |
| |||
293 | 294 | | |
294 | 295 | | |
295 | 296 | | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
Lines changed: 128 additions & 0 deletions
| 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 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
0 commit comments