Skip to content

fix(core): refuse to prune a sealed filter condition - #879

Merged
tada5hi merged 3 commits into
masterfrom
fix/877-sealed-prune
Aug 4, 2026
Merged

fix(core): refuse to prune a sealed filter condition#879
tada5hi merged 3 commits into
masterfrom
fix/877-sealed-prune

Conversation

@tada5hi

@tada5hi tada5hi commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #877 with option C: the contradiction between the two validators throws instead of failing open.

The gap

A filters validate hook may answer with a sealed policy residual scoping the leaf it saw (the authorization recipe pattern). When that residual names a relation the relations validate hook rejects, pruneCondition recursed into the sealed group and dropped the residual along with the client's own leaf:

without the relations gate:
  and( sealed(and( name = John, realm.id = SCOPE )), realm.name = master )

before this PR, with the gate rejecting `realm`:
  and( sealed(and( name = John )) )        // Johns from every realm

The fix

Pruning now treats a seal as protecting the whole subtree it heads. A drop inside (or of) a sealed condition throws, naming both sides of the contradiction:

SchemaError [schemaSealedConditionPruned]
The relations validator rejected "realm", but the sealed filter condition on "realm.id"
traverses it. A sealed condition must not be dropped, and a rejected relation must not be
joined: align the relations validator with the filters validator that sealed it.

An unsealed residual is an ordinary displaceable condition and is still pruned, which keeps seal the single marker that says "this must survive". Everything the gate did before is unchanged when no seal is in the way.

SchemaError, not ParseError

The issue's option C said "typed ParseError", but also called this a server misconfiguration, and those point at different HTTP buckets in errors.md (ParseError maps to 400). Two contradicting hooks are a configuration bug on the receiving side, and a 400 would be swallowed as ordinary client noise. Precedent: SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER is likewise detected during parse and classed as a schema error. New code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED.

Tests

  • packages/core/test/unit/parser/relation-prune.spec.ts: sealed leaf, residual inside a sealed group, sealed condition below an unsealed group, sealed elemMatch (target and interior), and pruning that leaves an untouched seal alone.
  • packages/parser-simple/test/unit/parser/relations-traversal.spec.ts: the issue's reproduction end to end (sync, async, standalone filters parse), plus the permitted-relation and unsealed-residual boundaries.

Docs updated: errors.md (code table), relations.md (rejection prunes deep), filters.md (validate replacements), merging-queries.md (seal section), recipes/authorization.md (warning box on scoping through a relation path vs. a local column).

Summary by CodeRabbit

  • New Features

    • Sealed filter conditions are now protected during relation validation.
    • Parsing reports a clear schema error when authorization would remove a required sealed condition, including the affected relation and field.
    • Unsealed conditions continue to be pruned normally, while sealed defaults receive the same protection.
  • Documentation

    • Clarified sealed-condition behavior, error handling, and recommended configuration.
  • Tests

    • Added coverage for nested, element-match, synchronous, asynchronous, standalone, and default-filter scenarios.

A filters `validate` hook may answer with a sealed policy residual that
scopes the leaf it saw. When that residual names a relation the relations
`validate` hook rejects, `pruneFiltersByRelations` recursed into the
sealed group and dropped the residual's leaf along with the client's own,
returning a query wider than the policy intended, silently.

Pruning now treats a seal as protecting the whole subtree it heads: a
drop inside (or of) a sealed condition throws `SchemaError` with the new
`SCHEMA_SEALED_CONDITION_PRUNED` code, naming both the rejected relation
and the sealed field. The two validators contradict each other (dropping
the condition widens the result set, keeping it joins a relation the gate
refused), which is a server misconfiguration rather than bad client
input, hence `SchemaError` and not a `ParseError`. An unsealed residual
stays displaceable and is pruned as before.

Closes #877
Copilot AI review requested due to automatic review settings August 3, 2026 14:28
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a typed schema error for rejected relations required by sealed filter conditions. Relation pruning now propagates sealed state through nested groups, elemMatch filters, and schema defaults. Tests and guides document the behavior.

Changes

Sealed condition pruning

Layer / File(s) Summary
Sealed-condition error contract
packages/core/src/errors/code.ts, packages/core/src/errors/schema.ts
Adds SCHEMA_SEALED_CONDITION_PRUNED and the SchemaError.sealedConditionPruned factory.
Sealed-aware relation pruning
packages/core/src/parser/relation-prune.ts
Resolves rejected relation names and throws for sealed conditions instead of pruning them. Unsealed conditions remain prunable.
Behavior coverage and documentation
packages/core/test/unit/parser/relation-prune.spec.ts, packages/parser-simple/test/unit/parser/relations-traversal.spec.ts, packages/docs/guide/*.md, packages/docs/guide/recipes/authorization.md, .agents/architecture.md
Tests cover nested, policy, default, synchronous, asynchronous, standalone, and elemMatch cases. Guides describe residual sealing, relation pruning, and the new error.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FilterParser
  participant RelationPruner
  participant RelationValidator
  participant SchemaError
  FilterParser->>RelationPruner: provide validated filter conditions
  RelationPruner->>RelationValidator: resolve referenced relation
  RelationValidator-->>RelationPruner: return rejected relation
  RelationPruner->>SchemaError: raise sealedConditionPruned(relation, field)
Loading

Possibly related PRs

  • tada5hi/rapiq#876: Adds sealed-filter behavior extended by this relation-pruning change.
  • tada5hi/rapiq#816: Modifies the relation-pruning logic extended by this change.
  • tada5hi/rapiq#766: Covers related filter validation and nested condition pruning behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: preventing relation pruning from removing sealed filter conditions.
Linked Issues check ✅ Passed The changes implement issue #877 by throwing a typed error when relation pruning would remove a sealed condition, with tests and documentation.
Out of Scope Changes check ✅ Passed The code, tests, documentation, and architecture updates directly support the sealed-condition pruning fix in issue #877.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/877-sealed-prune

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.

Copilot AI 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.

Pull request overview

This PR fixes a fail-open edge case in the core relation-pruning logic when a schema’s filters validate hook returns a sealed policy residual that traverses a relation the relations gate later rejects. Instead of silently pruning part of a sealed subtree (which can widen results), pruning now treats the seal as protecting the entire subtree and throws a SchemaError (ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED) on contradictions.

Changes:

  • Core: relation-based filter pruning now detects drops within sealed subtrees and throws SchemaError.sealedConditionPruned(...) instead of pruning.
  • Core: adds ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED and a corresponding SchemaError constructor helper with a detailed message.
  • Tests + docs: adds unit and end-to-end coverage for sealed residual vs relations-gate contradictions and documents the new error/behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/core/src/parser/relation-prune.ts Tracks “sealed subtree” during pruning; throws when a rejected relation would prune any sealed condition.
packages/core/src/errors/schema.ts Adds SchemaError.sealedConditionPruned(relation, field) with the new error code and explanatory message.
packages/core/src/errors/code.ts Introduces ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED.
packages/core/test/unit/parser/relation-prune.spec.ts Adds unit tests covering sealed leaves, sealed groups, sealed elemMatch, and “prune around untouched seal”.
packages/parser-simple/test/unit/parser/relations-traversal.spec.ts Adds end-to-end parser coverage for the reproduction scenario (sync, async, standalone filters parse; sealed vs unsealed residual boundaries).
packages/docs/guide/relations.md Documents that pruning never drops sealed conditions and that contradictions throw SchemaError with the new code.
packages/docs/guide/recipes/authorization.md Adds warning about sealed residuals that traverse relations needing to align with the relations gate.
packages/docs/guide/merging-queries.md Documents seal’s subtree protection and the parsing-time contradiction behavior.
packages/docs/guide/filters.md Documents sealed replacement behavior vs relation pruning and the thrown SchemaError.
packages/docs/guide/errors.md Adds the new schema error code and its trigger to the error code table.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Self-audit follow-up. The schema `default` is deliberately exempt from the
relations gate: it is re-applied after pruning, un-pruned. That left the
sealed case input-dependent, because whether a default is materialized
before the pruning pass (the client sent no filters) or after it (the
client sent filters that all pruned away) is decided by client input: the
same sealed default naming a rejected relation threw in the first case and
survived silently in the second.

The fallback now runs the same check, so a sealed default behaves like a
sealed residual in every shape. Unsealed defaults keep surviving unchanged,
which is what makes them the trusted baseline.

Also: rename the `drop` helper to `dropUnlessSealed`, correct the claim
that a pruned seal always widens (it narrows under an `or`, and the refusal
is a per-node rule rather than a per-operator judgement), and pin the `or`
and `not` shapes with tests.
@tada5hi

tada5hi commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Self-audit follow-up (eef5040)

Probed the edges rather than trusting the happy path. Three findings, all addressed.

1. Sealed schema default was input-dependent (real, introduced by the first commit)

filters.default is materialized in two different places: in the parser's build() when the client sent no filters (so it goes through pruning), and in pruneFiltersByRelations' fallback when pruning emptied the tree (so it does not). With a sealed default naming a rejected relation, that meant:

sealed default | realm named via relations  => THROWS
sealed default | realm named via own filter => survives silently

The same schema and the same actor, decided by what the client happened to send. The fallback now runs the same check, so a sealed default behaves like a sealed residual in every shape. Unsealed defaults keep surviving unchanged: they are the server-authored baseline, and dropping them would be the fail-open this PR is closing.

2. The "pruning would widen" justification was only true for the and shape

Dropping an arm of a sealed or narrows, and the docs/jsdoc claimed otherwise. The refusal is still correct there (the sealed condition is not honored as written, and a policy grant losing an arm denies legitimate access), but it is a per-node rule, not an operator-aware one. Wording corrected, and the or / not shapes are now pinned by tests so the stricter-than-fail-open behavior is deliberate rather than incidental.

3. Verified, no change needed

  • Dialect parity: expression and mongo parsers throw identically (they share the core helper).
  • No swallowing: no catch on the parse or schema-aware encode path, so the error propagates instead of degrading to a silent drop.
  • Post-parse composition unaffected: query.filters.and(scope) runs after pruning, so server scoping never trips this.
  • Vacuous nodes (seal(and()), a sealed elemMatch with an empty interior) are still dropped silently. They carry no restriction and no parser produces them.
  • Fields: a gated Field.condition is unaffected, since pruning drops the whole field and the column is never projected. No fail-open there.

Known cost, unchanged from the issue's analysis

Once the two hooks contradict each other, the failure is client-triggerable: any request naming the rejected relation now 500s instead of silently widening. That is the accepted trade of option C (the misconfiguration has to be visible to the operator), and it is bounded by the same three preconditions the issue lists.

The filters `validate` recipe recommended `seal(and(filter, <scope>))`.
A seal protects the whole subtree it heads, so that shape also protects
the client's own leaf: an actor filtering on a relation it may not
traverse then raised SCHEMA_SEALED_CONDITION_PRUNED instead of simply
having that filter dropped, even though the residual sat on a local
column and nothing was misconfigured.

`and(filter, seal(<scope>))` protects the same residual (a group is never
displaced as a unit either, and the marker survives a `flatten()` that
hoists the residual into the root), keeps the client leaf prunable, and
reserves the error for a residual that itself names a rejected relation,
which is the actual contradiction the check is for.

Recipe updated in the filters guide, the authorization recipe, the seal
section of the composition guide and .agents/architecture.md, with tests
pinning all three properties of the recommended shape.
@tada5hi

tada5hi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Recipe change: seal the residual, not the group (7b59c77)

Follow-up to the audit. The blast radius of the new error was wider than #877 assumed, and the cause was the recommended shape rather than the rule.

The issue notes that "a residual on a local column (realm_id) is never affected". With seal(and(filter, <scope>)) that is not true, because a seal protects the whole subtree it heads, including the client's own leaf:

residual on realm_id, client sends filter[realm.name]=master, relations hook rejects realm

seal(and(leaf, residual))  => THROWS
and(leaf, seal(residual))  => [ realm_id = S (sealed) ]     leaf dropped, scope kept

The first case is not a misconfiguration: the residual is fine, the gate is fine, and they collide only because the seal happens to cover the client's leaf. The second is what you want, and it gives up nothing:

flatten()          : [ name=John, realm_id=S (sealed) ]     marker survives hoisting
merge, either side : group carried through, realm_id=ATTACK and-ed in alongside

A group is never displaced as a unit either, so both shapes resist a merge; the seal only matters once flatten() hoists the residual into the root, and that works on the leaf.

So the recipe now reads and(filter, seal(inArray('realm_id', actor.realmIds))), updated in the filters guide, the authorization recipe, the seal section of the composition guide and .agents/architecture.md. Three tests pin the recommended shape: the client leaf prunes away cleanly, the residual keeps its marker through flatten(), and a residual that itself names a rejected relation still throws.

Net effect: the error now fires only on the contradiction #877 actually describes.

@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: 2

🤖 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/core/test/unit/parser/relation-prune.spec.ts`:
- Around line 280-291: Update the test around seal and the OR filter so only the
user.name arm is sealed while the surrounding OR node remains unsealed, then
retain the expectation that pruning for user throws
SCHEMA_SEALED_CONDITION_PRUNED. Adjust the test name and comments to describe a
sealed OR arm rather than a sealed OR group.
- Around line 323-332: The sealed-default coverage currently only exercises
pruning after a client filter is present. Extend the relation-pruning tests
around pruneFiltersByRelations to also call it with an empty client filters tree
and a sealed default referencing the rejected relation, asserting
ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED; preserve the existing non-empty filter
case.
🪄 Autofix (Beta)

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: 628a23be-0025-4541-af89-22465df300d1

📥 Commits

Reviewing files that changed from the base of the PR and between 467e7af and 7b59c77.

📒 Files selected for processing (8)
  • .agents/architecture.md
  • packages/core/src/parser/relation-prune.ts
  • packages/core/test/unit/parser/relation-prune.spec.ts
  • packages/docs/guide/filters.md
  • packages/docs/guide/merging-queries.md
  • packages/docs/guide/recipes/authorization.md
  • packages/docs/guide/relations.md
  • packages/parser-simple/test/unit/parser/relations-traversal.spec.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/docs/guide/recipes/authorization.md
  • packages/docs/guide/relations.md
  • packages/core/src/parser/relation-prune.ts
  • packages/parser-simple/test/unit/parser/relations-traversal.spec.ts

Comment on lines +280 to +291
// The two shapes below cannot fail open: dropping an OR arm narrows,
// and dropping the interior of a NOT removes a restriction the seal
// put there. Pruning still refuses, because the seal is a per-node
// marker and not a per-operator judgement call.
it('throws for a sealed OR arm, where a drop would narrow rather than widen', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
]);

expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise a sealed OR arm, not only a sealed OR group.

Line 286 calls seal() on the entire OR node. The test does not verify that a sealed user.name arm is rejected while the surrounding OR remains unsealed. Move seal() to the rejected arm, or rename the test and comments to describe a sealed group.

Proposed test shape
 const filters = new Filters(FilterCompoundOperator.AND, [
-    seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
+    new Filters(FilterCompoundOperator.OR, [eq('id'), seal(eq('user.name'))]),
 ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// The two shapes below cannot fail open: dropping an OR arm narrows,
// and dropping the interior of a NOT removes a restriction the seal
// put there. Pruning still refuses, because the seal is a per-node
// marker and not a per-operator judgement call.
it('throws for a sealed OR arm, where a drop would narrow rather than widen', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])),
]);
expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});
// The two shapes below cannot fail open: dropping an OR arm narrows,
// and dropping the interior of a NOT removes a restriction the seal
// put there. Pruning still refuses, because the seal is a per-node
// marker and not a per-operator judgement call.
it('throws for a sealed OR arm, where a drop would narrow rather than widen', () => {
const filters = new Filters(FilterCompoundOperator.AND, [
new Filters(FilterCompoundOperator.OR, [eq('id'), seal(eq('user.name'))]),
]);
expect(() => pruneFiltersByRelations(filters, ['user']))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});
🤖 Prompt for 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.

In `@packages/core/test/unit/parser/relation-prune.spec.ts` around lines 280 -
291, Update the test around seal and the OR filter so only the user.name arm is
sealed while the surrounding OR node remains unsealed, then retain the
expectation that pruning for user throws SCHEMA_SEALED_CONDITION_PRUNED. Adjust
the test name and comments to describe a sealed OR arm rather than a sealed OR
group.

Comment on lines +323 to +332
it('throws for a SEALED default naming a rejected relation', () => {
// otherwise the same default would throw when it is materialized
// before this pass (client sent no filters) and survive when it is
// materialized after it (client sent filters that all pruned away).
const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]);
const schema = defineFiltersSchema({ default: seal(eq('user.b')) });

expect(() => pruneFiltersByRelations(filters, ['user'], schema))
.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED }));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'SCHEMA_SEALED_CONDITION_PRUNED|default: seal|defineFiltersSchema' \
  packages/core/test/unit/parser/relation-prune.spec.ts

Repository: tada5hi/rapiq

Length of output: 8732


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate source definitions =="
rg -n "function pruneFiltersByRelations|export .*pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" packages/core/src packages/core/test -g '*.ts' | sed -n '1,220p'

echo
echo "== relevant source snippets =="
for file in $(rg -l "pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" packages/core/src packages/core/test -g '*.ts' | head -20); do
  echo "--- $file ---"
  rg -n -C 20 "pruneFiltersByRelations|SCHEMA_SEALED_CONDITION_PRUNED|defineFiltersSchema" "$file" | sed -n '1,220p'
done

Repository: tada5hi/rapiq

Length of output: 44603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path('packages/core/src/parser/relation-prune.ts')
text = path.read_text()

m = re.search(r'export function pruneFiltersByRelations[\s\S]+?^}', text, re.M)
print(m.group(0) if m else 'function not found')

print()
print('uses buildFiltersDefaults:')
print('buildFiltersDefaults' in (m.group(0) if m else ''))

print('check all calls to pruneFiltersByRelations in tracked TS:')
for p in Path('packages/core').glob('**/*.ts'):
    if any(part.endswith(('.test', '.spec')) for part in p.parts):
        lines = p.read_text(errors='ignore').splitlines()
        for i, line in enumerate(lines, 1):
            if 'pruneFiltersByRelations(' in line or 'buildFiltersDefaults(' in line:
                print(f'{p}:{i}:{line.strip()}')
PY

Repository: tada5hi/rapiq

Length of output: 1448


Cover the no-client-filter sealed-default path.

The existing case only prunes a client leaf and then checks the default fallback. Add the opposite call shape with an empty client filters tree, or cover that exact sealed default in another sealed-default test.

[loweffort_and_high_reward]

🤖 Prompt for 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.

In `@packages/core/test/unit/parser/relation-prune.spec.ts` around lines 323 -
332, The sealed-default coverage currently only exercises pruning after a client
filter is present. Extend the relation-pruning tests around
pruneFiltersByRelations to also call it with an empty client filters tree and a
sealed default referencing the rejected relation, asserting
ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED; preserve the existing non-empty filter
case.

@tada5hi
tada5hi merged commit 3876e01 into master Aug 4, 2026
9 checks 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.

Relation pruning silently drops a sealed policy residual (fail-open)

2 participants