From 467e7af82237970c8735d2d3d41878349e6ae4f6 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Mon, 3 Aug 2026 16:28:06 +0200 Subject: [PATCH 1/3] fix(core): refuse to prune a sealed filter condition 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 --- packages/core/src/errors/code.ts | 2 + packages/core/src/errors/schema.ts | 9 ++ packages/core/src/parser/relation-prune.ts | 55 ++++++++-- .../test/unit/parser/relation-prune.spec.ts | 92 ++++++++++++++++ packages/docs/guide/errors.md | 1 + packages/docs/guide/filters.md | 2 + packages/docs/guide/merging-queries.md | 2 + packages/docs/guide/recipes/authorization.md | 4 + packages/docs/guide/relations.md | 2 + .../unit/parser/relations-traversal.spec.ts | 102 +++++++++++++++++- 10 files changed, 261 insertions(+), 10 deletions(-) diff --git a/packages/core/src/errors/code.ts b/packages/core/src/errors/code.ts index f8ae99089..fd82d4366 100644 --- a/packages/core/src/errors/code.ts +++ b/packages/core/src/errors/code.ts @@ -40,6 +40,8 @@ export enum ErrorCode { SCHEMA_NAME_INVALID = 'schemaNameInvalid', + SCHEMA_SEALED_CONDITION_PRUNED = 'schemaSealedConditionPruned', + SCHEMA_UNRESOLVABLE = 'schemaUnresolvable', SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER = 'schemaValidatorAsyncRequiresAsyncParser', diff --git a/packages/core/src/errors/schema.ts b/packages/core/src/errors/schema.ts index 261c23878..e35b61cf7 100644 --- a/packages/core/src/errors/schema.ts +++ b/packages/core/src/errors/schema.ts @@ -40,6 +40,15 @@ export class SchemaError extends BaseError { }); } + static sealedConditionPruned(relation: string, field: string) { + return new this({ + message: `The relations validator rejected "${relation}", but the sealed filter condition on ` + + `"${field}" 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.', + code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED, + }); + } + static validatorAsyncRequiresAsyncParser() { return new this({ message: 'Asynchronous schema validators require parseAsync().', diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index ebfbaba87..98d858503 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -5,6 +5,7 @@ * view the LICENSE file that was distributed with this source code. */ +import { SchemaError } from '../errors'; import { Field, Fields, @@ -29,14 +30,22 @@ import type { FiltersSchema, SortSchema } from '../schema'; import { parseKey } from '../utils'; import { buildFiltersDefaults } from './parameter/filters/validate'; +/** + * The rejected relation governing a canonical relation/field path: the path + * is the relation itself or lives underneath it. + */ +function matchRelationRejected(path: string, rejected: string[]) : string | undefined { + return rejected.find( + (name) => path === name || path.startsWith(`${name}.`), + ); +} + /** * Whether a canonical relation/field path is governed by a rejected relation — * the path is the relation itself or lives underneath it. */ export function isRelationRejected(path: string, rejected: string[]) : boolean { - return rejected.some( - (name) => path === name || path.startsWith(`${name}.`), - ); + return typeof matchRelationRejected(path, rejected) !== 'undefined'; } function joinPath(prefix: string, segment: string) : string { @@ -110,6 +119,13 @@ export function pruneRelationsByRelations(relations: IRelations, rejected: strin * `elemMatch` conditions are addressed relative to the array element, so a * running `prefix` reconstructs their absolute path before matching. Falls back * to the schema `default` when pruning empties the parameter. + * + * A sealed condition is exempt from the drop, not from the gate: pruning + * anything out of a sealed subtree would widen a condition whose seal says it + * must survive, while keeping it would join a relation the relations validator + * rejected. Neither outcome is correct, so the contradiction between the two + * validators throws {@link SchemaError} (`SCHEMA_SEALED_CONDITION_PRUNED`) + * instead of failing open. */ export function pruneFiltersByRelations( filters: IFilters, @@ -120,7 +136,7 @@ export function pruneFiltersByRelations( return filters; } - const pruned = pruneCondition(filters, rejected, ''); + const pruned = pruneCondition(filters, rejected, '', false); if (pruned && isFilters(pruned)) { return pruned; } @@ -135,23 +151,42 @@ export function pruneFiltersByRelations( return new Filters(FilterCompoundOperator.AND, conditions); } +/** + * Drop the condition at `field`, or refuse to when it is protected: a drop + * inside a sealed subtree is the one case where pruning would silently widen + * the query rather than narrow it. + */ +function drop(relation: string, field: string, sealed: boolean) : undefined { + if (sealed) { + throw SchemaError.sealedConditionPruned(relation, field); + } + + return undefined; +} + function pruneCondition( node: ICondition, rejected: string[], prefix: string, + sealed: boolean, ) : ICondition | undefined { + // the marker protects the whole subtree it heads: every condition below a + // seal is part of what the seal says must survive. + const sealed2 = sealed || !!node.sealed; + if (isFilter(node)) { const field = joinPath(prefix, node.field); + const rejectedBy = matchRelationRejected(field, rejected); if ( node.operator === FilterFieldOperator.ELEM_MATCH && isConditionValue(node.value) ) { - if (isRelationRejected(field, rejected)) { - return undefined; + if (typeof rejectedBy === 'string') { + return drop(rejectedBy, field, sealed2); } - const interior = pruneCondition(node.value, rejected, field); + const interior = pruneCondition(node.value, rejected, field, sealed2); if (!interior) { return undefined; } @@ -163,7 +198,9 @@ function pruneCondition( return node; } - return isRelationRejected(field, rejected) ? undefined : node; + return typeof rejectedBy === 'string' ? + drop(rejectedBy, field, sealed2) : + node; } if (!isFilters(node)) { @@ -172,7 +209,7 @@ function pruneCondition( const conditions : ICondition[] = []; for (const child of node.value) { - const child2 = pruneCondition(child, rejected, prefix); + const child2 = pruneCondition(child, rejected, prefix, sealed2); if (child2) { conditions.push(child2); } diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index c1c168564..36d09f56c 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -6,6 +6,7 @@ */ import { + ErrorCode, Field, Fields, Filter, @@ -24,6 +25,7 @@ import { pruneFiltersByRelations, pruneRelationsByRelations, pruneSortsByRelations, + seal, } from '../../../src'; import type { IFilters } from '../../../src'; @@ -197,4 +199,94 @@ describe('src/parser/relation-prune.ts', () => { expect(pruneFiltersByRelations(filters, ['items.owner']).value).toEqual([]); }); }); + + describe('pruneFiltersByRelations (sealed conditions)', () => { + const eq = (field: string) => new Filter(FilterFieldOperator.EQUAL, field, 'x'); + + it('throws instead of dropping a sealed leaf', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(eq('user.name')), + ]); + + expect(() => pruneFiltersByRelations(filters, ['user'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('names the rejected relation and the sealed field', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(eq('realm.id')), + ]); + + expect(() => pruneFiltersByRelations(filters, ['realm'])) + .toThrowError(/"realm".+"realm\.id"/); + }); + + it('throws instead of dropping a policy residual out of a sealed group', () => { + // the shape a filters validate hook produces: + // seal(and(, )) + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(new Filters(FilterCompoundOperator.AND, [ + eq('name'), + eq('realm.id'), + ])), + eq('realm.name'), + ]); + + expect(() => pruneFiltersByRelations(filters, ['realm'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('throws for a sealed condition nested below an unsealed group', () => { + 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 })); + }); + + it('throws instead of dropping a sealed elemMatch', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filter(FilterFieldOperator.EQUAL, 'id', 1), + )), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('throws instead of pruning the interior of a sealed elemMatch', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [ + eq('owner.name'), + new Filter(FilterFieldOperator.EQUAL, 'id', 1), + ]), + )), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items.owner'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('keeps pruning around a sealed condition it does not touch', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + seal(eq('realm_id')), + eq('user.name'), + eq('id'), + ]); + + const output = pruneFiltersByRelations(filters, ['user']); + expect(filterFields(output)).toEqual(['realm_id', 'id']); + expect(output.value[0].sealed).toBe(true); + }); + }); }); diff --git a/packages/docs/guide/errors.md b/packages/docs/guide/errors.md index 30228a187..8f07c41d7 100644 --- a/packages/docs/guide/errors.md +++ b/packages/docs/guide/errors.md @@ -82,6 +82,7 @@ The URL encoders throw these too; a codec never silently changes what a query me | `SCHEMA_NAME_INVALID` | `registry.add()` with a schema that has no `name` | | `SCHEMA_UNRESOLVABLE` | `registry.getOrFail()` for a name that isn't registered | | `SCHEMA_KEY_VALIDATOR_CONFLICT` | a `fields`/`relations`/`sort` sub-schema declares both [`validate` and `validateMany`](/guide/schemas#batched-validation-with-validatemany); thrown while the schema is constructed, since there is no sensible precedence between them | +| `SCHEMA_SEALED_CONDITION_PRUNED` | the [relations gate](/guide/relations#validate-hooks) rejected a relation that a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition needs; the two validators contradict each other, see [scoping a filterable field](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) | | `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` | `parse()` (or a synchronous codec method) encountered an async validator (a filter validator or a key validation hook); use the corresponding `Async` method | | `SCHEMA_ENTITY_MISMATCH` | `assertSchemaMatchesEntity` (`@rapiq/adapter-typeorm`) found schema keys unknown to the entity; thrown as `SchemaEntityMismatchError`, which carries the offending `schema`, `entity` and `keys`; see [validating schemas against entities](/packages/adapter-typeorm#validating-schemas-against-entities) | diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index de29ea4b6..53b195dd2 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -211,6 +211,8 @@ defineSchema({ Two consequences of a compound replacement mirror [`and()` injection](/guide/merging-queries#and-or-wrap-inject). A later replace-merge carries the group through as one conjunct instead of displacing the injected scoping, and [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement)ing it keeps that true across a normalization pass. And the legacy simple URL dialect cannot express compounds: a schema-aware `encode()` through that dialect throws a typed `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it fine. +A sealed replacement is also exempt from relation pruning. When it names a relation the [relations hook](/guide/relations#validate-hooks) rejects, the two hooks contradict each other and parsing throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) rather than dropping the scoping. An unsealed replacement is displaceable and gets pruned like any other condition. + Use the synchronous `parse()` / `decode()` / schema-aware `encode()` methods when every validator is synchronous. Use their `Async` counterparts when a validator may be asynchronous: ```typescript diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index f0bec8d5f..a95ca0580 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -67,6 +67,8 @@ const scope = seal(eq('realm_id', actor.realmId)); Sealing is immutable (a sealed copy is returned) and idempotent. It is a **server-side composition marker, not wire grammar**: a sealed condition that is encoded to a URL and decoded again comes back displaceable, which is why a receiving service re-injects its own scope instead of trusting the transport. +The marker protects the whole subtree it heads, and it holds during parsing too: the [relations gate](/guide/relations#validate-hooks), which drops every key traversing a rejected relation, cannot silently prune a condition out of a sealed group. A rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead. + ### `and()` / `or()`: wrap & inject Always defined, for combining condition trees. The injected conditions are sealed, so they are part of the tree rather than candidates for replacement. A receiver already carrying that operator contributes its own conditions to the group (which keeps them mergeable); anything else becomes a child of the new group. Calling `and()` / `or()` with no conditions returns the receiver unchanged. diff --git a/packages/docs/guide/recipes/authorization.md b/packages/docs/guide/recipes/authorization.md index db06fb8a1..dd18693cf 100644 --- a/packages/docs/guide/recipes/authorization.md +++ b/packages/docs/guide/recipes/authorization.md @@ -77,6 +77,10 @@ Belt and suspenders: also leave `realm_id` out of the schema's `filters.allowed` When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `seal(and(filter, inArray('realm_id', actor.realmIds)))` and the policy residual stays attached to the leaf that triggered it, with the same displacement-proof effect on later merges. The [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement) is what makes the residual survive composition; without it the group is still never *replaced*, but a normalization pass may hoist its conditions back into the root. +::: warning A sealed residual and the relations gate must agree +A residual on a *relation* path (`seal(and(filter, eq('realm.id', ...)))`) is subject to the relations hook above, which prunes every key traversing a relation the actor may not read. Dropping the residual would hand back a wider result set than the policy allows, so a rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead: fix the contradiction by permitting the relation in the relations hook, or by scoping through a local column (`realm_id`), which the gate never touches. +::: + ## Row-scoped column access Some columns aren't a yes/no decision. An actor may read `email` on users of its own realm and nothing else, but the users of other realms must still be listed. A `fields` [validate hook](/guide/schemas#condition-verdicts) answering with a **condition** expresses exactly that: the field stays selected, and the condition marks it visible only on the rows that satisfy it. diff --git a/packages/docs/guide/relations.md b/packages/docs/guide/relations.md index 5b07ebe04..b974b4681 100644 --- a/packages/docs/guide/relations.md +++ b/packages/docs/guide/relations.md @@ -92,6 +92,8 @@ Two rules make the gate hard to bypass: - **Traversal counts.** The hook also gates relations reached through dotted [filter](/guide/filters), [field](/guide/fields) and [sort](/guide/sort) keys (`filter[items.id]`, `fields[items]`, `sort=items.name`), which the backends would otherwise auto-join. It runs **once per distinct relation** across the whole query, so a join has a single authorization point regardless of which parameter forced it. - **Rejection prunes deep.** Rejecting a relation drops every deeper relation reached through it and every dependent key in every parameter, at parse time; the pruned branch never enters the AST. Under [`throwOnFailure`](/guide/schemas#failure-behavior-drop-vs-throw) the rejection throws a `RelationsParseError` (`ErrorCode.KEY_VALIDATE_REJECTED`) instead. +One thing pruning never drops: a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) sealed a condition that names a relation this hook rejects, the two hooks contradict each other (dropping the condition would return a *wider* result set than the policy allows, keeping it would join a relation the gate refused), so parsing throws a `SchemaError` with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace). + ## Interaction with other parameters Parsed relations feed back into the other parameter parsers: fields, filters and sort input that addresses a relation (`items.id`, `realm.name`) is only accepted when the relation was requested and allowed. Request the relation first, then reference its fields. A [validate hook](#validate-hooks) participates in the same wiring: it also authorizes the relations those keys traverse. diff --git a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts index 1354ab59e..5ed144d32 100644 --- a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts +++ b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts @@ -8,10 +8,20 @@ import { ErrorCode, RelationsParseError, + SchemaError, SchemaRegistry, + and, defineSchema, + eq, + seal, +} from '@rapiq/core'; +import type { + ICondition, + IFilter, + IFilters, + IQuery, + KeyValidationScope, } from '@rapiq/core'; -import type { IFilters, IQuery, KeyValidationScope } from '@rapiq/core'; import { SimpleFieldsParser, SimpleFiltersParser, @@ -306,6 +316,96 @@ describe('relations.validate for traversed relation paths (#815)', () => { }); }); + /** + * A filters validate hook may answer with a policy residual scoping the leaf + * it saw. When that residual names a relation the relations hook rejects, + * the two hooks contradict each other: pruning the residual would return a + * wider result set than the policy allows, keeping it would join a rejected + * relation. The contradiction is a server misconfiguration, so it throws. + */ + describe('sealed policy residuals vs. relation pruning (#877)', () => { + function buildScopedRegistry( + residual: (filter: IFilter) => ICondition, + relationsValidate: (name: string) => boolean, + ) : SchemaRegistry { + const registry = new SchemaRegistry(); + registry.add(defineSchema, Actor>({ + name: 'user', + filters: { + allowed: ['id', 'name'], + // "you may filter by name, but only within your realms" + validate: (filter) => (filter.field === 'name' ? + residual(filter) : + filter), + }, + relations: { allowed: ['realm'], validate: relationsValidate }, + schemaMapping: { realm: 'realm' }, + })); + registry.add(defineSchema({ + name: 'realm', + filters: { allowed: ['id', 'name'] }, + })); + + return registry; + } + + const sealedResidual = (filter: IFilter) => seal(and(filter, eq('realm.id', 'SCOPE'))); + const input = { filters: { name: 'John', 'realm.name': 'master' } }; + + it('keeps the residual when the relations hook permits the relation', () => { + const parser = new SimpleParser(buildScopedRegistry(sealedResidual, () => true)); + + const query = parser.parse(input, { schema: 'user', context: actor }); + expect(filterFields(query.filters)).toEqual(['name', 'realm.id', 'realm.name']); + }); + + it('throws instead of pruning the residual when the relation is rejected', () => { + const parser = new SimpleParser(buildScopedRegistry( + sealedResidual, + (name) => name !== 'realm', + )); + + expect.assertions(2); + try { + parser.parse(input, { schema: 'user', context: actor }); + } catch (e) { + expect(e).toBeInstanceOf(SchemaError); + expect((e as SchemaError).code).toEqual(ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED); + } + }); + + it('throws on the async path too', async () => { + const parser = new SimpleParser(buildScopedRegistry( + sealedResidual, + (name) => name !== 'realm', + )); + + await expect(parser.parseAsync(input, { schema: 'user', context: actor })) + .rejects.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('throws for a standalone filters parse', () => { + const registry = buildScopedRegistry(sealedResidual, (name) => name !== 'realm'); + + expect(() => new SimpleFiltersParser(registry).parse( + input.filters, + { schema: 'user', context: actor }, + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('prunes an UNSEALED residual, which is what the seal marks', () => { + // without seal() the residual is an ordinary displaceable condition: + // pruning drops it like any other client-owned leaf. + const parser = new SimpleParser(buildScopedRegistry( + (filter) => and(filter, eq('realm.id', 'SCOPE')), + (name) => name !== 'realm', + )); + + const query = parser.parse(input, { schema: 'user', context: actor }); + expect(filterFields(query.filters)).toEqual(['name']); + }); + }); + // Tripwire for the resolveKey choke point (plan 022): every wire operator // resolves its field through resolveKey, so it must fire the relations hook. describe('filter operator matrix — no wire operator escapes the gate', () => { From eef50407924e76f1bc96761cf754a6de5ed10c1e Mon Sep 17 00:00:00 2001 From: tada5hi Date: Mon, 3 Aug 2026 16:54:18 +0200 Subject: [PATCH 2/3] fix(core): apply the sealed-condition check to the default fallback 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. --- packages/core/src/parser/relation-prune.ts | 44 ++++++++++++++----- .../test/unit/parser/relation-prune.spec.ts | 42 ++++++++++++++++++ packages/docs/guide/relations.md | 4 +- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index 98d858503..39c6c1936 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -121,11 +121,15 @@ export function pruneRelationsByRelations(relations: IRelations, rejected: strin * to the schema `default` when pruning empties the parameter. * * A sealed condition is exempt from the drop, not from the gate: pruning - * anything out of a sealed subtree would widen a condition whose seal says it - * must survive, while keeping it would join a relation the relations validator - * rejected. Neither outcome is correct, so the contradiction between the two - * validators throws {@link SchemaError} (`SCHEMA_SEALED_CONDITION_PRUNED`) - * instead of failing open. + * anything out of a sealed subtree returns a query the sealed condition does + * not describe (wider under the `and(, )` shape a filters + * validator produces, narrower under an `or`), while keeping it would join a + * relation the relations validator rejected. Neither outcome is correct, so the + * contradiction between the two validators throws {@link SchemaError} + * (`SCHEMA_SEALED_CONDITION_PRUNED`) instead of resolving it silently. The + * decision is per node, not per operator: the seal says the condition survives + * composition intact, and pruning is not asked to reason about which shapes + * happen to fail open. */ export function pruneFiltersByRelations( filters: IFilters, @@ -146,17 +150,35 @@ export function pruneFiltersByRelations( conditions = [pruned]; } else { conditions = schema ? buildFiltersDefaults(schema) : []; + + // The default is the server's own baseline and is exempt from the gate: + // it is re-applied here un-pruned, even when it names a rejected + // relation. A sealed default asserts the same must-survive contract as + // a validator residual though, so it is checked: without this, the very + // same default would throw or survive depending on whether the client + // sent a filter of its own, which is what decides whether it was + // materialized before this pass or after it. + for (const condition of conditions) { + assertSealedSurvivesPruning(condition, rejected); + } } return new Filters(FilterCompoundOperator.AND, conditions); } /** - * Drop the condition at `field`, or refuse to when it is protected: a drop - * inside a sealed subtree is the one case where pruning would silently widen - * the query rather than narrow it. + * Raise the sealed-condition contradiction for a condition that is kept rather + * than pruned. The pruned copy is discarded: only the throw matters here. + */ +function assertSealedSurvivesPruning(condition: ICondition, rejected: string[]) : void { + pruneCondition(condition, rejected, '', false); +} + +/** + * Drop the condition at `field`, unless it is protected: a drop inside a sealed + * subtree is the one case pruning must refuse rather than resolve. */ -function drop(relation: string, field: string, sealed: boolean) : undefined { +function dropUnlessSealed(relation: string, field: string, sealed: boolean) : undefined { if (sealed) { throw SchemaError.sealedConditionPruned(relation, field); } @@ -183,7 +205,7 @@ function pruneCondition( isConditionValue(node.value) ) { if (typeof rejectedBy === 'string') { - return drop(rejectedBy, field, sealed2); + return dropUnlessSealed(rejectedBy, field, sealed2); } const interior = pruneCondition(node.value, rejected, field, sealed2); @@ -199,7 +221,7 @@ function pruneCondition( } return typeof rejectedBy === 'string' ? - drop(rejectedBy, field, sealed2) : + dropUnlessSealed(rejectedBy, field, sealed2) : node; } diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index 36d09f56c..1c5932a7b 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -277,6 +277,28 @@ describe('src/parser/relation-prune.ts', () => { .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, [ + seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])), + ]); + + expect(() => pruneFiltersByRelations(filters, ['user'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + + it('throws for a sealed condition below a NOT', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filters(FilterCompoundOperator.NOT, [seal(eq('user.name'))]), + ]); + + expect(() => pruneFiltersByRelations(filters, ['user'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + it('keeps pruning around a sealed condition it does not touch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ seal(eq('realm_id')), @@ -288,5 +310,25 @@ describe('src/parser/relation-prune.ts', () => { expect(filterFields(output)).toEqual(['realm_id', 'id']); expect(output.value[0].sealed).toBe(true); }); + + it('re-applies an UNSEALED default naming a rejected relation', () => { + // the server-authored baseline is exempt from the gate, which is + // why the default fallback is not pruned. + const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]); + const schema = defineFiltersSchema({ default: eq('user.b') }); + + expect(filterFields(pruneFiltersByRelations(filters, ['user'], schema))).toEqual(['user.b']); + }); + + 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 })); + }); }); }); diff --git a/packages/docs/guide/relations.md b/packages/docs/guide/relations.md index b974b4681..516b7aad6 100644 --- a/packages/docs/guide/relations.md +++ b/packages/docs/guide/relations.md @@ -92,7 +92,9 @@ Two rules make the gate hard to bypass: - **Traversal counts.** The hook also gates relations reached through dotted [filter](/guide/filters), [field](/guide/fields) and [sort](/guide/sort) keys (`filter[items.id]`, `fields[items]`, `sort=items.name`), which the backends would otherwise auto-join. It runs **once per distinct relation** across the whole query, so a join has a single authorization point regardless of which parameter forced it. - **Rejection prunes deep.** Rejecting a relation drops every deeper relation reached through it and every dependent key in every parameter, at parse time; the pruned branch never enters the AST. Under [`throwOnFailure`](/guide/schemas#failure-behavior-drop-vs-throw) the rejection throws a `RelationsParseError` (`ErrorCode.KEY_VALIDATE_REJECTED`) instead. -One thing pruning never drops: a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) sealed a condition that names a relation this hook rejects, the two hooks contradict each other (dropping the condition would return a *wider* result set than the policy allows, keeping it would join a relation the gate refused), so parsing throws a `SchemaError` with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace). +One thing pruning never drops: a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) sealed a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a *wider* one for the usual `and(, )` residual), keeping it joins a relation the gate refused. Parsing therefore throws a `SchemaError` with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED` rather than picking one. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace). + +A schema `default` filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Sealing a default opts it back in, since a seal asserts the same must-survive contract as a residual and raises the same error. ## Interaction with other parameters From 7b59c7739f5240997e57702f28eb19e4e91ff220 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Tue, 4 Aug 2026 10:02:37 +0200 Subject: [PATCH 3/3] docs: recommend sealing the residual instead of the group The filters `validate` recipe recommended `seal(and(filter, ))`. 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())` 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. --- .agents/architecture.md | 2 +- packages/docs/guide/filters.md | 4 +- packages/docs/guide/merging-queries.md | 2 +- packages/docs/guide/recipes/authorization.md | 6 +- .../unit/parser/relations-traversal.spec.ts | 58 +++++++++++++++++++ 5 files changed, 65 insertions(+), 7 deletions(-) diff --git a/.agents/architecture.md b/.agents/architecture.md index ca84c0a97..e9440e70b 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -29,7 +29,7 @@ createURLCodec().encode (@rapiq/codec-url) The `Query` AST is an **intermediate representation (IR)**. Every package plays exactly one role around it: 1. **Define & interact** — client-side construction (plan 012): `defineQuery(QueryBuildInput)` + per-parameter `define*` fragment factories desugar typed input (scalars → `eq`, bare arrays → `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST — schema-free, no parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` → `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. Queries compose immutably via `mergeQueries` (left-priority; fields/relations/sorts keyed by name, pagination per-property) and the `Filters` combinators: `merge()` = per-field replace over the displaceable leaves of a root-AND, total (issue #875 — a sealed condition, a nested group or a non-AND root is inert: never displaced, always and-ed in, so a merge only ever narrows); `and()`/`or()` = wrap & inject, sealing every injected condition (server scoping — displaceability rides on the node as `ICondition.sealed`, so no normalization pass can strip it; `flatten()` never hoists a sealed group, and `seal()` exposes the marker for validator residuals). The seal is server-side only: no wire dialect carries it. `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed — `defineQuery` replaces it. -2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840 — per-leaf policy residuals like `and(, )` stay attached to the leaf; the simple URL dialect then throws its usual typed `FEATURE_UNSUPPORTED` on encode, and a later `merge()` carries the group through as one inert conjunct — `seal(...)` it to keep that true across a `flatten()`) or reject it, all without flattening compound structure. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. +2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840 — per-leaf policy residuals like `and(, seal())` stay attached to the leaf; the simple URL dialect then throws its usual typed `FEATURE_UNSUPPORTED` on encode, and a later `merge()` carries the group through as one inert conjunct — seal the SCOPE, not the group, so the marker survives a `flatten()` while the client's own leaf stays prunable by the relations gate, issue #877) or reject it, all without flattening compound structure. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. 3. **Consume the IR** — either interpret/walk it directly (`@rapiq/adapter-sql`, `@rapiq/adapter-typeorm` via visitors; `@rapiq/adapter-prisma`/`@rapiq/adapter-drizzle` serialize it into plain args/config objects; `@rapiq/adapter-memory` compiles it into plain functions to evaluate in-memory objects/arrays), or… 4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url` owns the complete HTTP URL wire format. The public `URLCodec` façade accepts a raw query string or a pre-parsed query object (Express `req.query`), maps wire names (`URLParameter`: `filter`, `page`, `include`, …) to canonical parameters and delegates to internal expression/simple strategies. Encoding writes stamped expression filters by default. Decoding dispatches stamped payloads and recognizes untagged expression strings or legacy simple bracket filters, so v2 follows a read-both/write-expression migration. App2 then works with the same IR. diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index 53b195dd2..4148fb658 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -207,11 +207,11 @@ defineSchema({ | `validate` | Sync or async per-filter hook: inspect a parsed `Filter`, replace it with any condition, or reject it. Receives the [parse context](/guide/schemas#validate-hooks-parse-context) as its second argument. | | `caseSensitive` | Fields whose equality comparisons stay exact instead of the [case-insensitive default](#case-sensitivity). | -`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `seal(and(filter, inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. +`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `and(filter, seal(inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. Two consequences of a compound replacement mirror [`and()` injection](/guide/merging-queries#and-or-wrap-inject). A later replace-merge carries the group through as one conjunct instead of displacing the injected scoping, and [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement)ing it keeps that true across a normalization pass. And the legacy simple URL dialect cannot express compounds: a schema-aware `encode()` through that dialect throws a typed `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it fine. -A sealed replacement is also exempt from relation pruning. When it names a relation the [relations hook](/guide/relations#validate-hooks) rejects, the two hooks contradict each other and parsing throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) rather than dropping the scoping. An unsealed replacement is displaceable and gets pruned like any other condition. +Seal the **residual**, not the whole group. Both shapes survive a merge, because a group is never displaced as a unit either, but the seal is what keeps the residual protected once `flatten()` hoists it into the root, and keeping the client's own leaf outside it matters for [relation pruning](/guide/relations#validate-hooks): a sealed condition is exempt from the gate, so `seal(and(filter, ...))` also protects the client leaf it wraps, and a client filtering on a relation it may not traverse then hits `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead of having that filter dropped. With `and(filter, seal(...))` the client leaf prunes away normally and only a residual that itself names a rejected relation raises the error, which is the genuine contradiction. An unsealed replacement is displaceable throughout and gets pruned like any other condition. Use the synchronous `parse()` / `decode()` / schema-aware `encode()` methods when every validator is synchronous. Use their `Async` counterparts when a validator may be asynchronous: diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index a95ca0580..148c3e6f4 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -67,7 +67,7 @@ const scope = seal(eq('realm_id', actor.realmId)); Sealing is immutable (a sealed copy is returned) and idempotent. It is a **server-side composition marker, not wire grammar**: a sealed condition that is encoded to a URL and decoded again comes back displaceable, which is why a receiving service re-injects its own scope instead of trusting the transport. -The marker protects the whole subtree it heads, and it holds during parsing too: the [relations gate](/guide/relations#validate-hooks), which drops every key traversing a rejected relation, cannot silently prune a condition out of a sealed group. A rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead. +The marker protects the whole subtree it heads, and it holds during parsing too: the [relations gate](/guide/relations#validate-hooks), which drops every key traversing a rejected relation, cannot silently prune a condition out of a sealed group. A rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead. Subtree-wide protection is why a [policy residual](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) seals the *scope* rather than the group around it: sealing the group would protect the client's own condition too. ### `and()` / `or()`: wrap & inject diff --git a/packages/docs/guide/recipes/authorization.md b/packages/docs/guide/recipes/authorization.md index dd18693cf..8536eb761 100644 --- a/packages/docs/guide/recipes/authorization.md +++ b/packages/docs/guide/recipes/authorization.md @@ -75,10 +75,10 @@ Why `and()` and not a merge? `and()` injects the condition **sealed**, which mar Belt and suspenders: also leave `realm_id` out of the schema's `filters.allowed` list, and clients can't even *mention* it. -When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `seal(and(filter, inArray('realm_id', actor.realmIds)))` and the policy residual stays attached to the leaf that triggered it, with the same displacement-proof effect on later merges. The [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement) is what makes the residual survive composition; without it the group is still never *replaced*, but a normalization pass may hoist its conditions back into the root. +When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `and(filter, seal(inArray('realm_id', actor.realmIds)))` and the policy residual stays attached to the leaf that triggered it, with the same displacement-proof effect on later merges. The [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement) is what makes the residual survive composition; without it the group is still never *replaced*, but a normalization pass may hoist its conditions into the root, where an unmarked residual becomes replaceable again. -::: warning A sealed residual and the relations gate must agree -A residual on a *relation* path (`seal(and(filter, eq('realm.id', ...)))`) is subject to the relations hook above, which prunes every key traversing a relation the actor may not read. Dropping the residual would hand back a wider result set than the policy allows, so a rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead: fix the contradiction by permitting the relation in the relations hook, or by scoping through a local column (`realm_id`), which the gate never touches. +::: tip Seal the residual, not the group +`seal(and(filter, ...))` protects the same residual, and additionally protects the client leaf it wraps, which is not something you want. A sealed condition is exempt from the relations gate above, so that shape turns "this actor may not traverse `realm`" into a thrown `SchemaError` the moment the client filters on `realm.name`, instead of simply dropping that filter. Sealing only the residual keeps the client's leaf prunable and reserves the error for the real contradiction: a residual that itself names a relation the gate rejects. Scoping through a local column (`realm_id`) never traverses a relation, so it never reaches the gate at all. ::: ## Row-scoped column access diff --git a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts index 5ed144d32..f2dbcfea4 100644 --- a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts +++ b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts @@ -393,6 +393,64 @@ describe('relations.validate for traversed relation paths (#815)', () => { )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); }); + /** + * The shape the docs recommend: seal the residual, leave the client's + * own leaf outside it. Both shapes resist a merge, but this one keeps + * the client leaf prunable, so the gate stays a drop for the client and + * the error stays reserved for a residual that itself names a rejected + * relation. + */ + describe('seal the residual, not the group', () => { + const localResidual = (filter: IFilter) => and(filter, seal(eq('realm_id', 'SCOPE'))); + const relationResidual = (filter: IFilter) => and(filter, seal(eq('realm.id', 'SCOPE'))); + + function buildLocalRegistry(residual: (filter: IFilter) => ICondition) : SchemaRegistry { + const registry = new SchemaRegistry(); + registry.add(defineSchema, Actor>({ + name: 'user', + filters: { allowed: ['id', 'name'], validate: residual }, + relations: { allowed: ['realm'], validate: (name) => name !== 'realm' }, + schemaMapping: { realm: 'realm' }, + })); + registry.add(defineSchema({ name: 'realm', filters: { allowed: ['id', 'name'] } })); + + return registry; + } + + it('drops the client leaf and keeps the residual, no error', () => { + const parser = new SimpleParser(buildLocalRegistry(localResidual)); + + const query = parser.parse( + { filters: { 'realm.name': 'master' } }, + { schema: 'user', context: actor }, + ); + + expect(filterFields(query.filters)).toEqual(['realm_id']); + }); + + it('keeps the residual sealed, so a later merge cannot displace it', () => { + const parser = new SimpleParser(buildLocalRegistry(localResidual)); + + const query = parser.parse({ filters: { name: 'John' } }, { schema: 'user', context: actor }); + const [group] = query.filters.value as [IFilters]; + const residual = group.value[1] as IFilter; + + expect(residual.field).toBe('realm_id'); + expect(residual.sealed).toBe(true); + // and it stays marked once normalization hoists it into the root + expect(query.filters.flatten().value.some((c) => (c as IFilter).sealed)).toBe(true); + }); + + it('still throws when the residual itself names the rejected relation', () => { + const parser = new SimpleParser(buildLocalRegistry(relationResidual)); + + expect(() => parser.parse( + { filters: { name: 'John', 'realm.name': 'master' } }, + { schema: 'user', context: actor }, + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + }); + }); + it('prunes an UNSEALED residual, which is what the seal marks', () => { // without seal() the residual is an ordinary displaceable condition: // pruning drops it like any other client-owned leaf.