Skip to content

fix: preserve inherited lens limits in narrowing and windows - #13

Merged
agreenspan merged 4 commits into
mainfrom
claude/workbench-audit-20260906
Sep 7, 2026
Merged

agreenspan merged 4 commits into
mainfrom
claude/workbench-audit-20260906

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Delegated lens authors could reference ancestor-hidden fields or enum values from nested/model-default/source where clauses even though the equivalent root clause was rejected. The old model-local check also rejected valid nested relation conditions and accepted nonexistent dotted-path tails.

Validate these clauses with the existing lens-aware rule checker, anchored at the declaring model and parent path. Model defaults also respect the parent's explicit visits. Bare comparison references retain the real lens root; current-element references are checked at the declaring visit. This removes the separate condition-tree walker and preserves the ability to filter on fields hidden by the declaring layer itself.

Relation grants now run before window selection for windowed conditions, as they already did for all. An excluded row can no longer occupy a take slot and change an any, none or aggregate answer. Relations inside an existing window filter also retain their scope. Unsupported conditionless shapes keep their existing fail-closed behavior; Prisma/SQL compilation still rejects windows it cannot preserve.

Validation: bun run check passes, including 1,278 tests. Regressions cover hidden fields/values, related-model validation, comparison references, path-specific restrictions, window displacement and compiler rejection. No compiler semantics or public API were added. The first consumer is the template marketing workbench's delegated lens authoring and execution pipeline.


Also in this branch: fix(toPrisma): fold boolean constants through AND/OR/NOT

true compiles to {}, which Prisma reads as match-all only at the top level and under AND. Inside OR Prisma drops the arm, so { any: [customerId = mateo, true] } compiled to OR: [customerId, {}] and returned zero rows for a customer scoped to sofia while check() passed every row (reproduced against real Prisma SQLite; NOT: {} is likewise ignored, not negated).

The logical builders now fold constants on compiled output instead of nesting them: a true arm absorbs an OR, a false arm absorbs an AND, either drops out of the other, and NOT of a constant is the other constant. This also covers constants that arrive indirectly (all: [], any: [], atLeast 0, and the bridge over-fetch sentinel, which under any now over-fetches the disjunction instead of being dropped and under-fetching). Match-nothing is Prisma's documented { OR: [] } everywhere; the { id: null } AND { id: { not: null } } sentinel is gone. Arms are compiled exactly once, in order, before folding, so groupBy step refs stay positional and no step is duplicated. The bridge short-circuit in if/then/else is unchanged.

Validation: bun run check passes (1,375 tests). New test/toPrisma.booleanFold.test.ts covers the reproducer, every constant position under AND/OR/if/then/else, a no-nested-constant invariant across 80 generated shapes, bridge-under-any over-fetch, and groupBy step-state coherence. Two lens.bridge expectations that pinned the under-fetching OR: [x, {}] shape were updated. A 14-rule differential against real Prisma 6 SQLite (check() vs executed toPrisma where) agrees on every row.

@agreenspan
agreenspan marked this pull request as draft September 6, 2026 23:01
The where validation added in the previous commit built a synthetic Lens
anchored at the where's own model, so a bare `path` comparison ref — which
check() resolves at the ROOT context and applyLens injects unchanged into a
to-many grant — was gated at the related model instead of the lens anchor.
A legal grant like `Order.where = { contactEmail equals path: 'email' }`
(Customer.email) was rejected, and a ref to a related-only column was
accepted although it resolves to nothing at the root.

checkRule now exposes an internal `checkConditionAtVisit` that starts the
existing visit at (mapName, modelName, relPath) on the REAL parent policy:
`field` and `$.` refs resolve at the visit, a bare `path` at the anchor.
narrowing.ts validates every where through it — relation nodes at their
relPath, root at [], model defaults at the off-path visit plus each declared
parent visit (OFF_PATH moves to policy.ts and is shared with exposedSurface).

applyLens: `filterFirst` uses `hasWindow`, the compilers' notion of a window,
so an empty `orderBy` keeps the AND injection that still compiles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan marked this pull request as ready for review September 6, 2026 23:05
agreenspan and others added 2 commits September 6, 2026 23:19
`true` compiles to `{}`, which Prisma reads as match-all only at the top
level and under AND. Inside OR Prisma drops the arm — `OR: [x, {}]` is just
`x` — so `{ any: [customerId = mateo, true] }` returned zero rows for a
customer scoped to sofia (verified against Prisma SQLite) while check()
passed every row. `NOT: {}` is likewise not NOT(true).

The logical builders now fold constants on compiled output instead of
nesting them: a true arm absorbs an OR, a false arm absorbs an AND, either
drops out of the other, and NOT of a constant is the other constant. This
covers constants that arrive indirectly too — `all: []`, `any: []`,
`atLeast 0`, and the bridge over-fetch sentinel, which under `any` now
over-fetches the disjunction instead of being dropped and under-fetching.
Match-nothing is Prisma's documented `{ OR: [] }` everywhere; the
`{ id: null } AND { id: { not: null } }` self-contradiction sentinel is gone.

Arms are still compiled exactly once, in order, before folding, so groupBy
step refs stay positional and no step is duplicated; an arm that folds away
leaves its step behind, which is executed and ignored. The bridge
short-circuit in if/then/else is unchanged — NOT of the over-fetch sentinel
must stay unknown, not become match-nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan merged commit 0346333 into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant