Skip to content

fix(mapper): make the late native fallback for unresolved value nodes fail closed (CIP-3715) - #442

Merged
freshtonic merged 5 commits into
mainfrom
james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is
Aug 5, 2026
Merged

fix(mapper): make the late native fallback for unresolved value nodes fail closed (CIP-3715)#442
freshtonic merged 5 commits into
mainfrom
james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is

Conversation

@freshtonic

@freshtonic freshtonic commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes CIP-3715.

Important

Stacked on #439 (CIP-3700, base branch james/cip-3700-eql-mapper-inference-hardening-unconstrained-limitfetch). It must land after #439, or be retargeted to main once #439 merges. The dependency is real: without #439's LIMIT/OFFSET/FETCH Native pins, removing the fallback would break LIMIT $1 and friends.

The problem

Unifier::resolve_unresolved_value_nodes resolved any ast::Value node still untyped after inference to Native — fail-open one layer below the CIP-3699/CIP-3700 inference gaps. A literal or param in a clause that inference never visited was silently typed Native and could skip encryption; a future missed AST field over an encrypted column would sail through the same way. Its Result was also discarded (let _ =) at the call site in EqlMapper::resolve, so even unification failures there were swallowed.

The audit

Instrumented the fallback and ran the full mapper suite. Every shape that reached it, and what owns it now:

Shape reaching the fallback Now typed by
WHERE true, WHERE $1 Native pin on Select::selection (boolean contexts are always native)
HAVING <literal> Native pin on Select::having
JOIN … ON true (incl. ASOF match conditions) Native pin on join ON constraints
ORDER BY 1, GROUP BY 1 ordinals (incl. under DISTINCT, and after set operations where no single projection resolves them) Native pin on literal sort/group keys — the literal reaches the database as a plain constant regardless of which projected column the ordinal selects; the redirected Ord/Eq bound on the projected column is unchanged
SELECT 'lit', SELECT $1, CASE results in a projection, SELECT ARRAY[1,2,3], SELECT 1 inside EXISTS, unreferenced derived-table columns Still defaulted to Native at resolve time, but the defaulting is now scoped: only type variables reachable from a Query/Statement node's type (i.e. from some projection) qualify. These values relate to nothing — they flow to the client or are discarded — so they cannot be EQL, which is what makes the default sound.

Fail closed

Anything else unresolved at resolve time is now TypeError::UnresolvedValue, naming the value, and the error propagates out of EqlMapper::resolve instead of being swallowed.

Shapes verified (by probe) to now fail closed instead of silently passing — left for CIP-3699, which owns these inference gaps; do not expect them to type-check until it lands:

  • window frame bounds (ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)
  • aggregate FILTER (WHERE …) clauses

(ON CONFLICT — also CIP-3699's — already errors earlier, on scope resolution.)

One behavioural edge outside the test suite: DISTINCT ON (<literal>) previously slipped through via the fallback and now errors. PostgreSQL has no ordinal resolution there, so nothing meaningful is lost; flagging it in case CIP-3699 wants to own it alongside the other clause pins.

Changes

  • packages/eql-mapper/src/inference/unifier/mod.rs — fallback replaced with projection-reachability-scoped defaulting + fail-closed error; unit test for the fail-closed path.
  • packages/eql-mapper/src/inference/infer_type_impls/select.rsNative pins for WHERE/HAVING/join ON and GROUP BY literal keys.
  • packages/eql-mapper/src/inference/infer_type_impls/query_statement.rsNative pin for ORDER BY literal keys.
  • packages/eql-mapper/src/inference/type_error.rs — new UnresolvedValue variant.
  • packages/eql-mapper/src/eql_mapper.rs — propagate the error instead of let _ =.
  • packages/eql-mapper/src/lib.rs — six new tests: WHERE-placeholder-is-native, HAVING/ON constant conditions, encrypted column as bare WHERE condition rejected, UNION ALL … ORDER BY 1, unreferenced derived-table literal column.
  • CHANGELOG.md — Fixed entry under Unreleased.

As a side effect of the WHERE pin, WHERE enc_col (a bare encrypted column as a boolean condition) is now rejected by the mapper instead of being forwarded for PostgreSQL to reject.

Verification

  • mise run check — clean (fmt, clippy, compile).
  • cargo test -p eql-mapper — 146 passed (139 pre-existing + 7 new), 0 failed.
  • cargo test -p cipherstash-proxy -- --test-threads=1 — 121 passed; the 3 doc-test failures are pre-existing on clean main.
  • Integration tests not run: this is a mapper-internal change with no wire-protocol or encryption-path surface; the mapper suite is the authority on statement typing.

Summary by CodeRabbit

  • Bug Fixes

    • Improved query type validation to prevent untyped values from being accepted unexpectedly.
    • Added clear errors for values that cannot be safely resolved.
    • Fixed handling of literals and parameters in comparisons, filters, joins, grouping, ordering, and aggregate conditions.
    • Bare encrypted boolean conditions are now rejected where native values are required.
  • Tests

    • Added coverage for literal-only predicates, shared parameters, joins, ordering, grouping, and derived queries.

@freshtonic
freshtonic requested a review from tobyhede August 4, 2026 06:03
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@freshtonic, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0134cf9-4568-4dfe-aa7d-8cbbf9281cdd

📥 Commits

Reviewing files that changed from the base of the PR and between ecc6b0e and a80d8c8.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • packages/eql-mapper/src/eql_mapper.rs
  • packages/eql-mapper/src/inference/infer_type_impls/expr.rs
  • packages/eql-mapper/src/inference/infer_type_impls/query_statement.rs
  • packages/eql-mapper/src/inference/infer_type_impls/select.rs
  • packages/eql-mapper/src/inference/type_error.rs
  • packages/eql-mapper/src/inference/unifier/mod.rs
  • packages/eql-mapper/src/lib.rs
  • packages/eql-mapper/src/transformation_rules/helpers.rs
  • packages/eql-mapper/src/transformation_rules/mod.rs
  • packages/eql-mapper/src/transformation_rules/rewrite_eql_any_all_ops.rs
  • packages/eql-mapper/src/transformation_rules/rewrite_eql_comparison_ops.rs
  • packages/eql-mapper/src/type_checked_statement.rs
📝 Walkthrough

Walkthrough

The EQL mapper now applies native typing to predicates and ordinal expressions, defers comparison operand grounding, and rejects unresolved values outside projections. Resolution errors propagate to callers. Regression tests cover literals, parameters, joins, ordering, projections, and encrypted boolean conditions.

Changes

EQL type inference

Layer / File(s) Summary
Native constraints for expressions
packages/eql-mapper/src/inference/type_error.rs, packages/eql-mapper/src/inference/infer_type_impls/expr.rs, packages/eql-mapper/src/inference/infer_type_impls/select.rs, packages/eql-mapper/src/inference/infer_type_impls/query_statement.rs
Comparison operands can be natively groundable when unresolved. WHERE, HAVING, join ON, AS OF, and ordinal GROUP BY/ORDER BY expressions receive native constraints.
Fail-closed unresolved value resolution
packages/eql-mapper/src/inference/unifier/mod.rs, packages/eql-mapper/src/eql_mapper.rs
The unifier tracks safely defaultable variables and projection-reachable variables. Other unresolved values return TypeError::UnresolvedValue, and resolve propagates the error.
Regression coverage and release documentation
packages/eql-mapper/src/inference/unifier/mod.rs, packages/eql-mapper/src/lib.rs, CHANGELOG.md
Tests cover literal predicates, shared parameters, boolean conditions, joins, encrypted conditions, ordinal ordering, and derived-table projections. The changelog documents the stricter behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the fail-closed fix for unresolved value nodes and matches the main changes in the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment-only review.\n\nI did not find a defect in the mapper changes themselves. The projection-reachability defaulting is scoped conservatively, the previously implicit boolean/ordinal cases are now constrained explicitly, and the new error is propagated.\n\nOne current-head issue remains: all four PostgreSQL CI jobs are failing in with a error (the server expects , while tokio-postgres is given a Rust ). This appears inherited from the stacked base rather than introduced by this diff, but the stack needs to be updated or the failure otherwise resolved before merge.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my preceding review (shell formatting stripped identifiers): the four failing jobs fail in update_with_param_reused_for_storage_and_query with WrongType; PostgreSQL expects eql_v3_text_search while tokio-postgres is given a Rust String.

@freshtonic
freshtonic force-pushed the james/cip-3700-eql-mapper-inference-hardening-unconstrained-limitfetch branch from f52244d to 561e9c6 Compare August 5, 2026 03:13
Base automatically changed from james/cip-3700-eql-mapper-inference-hardening-unconstrained-limitfetch to main August 5, 2026 03:34
@freshtonic
freshtonic force-pushed the james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is branch from f37a87e to e4fc642 Compare August 5, 2026 05:22
freshtonic added a commit that referenced this pull request Aug 5, 2026
…efault to native

The fail-closed fallback rejected WHERE 1=1: the literals unify with
each other through the comparison but reach no projection, so they
stayed unresolved and the statement was refused — which the proxy turns
into a silent passthrough, returning raw ciphertext (caught by the
Python integration test test_disable_mapping).

A comparison's result is native regardless of its operand type, so the
operand pair is now marked as safe to default to native at the end of
inference. The grounding stays late rather than eager: a param shared
with a later encrypted comparison (WHERE $1 = 'x' AND enc = $1) must
still resolve to the column's EQL type, and a marked variable that
grounded concretely in the meantime is simply skipped. Positions no
rule ever constrained still fail closed.

Fixes the CI failures on #442.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/eql-mapper/src/inference/infer_type_impls/expr.rs`:
- Around line 238-251: Extend the native-default eligibility marking beyond the
generic BinaryOp branch to every comparison path in the expression inference
implementation, including Expr::InList, Expr::Between, Expr::IsDistinctFrom,
Expr::AnyOp/Expr::AllOp, and simple CASE comparisons. Reuse
mark_natively_groundable for each operand type unified by Eq or Ord constraints,
preserving encrypted-type grounding behavior, and add regression tests covering
the listed literal comparison forms.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 731c150d-aeca-4b8e-82df-eb1d44a04876

📥 Commits

Reviewing files that changed from the base of the PR and between e2198c7 and 5b43968.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • packages/eql-mapper/src/eql_mapper.rs
  • packages/eql-mapper/src/inference/infer_type_impls/expr.rs
  • packages/eql-mapper/src/inference/infer_type_impls/query_statement.rs
  • packages/eql-mapper/src/inference/infer_type_impls/select.rs
  • packages/eql-mapper/src/inference/type_error.rs
  • packages/eql-mapper/src/inference/unifier/mod.rs
  • packages/eql-mapper/src/lib.rs

Comment thread packages/eql-mapper/src/inference/infer_type_impls/expr.rs
freshtonic added a commit that referenced this pull request Aug 5, 2026
… just BinaryOp

IN, BETWEEN, IS DISTINCT FROM, ANY/ALL and the simple-CASE operand all
unify their operands with each other (or a fresh bounded tvar) without
grounding them, so a literal-only condition in any of these forms was
rejected by the fail-closed fallback exactly as WHERE 1=1 was. Each now
gets the same mark as the generic comparison.

ANY/ALL with an array literal remains rejected for an unrelated,
pre-existing reason: the operand unifies with the whole array type,
which the Eq/Ord bound then refuses. The cast-param spelling
(= ANY($1::int[])) grounds through the cast and already works.

Addresses review feedback on #442.
… fail closed (CIP-3715)

Unifier::resolve_unresolved_value_nodes resolved *any* ast::Value node
still untyped after inference to Native — fail-open one layer below the
CIP-3699/CIP-3700 inference gaps: a literal or param in a clause that
inference never constrained was silently typed Native and could skip
encryption. (Its Result was also discarded at the call site.)

Audit of every shape reaching the fallback (instrumented run of the
mapper suite), and where each is now typed:

- WHERE / HAVING / join ON conditions (`WHERE true`, `ON true`,
  `WHERE $1`): boolean contexts are always native — pinned to Native in
  InferType<Select>. A bare encrypted column as a condition
  (`WHERE enc_col`) is now rejected.
- ORDER BY / GROUP BY literal keys (`ORDER BY 1`, `GROUP BY 1`): the
  literal reaches the database as a plain constant regardless of which
  projected column the ordinal selects — pinned to Native where the
  clause is inferred (also covers ordinals after set operations, which
  resolve against no single projection).
- Values whose type escapes only through a projection (`SELECT 'lit'`,
  `SELECT $1`, CASE results and ARRAY elements in a projection,
  `SELECT 1` inside EXISTS, unreferenced derived-table columns): these
  relate to nothing and cannot be EQL — still defaulted to Native at
  resolve time, but now scoped to type variables reachable from a
  Query/Statement node's type instead of applying to any value node.

Anything else unresolved at resolve time is now
TypeError::UnresolvedValue naming the value, and the error propagates
instead of being swallowed. Known shapes that now fail closed instead
of silently passing: window frame bounds (`ROWS BETWEEN 1 PRECEDING …`)
and aggregate FILTER clauses — both inference gaps owned by CIP-3699.
…efault to native

The fail-closed fallback rejected WHERE 1=1: the literals unify with
each other through the comparison but reach no projection, so they
stayed unresolved and the statement was refused — which the proxy turns
into a silent passthrough, returning raw ciphertext (caught by the
Python integration test test_disable_mapping).

A comparison's result is native regardless of its operand type, so the
operand pair is now marked as safe to default to native at the end of
inference. The grounding stays late rather than eager: a param shared
with a later encrypted comparison (WHERE $1 = 'x' AND enc = $1) must
still resolve to the column's EQL type, and a marked variable that
grounded concretely in the meantime is simply skipped. Positions no
rule ever constrained still fail closed.

Fixes the CI failures on #442.
… just BinaryOp

IN, BETWEEN, IS DISTINCT FROM, ANY/ALL and the simple-CASE operand all
unify their operands with each other (or a fresh bounded tvar) without
grounding them, so a literal-only condition in any of these forms was
rejected by the fail-closed fallback exactly as WHERE 1=1 was. Each now
gets the same mark as the generic comparison.

ANY/ALL with an array literal remains rejected for an unrelated,
pre-existing reason: the operand unifies with the whole array type,
which the Eq/Ord bound then refuses. The cast-param spelling
(= ANY($1::int[])) grounds through the cast and already works.

Addresses review feedback on #442.
x <op> ANY/ALL(rhs) compares x with each ELEMENT of rhs, so when rhs is
an array its element type is what x unifies with — unifying with the
array itself typed 1 = ANY(ARRAY[1, 2]) as an array (and made the
encrypted case a bounds conflict). The operator now decides the
capability, as for a binary comparison: = ANY needs Eq, < ALL needs Ord.

The new RewriteEqlAnyAllOps distributes the scalar comparison rewrite
over the array literal: eq_term(col) = ANY(ARRAY[eq_term(elem), ...]),
each element cast to the term-only query twin and encrypted like any
other comparison operand.

The encrypted spellings with no elementwise rewrite are refused loudly:
an encrypted subquery projection (previously forwarded unrewritten,
comparing randomised payloads that silently matched nothing) and a bare
array param.

Also rebases onto main so the changelog no longer collides with the
entries #435 merged, and moves this PR's changelog entry out of the
since-released 3.0.0 section.

Addresses review feedback on #442.
@freshtonic
freshtonic force-pushed the james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is branch from efaf370 to f3f7702 Compare August 5, 2026 06:44
@freshtonic
freshtonic merged commit 75f5106 into main Aug 5, 2026
6 checks passed
@freshtonic
freshtonic deleted the james/cip-3715-unifiers-late-native-fallback-for-unresolved-value-nodes-is branch August 5, 2026 07:13
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.

2 participants