Skip to content

json-rules 3.4.0: relations off by default, turned on by the relation object; context removed; lensVisit - #26

Merged
agreenspan merged 12 commits into
mainfrom
feat/4.0-declared-relations
Oct 8, 2026
Merged

agreenspan merged 12 commits into
mainfrom
feat/4.0-declared-relations

Conversation

@agreenspan

@agreenspan agreenspan commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Why

Aron: "I thought you had to declare relationships... The lens defines things. If you don't, then you have effective access to the full map that any model would have access to... including recursive back-and-forth traversals." This PR restores the May 2026 design: relations are fields, and each delegate works on its parent's projection (CLAUDE.md, Lens rulings). It also removes context as a second channel for caller values. Final spec approved by Aron on 2026-10-08.

Relations: fields, off by default

  • Off until turned on. A bare lens reads its root columns only. The first narrowing over the base turns a relation on through the relation object:
    • along the path: root.relations.org;
    • at the model default: mapDefaults…models.Org.relations.users, wherever Org is visited. ModelDefaultNarrowing gains relations, and nested relation objects are allowed.
    • Never through picks, which names columns only; a relation in picks is wrong_kind.
  • One answer for a relation that is off. It reads as hidden everywhere:
    • the gate gives not_in_lens, with a hint to turn it on with relations;
    • walkLensPath and readLensValue report hidden;
    • { lens } compiles throw;
    • projectLens, toLensSelect and projectRows leave it out.
  • Layers only get darker. exposed₁ = layer-1 turn-on ∧ ¬layer-1 hide; exposedₖ = exposedₖ₋₁ ∧ ¬layer-k hide.
    • Policy.origin pins layer 1, so a call site that filters the chain can't promote a different layer to first.
    • A later layer may omits a relation (allowed beside picks), or restate one its parent shows to narrow that hop. A restatement hides nothing else.
    • Naming a relation the parent doesn't show is not_visible.
  • Grants (the oracle). Layer 1's wheres and source-eligibility wheres may read any relation in the schema. A later layer's may read only what its parent exposes.
  • Recursion: model defaults grow a tree. From the anchor and every path spelled under root.relations, the first narrowing's model-default turn-ons are followed breadth-first. Each model is included at most once, at its nearest reach; ties go to the earlier parent, then to field declaration order. A model already on the spelled path is never re-entered. Anything else must be spelled, and every spelled node grows its own tree. Every posture walks this same tree, so they agree exactly and stay within spelled nodes × models visits. lensVisit resolves one path on demand.
  • Sources. A dotted label/groupBy may cross only relations shown at each projected visit; otherwise it is invalid_source and dropped. A source keyed on a relation is wrong_kind.
  • Fetch. toLensSelect/projectRows open exactly what is turned on, plus the columns grants read. The shallow fetch and the rules option are cut. A relation that shows no columns is selected by its key column only.

context removed

  • Caller values are binds. options.context is gone from CheckOptions, ToPrismaOptions and ToSqlOptions. timeZone is a string or a { bind }.
  • A bare path is a root-row column on every rail, gated and narrowed like a field. readContextRef and the compile-only gate and narrow variants are deleted.
  • check: reads the root row.
  • toSql: compiles a column. A substring, pattern or set operator against a column throws; the substring case used to emit a broken 'undefined%' parameter.
  • toPrisma: compiles a Prisma field reference.
    • The plan carries a { __field: { model, field } } sentinel; executePrismaPlan resolves it to <delegate>.fields.<col>. Prisma rejects the raw sentinel.
    • Supported only between columns of the same model at the same visit (a bare path at the root, or $. inside a relation filter), of exactly the same type, with equals/notEquals/lt/lte/gt/gte and no offset.
    • NULL handling follows IS [NOT] DISTINCT FROM, as check and toSql do.
    • Anything else throws with the reason. validateRule(rule, { target: 'toPrisma', map, model }) (it now takes the schema) and describeRule drop toPrisma for those rules up front.
  • Agreement. The three rails agree on the PGlite + Prisma 7 rails, including NULL rows (test/rails.columnRef.test.ts). The bind-deny test under a lens returns [1,3,4,5] on all three rails.

Edge decisions (flagged)

  1. Prisma text operators against a column are refused. A probe showed Prisma reads the column in contains/startsWith/endsWith as a LIKE pattern: org xyz contains plan _ matched. Case-insensitive column compares are refused too, because they would compile to ILIKE.
  2. Date rules with a column path are refused on Prisma. Only field operators are supported.
  3. Bad bound magnitudes now throw. A fractional or negative bound unit amount makes check and toSql throw, the way a bad literal does; a bad context value used to match nothing.
  4. Restating a recursive model default. A later layer restating it to narrow (e.g. Org.relations.parent { where }) is allowed as long as each parent visit of the model either shows the relation or has already crossed that edge.

Tests

  • bun run check: typecheck, biome, 2512 pass / 0 fail.
  • test/lens.relationsOn.test.ts holds the spec cases, each checked across the gate, { lens } compile, read, fetch and cut: the bare lens; on/off at each hop; the N1→N2 refusal; restatement hides nothing; omits that can't be undone; picks naming a relation; later-layer mapDefaults turn-ons; the oracle; edge-once recursion (spelled and multi-hop); sources; and bridges.
  • test/rails.lens.test.ts: fetch → projectRows(keepGrantColumns) → check(narrowRule) equals the database, including under recursion and for grants that read a relation that is off.
  • test/rails.columnRef.test.ts: three-rail column comparisons.
  • Every existing test now turns on the relations it uses and passes caller values as binds. Where a test's point was the old semantics, it now asserts the new one.

Round-4 review (4ac5598)

Each finding got a failing test first, in test/review.round4.test.ts.

  • H1 — a bare path in a non-root grant.
    • A bare value path reads the root row, so only root.where may hold one.
    • Inside a relation grant, a model default, or a source's eligibility where, it is now invalid_value_source in validateNarrowing.
    • Every posture (gate, narrowRule, { lens }, toLensSelect, projectRows, readLensValue, sources) throws instead of reading the related row.
  • H2 — forgeable sentinels.
    • Each Prisma step records where its own { __step } / { __field } refs sit (refs), and executePrismaPlan resolves only those locations. A forged sentinel in a value stays data.
    • A rule value holding a __step / __field key is refused at compile.
    • On all three rails: check and toSql treat the forged value as a literal, and toPrisma refuses it.
  • H3 — exponential recursion bound.
    • Model-once replaces edge-once: a model default never re-enters a model already on the path.
    • The enumerating walks dedupe by visit class: model, spelled path or "a model default", and incoming edge.
    • Perf tests: a 6-model all-on schema, a dense 5-model/20-edge schema, and a 5-model/40-parallel-edge schema each finish projectLens (both modes), toLensSelect, validateNarrowing and toSourceQueries in under 200 ms, with small output.
  • M1 — later-layer grant restriction was validation-only. A later-layer grant crossing a relation its parent doesn't show now throws at runtime too (LensRefusal). validateNarrowing still reports it.
  • M2 — column refs inside counting steps. A column comparison inside a count or relation-aggregate step is refused on Prisma at compile and in validateRule/describeRule. A bare path there never binds to the target model.
  • M3 — negated column compares. These are now supported rather than dropped:
    • settleLeaf keeps the column compare.
    • negate adds the comparison column's NULL arm.
    • if / all over a column compare compile, and the three rails agree.
  • L1 — stale docs. Fixed in types JSDoc, LENS.md (model defaults now take relations) and TIMEZONE.md (zone is a bind or a string).
  • Additions from the rules-builder 0.30 port:
    • lensVisit(lens, relationPath) (export, VERBS, README) agrees with projectLens by path at every key, and returns null for a relation that is off or a model that is re-entered.
    • projectLens by path now keeps a map's declared option labels and groups.

Flagged

  1. Visit classes vs. model-once. Under model-once, a visit's exposure depends on which models its path already holds. So a visit class's projection comes from its shortest path; other paths of the same class can expose less. The gate, read, lensVisit and fetch stay exact per path.
  2. Source eligibility where can't hold a bare path. This applies even at the root, because a source's where filters option rows, not the root row.
  3. Restating a model default requires the relation to be shown. A later layer may restate a model-default relation only if the parent shows it at one or more of the model's visits. A visit where following the relation would re-enter a model doesn't count against it.

Round-5 review (32b5ca8)

Each finding got a failing test first, in test/review.round5.test.ts and test/review.round5.columnFuzz.test.ts.

  • F1 + F2 (shared root cause). Model-default turn-ons now grow a tree per spelled node. The visit-class dedupe is removed, and shownVisits walks the exact tree.
    • The p1 / p2a / p2b / p2c repros now agree across every posture. For 2b, validateNarrowing and the runtime accept or refuse together.
    • Ties go to field declaration order, and spelling a path reaches a model another way.
    • Agreement fuzz: 120 random lenses over 4 models, with nested defaults, picks and omits. At every path: gate ⇔ lensVisit ⇔ projectLens ⇔ by-model ⇔ toLensSelect, and every path the gate admits is projected. The only allowance is the documented key-column fetch for a relation that shows no column.
    • Perf: 20 models × 4 relations and 30 models × 3 relations, all turned on — every API takes a few ms (budget 200 ms). Before this fix they took 16 s and 68 s.
  • F3 — a scope ref escaping its grant. A grant reads its own row. A scope ref that climbs out of it ($$. at its top) is scope_out_of_bounds in validateNarrowing and a LensRefusal in every posture. A $$ that stays inside the grant, one array down, is still its own row and allowed.
  • F4 — enum column compares.
    • An enum column compares only with an enum column of its own type, and only by equality. That compiles natively on SQL (no one-sided ::text cast) and on Prisma.
    • Ordered comparisons and enum↔text comparisons are refused on both compilers and in validateRule/describeRule.
    • The 558-shape column fuzz now runs as a test: supportedTargets matches what compiles with zero exceptions, and every compiled rail agrees with check.
    • The rails harness now loads org.users.posts, so check sees the same rows the compilers query.

Flagged (round 5)

  1. F3 allows a $$ that stays inside the grant. I refused only scope refs that climb above the grant's own row, rather than every $$.
  2. A later layer's model-default restatement must be shown at one or more parent visits of that model. Where the tree doesn't cross it, restating it narrows nothing.
  3. Off-path (model-intrinsic) visits take their model defaults' turn-ons with no tree applied. Only grant validation and model-source lookups read these visits.

Round-6 review (e7cdb34)

Each finding got a failing test first, in test/review.round6.test.ts.

  • R6-1 + R6-3 — one check for a later layer's grant, at the visits it applies to.
    • The check: the gate (checkConditionAtVisit) run over the parent's surface. It covers every hop and the column at the end, so a grant on a column the parent hides is refused too.
    • validateNarrowing runs it at the shown visits of the lens being composed, with the vetting step held back while it enumerates them. The first layer's grants still read the whole schema, including the model's own (off-path) visit.
    • Runtime: every posture runs the same function at each visit it resolves.
    • Visits outside the shown tree: to cover visits that a layer-1 grant or source crosses outside the tree, validateNarrowing also runs toLensSelect and sourcePlans on the composed lens and reports any LensRefusal.
    • Tests: the a/b/c/g repros agree between validation and runtime, plus a randomized check: 150 random two-layer lenses (more seeds were also run at 500 iterations each), where validateNarrowing.ok ⇔ no posture refuses.
  • R6-2 — a pointer dropped its layer for later layers. A source pointer now drops only its own layer's carried grants, via Policy.skipGrantsOf, without re-indexing the chain. Later layers' grants still read through the pointing layer, so L1's Comment.where deleted = false reaches L2's comments.any.
  • R6-4 — tree cache went stale on mutation. Model-default trees are now cached per API call (Policy.trees, built by resolvePolicy), never on the narrowing object. A narrowing mutated in place reads fresh on the next call.
  • R6-5 — presence could fetch a hidden column (ssn). A relation that shows no column is selected for presence by its key alone: the join key, else id, even if that key is hidden, the same way a grant's columns are fetched. It never picks another column. The fetch carries the key so the re-check can count rows, and a viewer's projection drops it. A model with no key is not selected, so presence on it can't be re-checked from the fetched rows (documented). Rails test: with posts at picks: [], fetch → re-check of posts any / none / atLeast 1 equals the database. The R6-5 repro still never selects ssn.
  • R6-6 — the gate admitted what compile refused.
    • narrowRule's re-root refusals are now LensRefusals.
    • validateRuleInLens refuses a rule that narrowRule can't narrow, with narrowRule's message. It also reports a grant the lens refuses on the rule's visits as an issue, instead of throwing.
    • validateNarrowing refuses a grant at a to-one visit that narrowRule can't re-root.
  • LOW — list column compares. A list column compares with another column only by membership: contains / notContains of a scalar column, which SQL compiles exactly. Every other comparison involving a list column is refused on both compilers and in validateRule / describeRule. The column fuzz now includes list leaves: 648 shapes, 0 bad.
  • Docs. A model default's nested relation object applies wherever its edge is crossed — a tree edge or a spelled one. The code already behaved this way; the docs now say so.

Flagged (round 6)

  1. Overridden (follow-up commit): a relation that shows no column is fetched by its key alone, hidden or not, so the re-check holds.
  2. R6-6 in validateNarrowing is stricter than the compile. It refuses an un-re-rootable grant at any shown to-one visit, even if no rule crosses that hop. Examples: a $. ref, or an array condition in an Org grant reached by org. validateRuleInLens is exact.
  3. A later layer's grant that never applies isn't checked. If the composed lens never visits a model, a later layer's grant on that model isn't checked (so it isn't refused). This matches the runtime, which never applies it.

Round-7 review (8b11242)

Each finding got a failing test first, in test/review.round7.test.ts.

  • R7-1 — validators threw on ordinary input.
    • Every refusal narrowRule raises is now a LensRefusal. That includes reading a to-many relation flat through a grant, and an out-of-bounds scope ref.
    • The validators report these as issues; the runtime postures throw them.
    • Tests cover the f1/f9 repros, plus a 300-iteration fuzz showing validateRuleInLens and validateNarrowing never throw.
  • R7-2 — the dry run was masked by unbound lenses. The composed-lens dry run is gone.
    • validateNarrowing now resolves every shown visit with the runtime's own vetting. It also resolves every visit that the grants, source wheres, labels and axes at those visits read through, found from readPaths.
    • Nothing is compiled, so an unbound lens validates the same as its bound runtime runs.
    • Tests cover f3/f3b: no other grant, a relative-date grant, and a bind grant. validateNarrowing refuses, and toLensSelect(bindLens(...), { now }) refuses too.
  • R7-3 — never-applied grants were refused. A path node's child visits are now limited to those the composed lens shows. The enum-inheritance chain is no longer vetted. Test: f5.
  • R7-4 — empty root select. A root that shows no column is now selected by its id, hidden or not; a viewer's projection drops it. Rails test: Prisma accepts the select and the viewer never sees id.
  • R7-5 — supportedTargets over-claimed toSql.
    • validateRule and describeRule now refuse toSql for a substring, pattern or set operator compared against a column. List membership is the one exception.
    • The shipped column fuzz now covers every operator: 1728 shapes, 0 bad.
  • PERF.
    • Model-default trees are cached on the first narrowing, keyed by a fingerprint of its model defaults plus each spelled node's own turn-ons and omits. Mutation safety from R6-4 is kept.
    • A later grant's check is memoized per grant, visit and parent chain within a call.
    • Spelled-heavy bench, 2337 paths: lensVisit 85 ms vs 66 ms at 32b5ca8 (1.3×); validateNarrowing 61 ms vs 40 ms.

Round-8 review (8edd426)

Each finding got a failing test first, in test/review.round8.test.ts and test/review.round8.fuzz.test.ts.

  • Structural fix: validateNarrowing runs the runtime's postures. The hand-built visit loop is deleted.
    • validateNarrowing now runs, in an inspect mode, the same code the runtime runs: projectLens by path and by model, lensVisit at every shown path, the source plans (what toSourceQueries and materializeSources plan), toLensSelect, and a rule reaching each shown visit, narrowed.
    • Inspect mode compiles nothing, so it reads no binding, clock or literal. Each LensRefusal becomes an issue. Any other error propagates, and the fuzz asserts none occurs.
    • Result: ok holds exactly when no posture refuses, by construction.
    • F1 (pointer, model-intrinsic visit) and F3 (the source plan's re-root) are now refused by both validation and runtime. Tests: a, d.
  • P1 — source wheres now narrow like rules (fail-open fixed).
    • Each source where is narrowed under the whole lens with narrowAt, the same as narrowRule narrows a rule.
    • Grants apply inside array conditions (in condition, or in filter under all, a window, or no condition) and on every hop and terminal relation of a dotted path. An option never comes through a row the lens hides.
    • Tests: c, a count with no condition, a terminal and hop relation, and 3-rail agreement on PGlite (check via materializeSources, Prisma and SQL via toSourceQueries + materializeSourceQuery).
    • The rails test surfaced a pre-existing bug, now fixed: the source query's SQL selected FROM "<Model>" while its joins used dbName.
  • F4 — label/axis visibility dropped chain layers. Fixed: the chain stays whole. Only the declaring layer's picks/omits are skipped, by index (skipHidingOf), like skipGrantsOf. Test: f (L1 label, L3 grant on org).
  • F5 — the tree cache ignored re-parenting. The cache key now includes the base lens's identity, its maps' identity, mapName/model, and the model defaults. Test: h. The cache fuzz shows 0 stale results for re-parent and root edits. In-place field-map mutation is still the accepted edge, now documented in LENS.md.
  • P2/P3 — plain Errors are now LensRefusals, and validation reports them.
    • The source planner's errors: an unguardable label/axis hop, a to-many hop with a grant, an empty sources: {}.
    • toLensSelect: a to-many relation's grant that is windowed (windowRewrite has no form) or counting (a count op or aggregate). This is checked from the grant's shape before anything compiles, so inspect and runtime agree regardless of values.
    • e (a flat to-many source where) now comes from narrowAt's own to-many refusal. Tests: e, k, and a counting grant.
    • The empty source previously made validateNarrowing throw. It is now an invalid_source issue at its position.
  • Fuzz: validateNarrowing.ok ⇔ the bound runtime (with now) never refuses.
    • The fuzz covers binds, relative dates, windows, aggregates, labels/groupBy, pointers and bridges. The runtime attempts include presence rules at each shown visit, which the gate admits.
    • In the suite: ≥ 2000 later layers per run, 0 mismatches (~4.7 s).
    • Offline: agree8 + presence over 16 seeds × 1500 iterations, before and after the perf pass. 4,498 valid first narrowings and 5,864 later layers (1,754 valid, 4,110 refused). 0 under-strict, 0 over-strict.
    • The malformed-lens fuzz ran 7,500 times with 0 throws.
    • Check against 8b11242: the same suite fuzz finds 3 mismatches there.
  • Perf.
    • One call memo now holds resolved visits (with vetting deferred, so an unvetted resolve is reused by a vetted one), visit places (trail + turn-ons), model fields, and grant refs (one walk for bare/escaping).

    • Ancestor grants are carried once per path.

    • Spelled-heavy bench (dense 25×6, spelled to-one depth 4, 17,320 visits), against 8b11242:

      Measure 8b11242 Now
      lensVisit over every path, L2 0.87 s 0.70–0.82 s
      validateNarrowing, L2 (now running five postures) 0.45 s 0.49–0.53 s
      validateNarrowing, L1 1.15–1.68 s 0.72–0.74 s
      Grants on every model: lensVisit 1.70 s 1.25 s
      Grants on every model: validateNarrowing 1.01 s 0.67 s
      Grants on every model: toSourceQueries 0.72 s 0.48 s

Flagged (round 8)

  • b now refuses in both validation and runtime. It does not "accept in both" as the review expected. P1 makes the source carry every layer's grants on the relations it reads. L2's Post grant now applies inside L1's posts any … source, and is checked there. It reads secret, which L1 hides on Post, so the runtime refuses and validation agrees. When L1 shows secret, both accept, and the options carry the grant (second b test). The alternative is to apply a later layer's grants only at visits its parent shows. That would let options come through rows the later layer hides, so I didn't take it.
  • Source where hop grants now take narrowAt's form (underHopGrants). A hop shared between the where and an axis or label is guarded by each; the duplicate is harmless. The expectation in sources.compositeGroupBy changed accordingly.
  • Follow-up (cf938d4): the source planner now refuses a windowed option query by its shape, using the same check toLensSelect uses. This covers a window in the source's own where and a windowed grant carried into it. Counting steps are still allowed, because the option query runs them. The refusal is a LensRefusal, so validation reports it and both materializers refuse alike; materializeSources would otherwise have handled the window through check(). Tests are in R8-7. The fuzz now generates windowed source wheres and asserts that no plain windowing Error escapes.
  • b now refusing in both (layers only get darker) is confirmed.
  • The structural select check refuses a counting grant even when it crosses a bridge (where toPrisma would over-fetch instead). This is stricter than the compile, but validation and runtime agree.

Round-9 review (c6c4745) — the source-options pipeline

Each finding got a failing test first, in test/review.round9.test.ts and test/review.round9.sourceFuzz.test.ts.

  • N1/P2 — fetch then materializeSources gave wrong options (fail-open both ways).
    • materializeSources now checks the exact condition the option query compiles, including the visit's own grants. SourcePlan.where is the one condition every materializer evaluates.
    • toLensSelect now fetches everything each projected source reads, as it does grant columns: the value, label, axes, and every column and relation in its condition, including the inverse relation that carries the grants above. projectRows(…, { keepGrantColumns: true }) keeps them; a viewer's projection drops them.
    • Tests cover the five pipeline repros plus a source reading a hidden column. The DB, the fetched rows and the kept rows now give the same options.
    • The new fuzz then exposed a related, older over-offer. A path source with no grant above offered rows that can't be reached down its path (e.g. root orgs under User.org.children). A path source is now linked down its path through each relation's inverse even when no grant sits above.
  • N2 — toSourceQueries(lens, options?) takes the clock (now/timeZone/weekStart). Without now, a relative date is a plain usage error at runtime, not a refusal; validation is unaffected. Binds go through bindLens, as for the compilers. Documented.
  • P1 — a source where across a bridge folded to TRUE (fail-open). Such a query is no longer compiled: prisma is null and sql.error says "crosses a bridge… materialize it with materializeSources". Validation is unaffected. Tests show both rails return no query and materializeSources gives the right set.
  • A — toLensSelect threw plain toPrisma errors.
    • Both the fetch select's to-many grant and the source condition are now checked by validateRule(…, { target: 'toPrisma', map, mapName, model }), memoized per condition. A failure is a LensRefusal. The hand-written shape check is gone; only the select-specific counting-step check remains.
    • validateRule now also reports a case-insensitive list comparison, and a count or aggregate over a relation that can't carry a group step (it asks the compiler's own groupPath).
    • This covers matches/fuzzy/case-insensitive lists in grants and matches in a source where.
  • P3 — labels differed between rails. Every rail now picks the least label for a value. Prisma distinct is the value plus a sibling label; a dotted label drops it. Options that share a label are ordered by value. A rails test checks equality.
  • Fuzz harness.
    • The round-8 agreement fuzz now runs 21 seeds (8, 901–920), including the review's failing seeds, at the full shown depth, with ≥100 later layers each. It asserts that the lens's own queries and projections never throw a plain error on a valid lens.
    • The new 3-rail source fuzz (24 seeds, about 600 source queries) compares Prisma, SQL, and materializeSources over the library's own fetch, both as fetched and as keepGrantColumns rows. It covers hidden to-one and list rows, NULLs, narrow roots, nested sources, labels, axes and pointers. 0 mismatches.
    • The reviewer's sourceRails fuzz in pipeline mode (6 seeds × 200): 0 mismatches. agree8 on seeds 901/911/915/916/920/31: 0 under-strict, 0 over-strict.
  • Perf (spelled-heavy L2): lensVisit over every path 0.71–0.90 s; validateNarrowing ~0.56 s, with plans shared between inspect phases. toLensSelect rose from ~0.18 s to ~0.35 s on this lens, which has a source on every model, because it now plans sources.

Flagged (round 9)

  • Bridge routing deviates from the spec. A bridged source is routed per query (prisma: null) rather than making toSourceQueries throw a LensRefusal. A throw would fail the whole call for a lens that validation accepts and must keep accepting (bridges are allowed), which would break validation ⇔ runtime.
  • One-sided maps keep the old behavior. A relation whose map declares no inverse links nothing when no grant sits above, so its query offers the rows the grants admit, reachable or not. With a grant above and no inverse, it still offers nothing. Failing closed instead would empty every path source on one-sided hand-written maps, including the EAV example.
  • Breaking change: SourceQuery.prisma is now SourcePrismaQuery | null.
  • materializeSources no longer takes viewer rows. It needs fetched rows or keepGrantColumns rows, and now re-applies the visit grants (documented). An old test that encoded the opposite contract was rewritten.

Round-10 review (8676135)

Each finding got a failing test first, in test/review.round10.test.ts. Expected options come from an independent oracle: the seeded rows are walked down the path by hand, each level's grant checked as a plain predicate. The lens's own plan is never used as the oracle.

  • feat: map-aware toPrisma + toSql with multi-step plans and path refs #1 (HIGH) — a to-one → to-many path source offered nothing.
    • Cause: prefixConditionFields refused to re-root an array rule, and the planner collapsed that to false.
    • Fix: a relation node now re-roots under a to-one hop by its field. Its condition reads its elements, so only the field moves: users any … on an Org becomes org.users any … on its User.
    • A relation node whose inner ref climbs to the row being re-rooted ($$. inside it) is still refused. A link the planner can't carry is now a LensRefusal, never false.
    • Tests: the oracle covers to-one→to-many (no grant, a root grant, a relation grant), to-many→to-one, to-one→to-many→to-many, up-then-down a self-relation (with and without grants), and to-many→to-one→to-one. Each test checks Prisma, SQL where present, fetched rows and kept rows against the oracle.
    • The source fuzz now runs the same row-walk oracle on every source whose where reads only its own columns (more than 200 per run). Restoring the old behavior makes it fail 45 times.
    • The narrowRule refusal tests (R6-6, R8 d, failClosed) now assert the new boundary: the refusal happens when an inner ref climbs out.
  • feat: lens primitive — schema-aware narrowings + bridge boundaries #2 (MED perf) — the fetch pulled the whole inverse tree.
    • The fetch now reads a source below its visit only: each plan's rowWhere (visit grants + own eligibility + guards + allowed values). materializeSources walks the fetched tree as the path's link, checking each level's grants.
    • toSourceQueries keeps the fully linked where.
    • Results: the reviewer's size probe now fetches 25,396 bytes with or without the source (was 2.6 MB). A size test asserts ≤1.5× and no children in the select.
  • fix(bind): key-presence contract (2.11.1) #5 — viewer rows over-offered. materializeSources now throws "needs fetched or keepGrantColumns rows, not viewer rows" when a row at a source's path, or on its way down, lacks a key that its source or a grant there reads.
  • fix(toPrisma): isEmpty/notEmpty must not compare non-String columns to '' #6/Adopt @inixiative/gloss: harvest src/ commentary into the margin, promote load-bearing constraints to // why: #7/fix(null): a null date column is a non-match, not a throw; 2.19.1 #8 — bridges.
    • A source whose where, label or axis reads across a bridge now has no query: prisma: null, and sql.error names the path and the rows to supply.
    • materializeSources handles it, and bridged pointers too, from caller-supplied rows holding the far side inline. Rows without that side throw. toLensSelect emits no bridge select.
    • Tests cover where, label, axis and pointer. The README has a new "Sources across a bridge" section, and LENS.md is updated.
  • Feature requests: relative date operator + ordered-relation selection (first/last/firstN/lastN) with predicates #3 — a lens's own grant or source that fails to compile on a literal is now a LensRefusal.
    • A missing now or an unbound bind stays the caller's usage error, marked with an internal UsageError subclass of Error.
    • validateRule (toPrisma) now reports, from the shape alone: a case-insensitive comparison on Json, a list literal holding null, and an element condition over an array column (scalar list or Json array).
  • feat: context bindings — preprocess into the lens (2.11.0) #4 — fixed. A case-insensitive in/notIn set on a list column is accepted again, as toPrisma compiles it.
  • feat: rule value extraction belongs to the engine — referencedFieldVa… #9 — the fuzz RNGs. All fuzz tests use mulberry32 (test/fuzz/mulberry32.ts).
    • The source fuzz asserts that more than 95% of its lenses are distinct, and the round-8 fuzz asserts the same for its first narrowings.
    • The new RNG reached a gap in the round-5 posture fuzz's own oracle (a root showing no column is fetched by id, R7-4). The oracle is fixed.
  • Minor: the README's query.prisma example now handles null.
  • Perf (spelled-heavy L2): lensVisit over every path 0.77–0.92 s; validateNarrowing 0.57–0.59 s. Bridge detection is skipped when the lens has no bridges.

Round-11 review (fe33dee)

Each finding got a failing test first, in test/review.round11.test.ts.

  • F1 — materializeSources checked only the first segment of each read (fail-open). It now walks every read through each relation and list element to the column, and requires every key on the way. Walking stops at a null row or an empty list, and a Json column's inside is not checked.
    • Reads covered: the source's where, label and axes, plus every grant on the path.
    • A relation is also checked to be one row or a list as the map declares it.
    • Viewer rows and caller rows missing the far side of a bridge now throw UsageError.
    • Tests cover both r11_viewer cases, r11_bridge (a missing far column, and the far side given as a list), and no false positives (a null to-one row, an empty list, a to-one label). The pipeline tests and fuzzes cover kept rows, id-only picks and relation-off sources.
  • F2 — false links. ancestorGrants never returns false any more.
    • A grant the path can't carry (the map declares no inverse) is a LensRefusal, which validation reports.
    • A path across a bridge is routed to caller rows (prisma: null). A pointer with nothing to carry still compiles against the far map.
    • Tests: r11_bridge2 (both rails agree), and a one-sided map with a root grant.
    • Existing tests that relied on the old "offers nothing" behavior now assert the refusal or the routing: lens.security (the author source), lens.sourceFromMapDefaults (bridged path source), and the round-5 p2c fixture, whose incidental grant was dropped.
  • F3 — error classes.
    • UsageError now covers a missing or invalid now, an invalid time zone (checked once per zone name), a time-zone bind that isn't a string, and an unbound bind in the compilers or in check/materializeSources.
    • compileOrRefuse and compileWithLens pass a UsageError through unchanged.
    • LensRefusal and UsageError are exported from index.ts with name set, and documented in the README "Error Handling" section and in docs/VERBS.md.
    • Tests check every rail and materializer for no/invalid now and an invalid time zone, a missing binding in materializeSources, and both names.
  • F4 — a lens grant that a rail can't compile threw a plain Error. toPrisma and toSql under { lens } now run through compileWithLens. When the narrowed rule fails to compile but the bare rule compiles against the same base, the failure is the lens's: a LensRefusal with code unsupported_target.
    • That code marks a rail limit (check() runs the lens), not an invalid lens. validateNarrowing still accepts these lenses, and the agreement fuzz treats the code as a compile limit.
    • validateRuleInLens takes no target, so the compile is where this is enforced.
    • Tests: a window and a nested scope ref re-rooted under a to-one hop, and a root window grant.
  • F5 — not fixed, by decision. Gaps where validateRule(toPrisma) passes a rule that toPrisma or Prisma then rejects are filed as validateRule(toPrisma) misses Prisma shape and literal gaps: Json path arrays, case-insensitive Json, literal types, ordered list/enum ops #30, with the probe and the categories.
  • Also: date-operations "complex date validation" depended on the wall clock and failed under the check's TZ today. It now picks its weekday in UTC, the zone the rule reads in.
  • Perf: the dense spelled perf lens's grants can't reach its sources on a one-sided map, so that lens is now refused. With the grants dropped (still valid, 17k visits), L2 lensVisit over every path takes 0.64–0.81 s and validateNarrowing ~0.52 s. On the reviewer's probe, lensVisit takes 4–15 ms per pass and validateNarrowing 3–9 ms.

Release polish (23e2833)

Tests are in test/review.round12.test.ts.

  • materializeSources catches two more kinds of bad input, as UsageError:
    • A to-one row that is null while its key is set. Only a viewer projection produces that; the round-12 repro showed it silently over-offering.
    • A relation on the source's own path that is missing, or is a list where the map says one row (or the reverse).
    • Raw fetched rows and keepGrantColumns rows are unaffected; the pipeline tests and fuzzes stay green.
    • One hand-made fixture row with a dangling key (mapId set, map: null) now models its unreachable label hop with a null definition instead.
  • More caller-input cases are now UsageError: a row path given as the time zone, a lens passed together with map/mapName/model, and a pointer source handed to materializeSources.
  • Lint: unused imports removed. bun run check passes with 0 warnings.

Downstream

  • Lenses must turn on every relation they cross, in the first narrowing.
  • Relation names must come out of picks.
  • rules is removed from toLensSelect/projectRows.
  • context callers must switch to binds.
  • rules-builder's surface/anchor code must gate against the narrowing, not the { by: 'model' } surface. lensVisit replaces its rawView.
  • A lens that reaches a model a second way through the defaults (e.g. org.users back to the User root, or the longer of two routes to one model) must now spell that path.
  • Follow-up (not in this PR): per-path sourceValues in validateRuleInLens, which rules-builder asked for.

🤖 Generated with Claude Code

Relations
- A relation is crossed only where the first narrowing over the base lens turns it on, along
  the path (root.relations) or at the model default (mapDefaults…models.M.relations, nested
  objects included). exposed1 = turn-on1 and not hide1; exposedk = exposed(k-1) and not hidek.
  Later layers hide with omits or restate a shown relation to narrow that hop; naming one the
  parent doesn't show is not_visible. picks names columns only (a relation in it is wrong_kind).
- A model-default relation crosses each edge Model.relation once per path; deeper recursion is
  spelled under root.relations. One walk (resolveVisit) serves every posture: the gate,
  walkLensPath, readLensValue, projections, sources, fetch and validateNarrowing.
- The first narrowing's grants read the schema; a later layer's only what its parent shows.
- toLensSelect / projectRows open exactly what is turned on plus grant columns; shallow fetch
  and the rules option are cut; a column-less relation is selected by its key alone. A source
  keyed on a relation, or a bare label naming one, is wrong_kind.

Context
- options.context is removed from check / toSql / toPrisma; caller values are binds. A bare
  path is a root-row column on every rail, gated and narrowed like a field. toSql compiles a
  column; toPrisma a field reference ({ __field }, resolved by executePrismaPlan) between two
  columns of the same model, visit and type with equality / ordered operators and
  IS [NOT] DISTINCT FROM null semantics; anything else throws, and validateRule / describeRule
  report it first. timeZone is a string or a bind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@agreenspan
agreenspan force-pushed the feat/4.0-declared-relations branch from 73c99a9 to 2d19e42 Compare October 8, 2026 04:35
@agreenspan agreenspan changed the title json-rules 3.4.0: relations must be declared to be traversed json-rules 3.4.0: relations are fields, off by default; context removed Oct 8, 2026
agreenspan and others added 9 commits October 8, 2026 02:24
…e refs, model-once, runtime grant refusal, step refs, negated column compares; lensVisit

- H1: a bare value path reads the root row, so only root.where may hold one; a relation grant,
  model default or source eligibility where with one is invalid_value_source, and every posture
  throws (LensRefusal) instead of reading the related row.
- H2: each Prisma step records where its own { __step } / { __field } references sit (refs);
  executePrismaPlan resolves only those locations; a rule value holding __step / __field is
  refused at compile.
- H3: a model-default relation never re-enters a model already on the path (root included);
  spelled paths are followed as written. Enumerating walks visit each visit class once.
- M1: a later layer's grant crossing a relation its parent doesn't show throws at runtime too.
- M2: a column comparison inside a counting step (count / relation aggregate) is refused on
  Prisma at compile and in validateRule / describeRule; a bare path there never binds to the target.
- M3: a negated column comparison (if, all) compiles to its complement with NULL arms.
- lensVisit(lens, relationPath): one projected visit on demand (first consumer: rules-builder 0.30).
- projectLens by path keeps a map's declared option labels and groups.
- Docs: LENS.md, README, CHANGELOG, VERBS.md, TIMEZONE.md, types JSDoc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n their own row; enum column compares exact

- F1/F2: model-default turn-ons grow a tree under each spelled node (the anchor and every
  root.relations path): breadth-first, each model at most once at its nearest reach (ties: earlier
  parent, then field order), never one already on the spelled path. Every posture walks the same
  tree (no class dedupe), so the gate, read, lensVisit, projections, sources, validateNarrowing
  and toLensSelect agree exactly and stay within spelled nodes x models.
- F3: a scope ref that climbs out of a grant ($$ at its top) is scope_out_of_bounds in
  validateNarrowing and a LensRefusal in every posture.
- F4: an enum column compares only with an enum column of its own type, by equality (natively on
  SQL); ordered and enum-to-text column compares are refused on both compilers and in
  validateRule / describeRule. The 558-shape column fuzz: supportedTargets matches compile, rails agree.
- The rails harness loads org.users.posts so check sees what the compilers query.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plies to; index-stable pointers; per-call trees; presence reads only shown columns; gate refuses what narrowRule can't re-root; list column compares

- R6-1/R6-3: a later layer's grant is checked by one function (the gate over its parent's
  surface: every hop and the column at its end) — validateNarrowing at the visits it applies to
  (the composed lens's shown visits; the fetch and sources run as they will to cover the visits
  layer-1 grants cross), and every runtime posture at each visit it resolves.
- R6-2: a source pointer drops its own layer's carried grants without re-indexing the chain, so
  later layers' grants still read through it (Policy.skipGrantsOf).
- R6-4: model-default trees are cached per API call (Policy.trees), never on the narrowing.
- R6-5: a relation that shows no column is fetched by a column it shows, or not at all.
- R6-6: narrowRule's re-root refusals are LensRefusals; validateNarrowing refuses a grant at a
  to-one visit it can't re-root, and validateRuleInLens refuses a rule narrowRule can't narrow.
- LOW: a list column compares with a column only by membership (contains / notContains of a
  scalar); anything else is refused on both compilers and in validateRule / describeRule.
- Docs: nested default relation objects apply along spelled edges as well as tree edges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nother column

Overrides round-6 edge call 1: a relation showing no column is selected by its key alone (the join
key, else id), even a hidden one, so fetch → projectRows(keepGrantColumns) → check(narrowRule)
equals the database for posts any / none / atLeast; a viewer's projection drops the key. A model
with no key is not fetched (documented: presence can't be re-checked there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ound from grant reads; never-applied grants unchecked; root key fetch; toSql column targets exact; tree cache by fingerprint

- R7-1: every refusal narrowRule raises is a LensRefusal (to-many flat read, out-of-bounds scope);
  validators report it as an issue, runtime postures throw it. Fuzz: validators never throw.
- R7-2: the composed-lens dry run is gone. validateNarrowing resolves, with the runtime's own
  vetting, every shown visit and every visit the grants / sources / labels / axes at them read
  through — found from readPaths, so unbound and bound lenses validate alike.
- R7-3: a path node's child visits are filtered to those the composed lens shows; the enum
  inheritance chain is unvetted.
- R7-4: a root that shows no column is selected by its id (a viewer's projection drops it).
- R7-5: validateRule / describeRule refuse toSql for a substring, pattern or set operator against a
  column (save list membership); the column fuzz covers every operator (1728 shapes, 0 bad).
- PERF: model-default trees are cached on the first narrowing under a fingerprint of its model
  defaults and each spelled node's own turn-ons and omits (R6-4's mutation safety kept); a later
  grant's check is memoized per grant, visit and parent chain within a call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es; source wheres narrow as rules

- validateNarrowing runs the postures the runtime runs, in an inspect mode that compiles nothing:
  the projection by path and by model, lensVisit at every shown path, the source plans, the fetch
  select, and a rule reaching each shown visit narrowed. Each LensRefusal is an issue; ok holds
  exactly when no posture refuses. The hand-built visit loop is gone.
- A source where is narrowed under the whole lens as narrowRule narrows a rule: grants on
  relations inside array conditions and on a path's hops and terminal relation apply, so an
  option never comes through a row the lens hides.
- sourceReadsVisible keeps the chain whole and skips only the declaring layer's hiding.
- The default-tree cache keys on the base lens (identity, maps, mapName, model) as well.
- The source planner's and the fetch select's refusals are LensRefusals: a guarded label/axis hop,
  a to-many hop with a grant, an empty source, a windowed or counting grant on a to-many relation
  (checked before anything compiles).
- toSourceQueries' SQL selects from the model's dbName.
- Perf: one call memo (resolved visits with deferred vetting, visit places, model fields, grant
  refs), cached ancestor grants per path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A window toPrisma has no form for — in a source's own where or a grant carried into it — made
toSourceQueries throw toPrisma's plain Error while validateNarrowing passed the lens. The planner
now runs the shape check toLensSelect uses (counting steps allowed: the option query runs them)
and refuses with a LensRefusal, so inspect-mode validation reports it. The round-8 fuzz generates
windowed source wheres and asserts no plain windowing Error escapes the lens's own queries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… every rail

- materializeSources checks the condition the option query compiles (the visit's own grants
  included); toLensSelect fetches, and projectRows(keepGrantColumns) keeps, what each projected
  source reads — value, label, axes, its condition's columns and relations (the inverse that
  carries the grants above included). Fetch → materialize now equals the database.
- A path source is linked down its path through each declared inverse even with no grant above.
- toSourceQueries(lens, options?) takes the clock.
- A source where across a bridge has no query (prisma null, sql.error) instead of folding to TRUE.
- Labels: least label wins on every rail (distinct on value + label; no distinct for a dotted
  label); options sharing a label order by value.
- What toPrisma can't compile in a to-many grant (fetch select) or a source condition is read by
  validateRule (toPrisma, with the map) and refused as a LensRefusal; validateRule now also
  reports a case-insensitive list comparison and a count/aggregate over a relation that can't
  carry a group step.
- Fuzz: round-8 agreement over 21 seeds at full shown depth, no plain throws from the lens's own
  queries on valid lenses; new 3-rail source fuzz including the library's fetch pipeline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…; fetch reads below the visit

- A relation node re-roots under a to-one hop by its field (`users any …` on an Org becomes
  `org.users any …`), so a path going to-one then to-many carries its link and grants instead of
  collapsing to an empty option list; a ref inside that climbs to the re-rooted row is refused,
  and a link the path can't carry is a LensRefusal, never `false`.
- The fetch reads a source below its visit only: materializeSources walks the fetched tree (the
  path's link), each level's grants met, and checks the source's condition at its visit
  (`rowWhere`); the option query keeps the fully linked condition.
- materializeSources throws on rows missing a key its sources or their path's grants read (viewer
  rows); a source across a bridge (where, label, axis, or a bridged pointer) has no query
  (`prisma: null`, sql.error names the path and the rows to supply) and is materialized from
  caller-supplied rows holding the far side.
- A failed compile of a lens's own grant or source is a LensRefusal; a missing clock or unbound
  bind stays a usage error. validateRule (toPrisma) now reports case-insensitive Json, list
  literals holding null, element conditions over array columns, and accepts case-insensitive sets
  of members on list columns.
- Fuzz: mulberry32 everywhere; the source fuzz gains an independent row-walk oracle and a
  distinct-lens check; new round-10 tests with a hand-walked oracle per path shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
agreenspan and others added 2 commits October 8, 2026 07:14
…links, error classes

- materializeSources requires every key each read walks — through relations and list elements to
  the column, for the source's where, label, axes and every grant on the way — and each relation
  as one row or a list as the map declares; viewer rows and incomplete bridged rows throw.
- A grant a source's path can't carry (no inverse) is a LensRefusal; a path across a bridge is
  routed to caller rows (prisma: null). Nothing compiles to `false`.
- UsageError (missing/invalid now, invalid time zone, unbound bind) and LensRefusal are exported
  with their names; compile wrappers keep them apart.
- toPrisma / toSql under { lens }: a failure the bare rule doesn't meet is the lens's grants —
  a LensRefusal (code unsupported_target), never a plain Error.
- A wall-clock date example now picks its weekday in UTC, the zone the rule reads in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ageErrors

- materializeSources: a to-one null while its key is set (a viewer's projection hid it) and a
  relation missing or misshapen on the source's own path throw UsageError instead of reading as
  absent.
- UsageError for a row-path time zone, a lens passed with map/mapName/model, and a model source
  handed to materializeSources.
- Unused imports removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@agreenspan agreenspan changed the title json-rules 3.4.0: relations are fields, off by default; context removed json-rules 3.4.0: relations off by default, turned on by the relation object; context removed; lensVisit Oct 8, 2026
@agreenspan
agreenspan merged commit 9e47233 into main Oct 8, 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