DynamicWhere.ex
DynamicWhere.exv3.4.0·docs

Breaking Changes & Known Limitations

DynamicWhere.ex is intentionally opinionated about how queries are shaped. The fifty-four points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

Behaviour changes in 3.4.0
Points 46 to 54 changed in 3.4.0. A declared purpose can carry page caps of its own, so an export reads every match in one statement while a screen keeps its page (point 46), and a context stores its purpose trimmed (point 47). Four security fixes: the parts of a struct take the struct member's policy, where a denied struct's parts were filtered on and returned (point 48); a typed selection beneath a collection of structs carries only what it names, where each element came back whole (point 51); a transform on a struct's member is applied, where every one was skipped, with a struct that cannot be written back now failing the query (point 52); and a member of a nullable struct is policed as the policy names it, where a path through Value matched nothing (point 53). Under Strict, a path through a member a constructor builds is refused rather than failing in the provider (point 49), and a typed selection through a struct returns its values rather than its default (point 50). And Bind reports a value a property refuses as the InvalidOperationException it documents (point 54).
Behaviour changes in 3.3.0
Points 30 to 45 changed in 3.3.0. Under Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30). LastTrace is assigned before a request is sanitized, so a refused request leaves its own trace readable (point 31). And DwPolicy.Configure takes a second call asking for the posture already in force, which is what lets an integration suite start several hosts over one composition root (point 32). A query that exhausts the audit buffer under Strict is now refused with the clause's own field refusal rather than with CapExceeded (point 33). Four more refusals that named a field under Strict name the clause instead (point 34). And [DwAudit] records the audited members a projection the caller never named hands back, which an empty Selects used to return unrecorded (point 36), and MaxNavigationDepth answers an alias the way it answers a name that matches nothing (point 35). A path the attribute walk cannot name is policed rather than allowed: one beneath a member whose type the framework declares (point 37), a transform the walk reached no path to (point 38), a type first met at the walk's depth limit (point 39), and a path past four segments where a host raised MaxNavigationDepth (point 40). A page number whose offset passes Int32 is an empty page (point 41). And the four methods that hand back a query for the caller to run are refused on a type whose only transformed member sits where the policy names no path (point 42), and [DwAudit] records a member no path names once the rows show it (point 43). Two shapes of malformed request that used to reach the caller as a five-hundred are refused as requests now: a Number value the expression parser cannot read, or cannot compare with the member the condition names (point 44), and a null entry inside one of the request's lists (point 45).
Behaviour changes in 3.2.0
Points 25 to 29 changed in 3.2.0, and each is visible to code written for 3.1.0. A guarded query that sends no Selects synthesizes its projection whenever a denied value can reach the result, and the projection keeps what the source carries: the assigned members of a projected row, and an entity's columns, owned and complex members, while an entity's navigations and the objects of a row in memory are left out (point 25). Selects naming a member is gated against every denial beneath it, and a narrowing that cannot be built is refused (point 26). A declared default order reaches a projection that builds T and assigns every field the default names (point 27). Every async terminal gains overloads that take a CancellationToken, so ToListAsync(filter, default) no longer compiles, and the async dynamic Filter and the async Summary read through EF Core's asynchronous operators (point 28). And a type in an application namespace that starts with System is policed (point 29).
Behaviour changes in 3.1.0
Eleven behaviours changed in 3.1.0. Each one fixes a defect, and each one is visible to a caller that depended on the old shape. Date comparisons now read the member's type before building the predicate (point 14) and accept a value only in ISO 8601, a year-first form, or a format the deployment declares (point 15); an unpaged PageCount is now 1 rather than TotalCount (point 16); the Select constructor refusal carries a stable code instead of an English sentence (point 17); a guarded query is refused unless its context was prepared (point 18); a segment's condition sets are combined, ordered and paged in the database (point 19); a member named Root, It or Parent is read as that member rather than as the row, and ParsingConfig.Default is no longer read (point 20); a guarded request whose condition groups nest deeper than MaxConditionDepth, or a guarded segment with more sets than MaxConditionSets, is refused (point 21); under the strict tier a result no longer carries the policy trace (point 22), and a field that does not exist is refused exactly as a denied one is, with no field named (point 23); and a guarded condition carrying more values than MaxConditionValues, or a guarded summary computing more aggregates than MaxAggregates, is refused, while a Count is now charged to the query budget (point 24).
Upgrade to 2.1.4
Releases before 2.1.4 did not escape condition values before embedding them in the generated expression. A value carrying a \ or a " ended its string literal early — a search term ending in \ threw ParseException: ')' or ',' expected, and a crafted value could close the literal and append predicate logic of its own, returning rows the filter should never have matched. See point 12.

1. Parameterless Constructor Required for Select Projection

Select<T>(fields) requires T to have a parameterless (default) constructor. If T does not have one, a LogicException is thrown, with SelectTypeMustHaveParameterlessConstructor as its Message and the type's name on Subject — see point 17 for what that message used to be. Most EF Core entity classes have parameterless constructors by default.

Hard requirement
Records with positional parameters and classes whose only constructor takes required arguments are not usable as the target type for Select<T> or for the typed Filter projection. Use SelectDynamic instead, or add an explicit parameterless constructor to your DTO.

2. Segment Operations are Async-Only

ToListAsync<T>(Segment) is the only entry point for segment queries. There is no synchronous ToList<T>(Segment) variant. The condition sets are combined into one query that the database orders and pages (point 19). Under ApplyPolicy, DwCaps.MaxConditionSets (default 10) bounds how many sets one request may carry.

No synchronous overload
If you need to compose UNION / INTERSECT / EXCEPT across multiple condition sets you must use the async pipeline. See Segment and ToListAsync<T>(Segment).

3. Case-Insensitive Operators use .ToLower()

All I* operators (e.g., IContains, IEqual) normalize both sides via .ToLower(). This works correctly with SQL Server (COLLATE is typically case‑insensitive), but be aware of potential performance or behavior differences on case‑sensitive database collations (e.g., PostgreSQL with C locale).

Mind your collation
On case‑sensitive collations the provider may not be able to use an index for a LOWER(column) predicate, which can turn a fast seek into a table scan. If you target PostgreSQL with C locale, consider a functional index on LOWER(column) or use the case‑sensitive operator variants.

4. Enum Filtering Matches the Member Name, Whatever the Column Stores

DataType.Enum compares the member name you send ("Pending"), and it works whether the column stores names or integers: the dynamic LINQ parser converts the name to the enum value before EF Core translates the comparison. DataType.Enum has no value-format check at all, so nothing is rejected at validation time.

Substring operators need a string member
Contains, StartsWith and EndsWith (and their Not forms) only bind on a member that really is a string. On an enum-typed member the parser has no such method to bind and throws ParseException from System.Linq.Dynamic.Core — as does a name that is not a member of the enum. For enums use Equal, NotEqual, In, NotIn, IsNull or IsNotNull.

5. Having Clause Fields Reference Aliases, Not Entity Properties

In a Summary, Having is itself a ConditionGroup, so the path is Having.Conditions[].Field — and each field in its nested SubConditionGroups as well. Every one of them must match an AggregateBy.Alias, not an entity property path.

Aliases only
A Having condition that references an entity property directly (e.g., "UnitPrice" instead of the alias "AvgPrice") throws HavingFieldMustExistInAggregateByAlias. See the error code reference.

6. GroupBy Flattens Dotted Field Names in Results

Dotted GroupBy fields (e.g., Category.Name) produce flattened alias keys in the dynamic result objects (e.g., CategoryName). Order fields in Summary.Orders should use the dotted form; the library handles alias mapping internally.

Dotted in → flattened out
Inside the result, access the grouped column as row.CategoryName — not row.Category.Name. When writing Summary.Orders entries, keep the dotted form ("Category.Name") and the library will map it to the flattened alias for you.

7. Collection Navigation Auto-Wraps with .Any()

When a condition's Field path traverses a collection property, the library automatically inserts .Any() lambdas. This means the filter checks if any item in the collection matches — there is no built‑in .All() support.

No .All() support
There is no negated .Any() either. The Not operators are emitted inside the Any lambda, so NotEqual on a path through a collection means "some element does not match", not "no element matches". Universal quantification has to be expressed outside the library — split it into two queries, or apply it in memory on the materialized result. See the nested‑collection example.

8. Thread-Safe Cache, But Configuration Changes are Eventually Consistent

CacheExpose.Configure() is thread‑safe, but already‑in‑progress operations may use the previous configuration until they complete.

In-flight calls keep the old config
Treat configuration as a startup concern when possible. Hot‑swapping the cache strategy at peak traffic is safe but won't retroactively re‑classify ongoing operations. See cache configuration.
Fixed in 3.1.0: an invented field name stayed in memory
Validating a field path recorded an access for eviction before the path was validated. A path that fails adds no cache entry for eviction to remove, so under LRU — the default — or LFU every distinct invalid name a caller sent kept its record for the life of the process, and a caller sending unique invented names grew the process without limit, fastest under a strict policy, which resolves every unknown name in a request. A path is now tracked only once it has validated.

9. getQueryString Parameter Requires EF Core Provider

Passing getQueryString: true to ToList / ToListAsync calls .ToQueryString(), which needs an active EF Core database provider to produce SQL. On an in‑memory IEnumerable<T> it does not fail: QueryString holds a placeholder sentence where the SQL would be.

EF Core only
Only enable getQueryString when the source is a real DbSet<T> or an EF Core‑backed IQueryable<T>. On an in‑memory collection you get the placeholder sentence rather than SQL. Use it as a development aid, not as a production feature.

10. SelectDynamic / FilterDynamic / ToListDynamic / ToListAsyncDynamic Return Non-Generic Types

These methods return IQueryable or FilterResult<dynamic> instead of the strongly‑typed equivalents. Downstream code must work with dynamic objects. Property names in the dynamic result follow these rules:

  • Non‑dotted paths (Name, Category, OrderItems, …) are projected as‑is — access them by their exact field name at runtime.
  • Dotted paths through reference navigations (e.g., Category.Name) produce nested dynamic objects reflecting the navigation hierarchy — access them as result.Category.Name, not as a flat CategoryName.
  • Dotted paths through collection navigations (e.g., Category.Vendors.Id) generate a Select lambda per collection segment — the result is a nested collection of dynamic objects accessible as result.Category.Vendors[0].Id.
  • Multiple dotted fields sharing the same root segment (e.g., Category.Name + Category.Id) are merged into a single nested object: result.Category.Name and result.Category.Id.
  • Mixed whole‑navigation + sub‑field paths: when both "Category" and "Category.Name" are requested, the sub‑field projection takes precedence and "Category" is silently dropped.
Two different shapes — typed vs. dynamic
Note that SelectDynamic preserves the navigation hierarchy (nested), whereas the typed GroupBy result flattens dotted names (point 6). They are different on purpose — pick the extension method that matches the shape your client expects.

11. All Filter Extensions Apply Order and Page Before the Select Projection

All Filter extensions — both typed (Filter<T>, ToList<T>(Filter), ToListAsync<T>(Filter), and ToListAsync<T>(Segment) since 3.1.0) and dynamic (FilterDynamic<T>, ToListDynamic<T>, ToListAsyncDynamic<T>) — apply ordering and pagination on the typed IQueryable<T> before the select projection. This ensures that field names referenced in orders always resolve against the original entity type T, regardless of which fields are projected.

Order on the entity, project after
You can sort by a column that is not in your Select.Fields list. The library resolves orders[].field against T's original property graph, then projects to the requested subset. This is the right behaviour for almost every list endpoint — it just surprises people who expected the order field to also need to be in the projection.

12. Condition Values Become Escaped Literals, Not Query Parameters

A condition's Values are written into the generated dynamic LINQ expression as string literals. Since 2.1.4 they are escaped first — a backslash is doubled and a double quote is backslash‑escaped — so any value matches literally, \ and " included, and a value can no longer break out of its literal to alter the predicate.

The literal then reaches the provider as a constant, so EF Core inlines it into the SQL rather than binding a parameter — a Contains on "الثانية\" renders as instr(lower("p"."Name"), 'الثانية\') > 0. EF Core escapes that literal for SQL itself, so this is not a SQL injection path.

No plan-cache reuse across distinct values
Because values are inlined rather than parameterized, each distinct search term produces a distinct SQL statement. On SQL Server that means a separate plan‑cache entry per term. If a high‑cardinality free‑text filter is on a hot path, consider enabling forced parameterization at the database level.
Fixed in 3.1.0: a long In list ended the process
In, NotIn, IIn and INotIn on Text, and In and NotIn on Guid, Number and Enum, joined their values into one flat chain — f == "a" || f == "b" || … — which the expression parser reads as one level of nesting per value. EF Core and the expression compiler walk that tree recursively, so a single condition carrying about seven hundred values overflowed the request thread's stack, guarded or not, and a stack overflow ends the process: no catch can stop it. A list longer than 32 values is now nested as a balanced tree of flat chains of at most 32 terms, all joined by the same operator. A list of 32 or fewer is written exactly as before, so its predicate and its SQL do not change, and a longer list returns the same rows. Under ApplyPolicy, MaxConditionValues also bounds the values of one condition (point 24).

13. AggregateBy.Alias Must Be a Plain Identifier

The alias is emitted verbatim into the generated Select projection, so since 2.1.4 it must be a leading letter or underscore followed by letters, digits, or underscores. Letters are matched by Unicode category, so a non‑Latin alias such as "المجموع" stays valid.

Tightened in 2.1.4
Earlier releases only rejected aliases containing a dot, which let an alias holding a comma — "Total, 1 as Leaked" — append terms of its own to the projection. Aliases carrying any other separator (a space, a dash) never parsed in the first place, so nothing that previously worked is rejected; a malformed alias now throws AggregationMustHasValidAlias at validation time instead of failing later. See AggregateBy.

14. Date Comparisons Resolve the Member's Type

Before 3.1.0 every DataType.Date and DataType.DateTime condition produced the same string per operator, whatever the member actually was — for GreaterThanOrEqual, {field} != null && {field} >= DateTime.Parse("…"). The builder now reads the member's CLR type first and emits the null guard, the literal, and the .Date access that type can actually take.

  • The null guard is emitted only for a member that can be null. A non-nullable member reached through a navigation — Approval.ApprovedAt — guards each navigation instead: Approval != null && ….
  • A DateTimeOffset member is compared against a DateTimeOffset literal; a DateTime member against a DateTime literal; a DateOnly member against a DateOnly, as a day under both data types.
  • A nullable member is unwrapped with .Value under its guard, so DataType.Date emits {field}.Value.Date — or {field}.Value on a DateOnly?, which is already a day.
  • A Having condition names an aggregate alias rather than a member, so the type comes from what the alias stands for: a Minimum, Maximum, FirstOrDefault or LastOrDefault returns one of the values it read, so it has that member's type — nullable if the member is. On PostgreSQL a Having on Maximum of a timestamptz column becomes HAVING max(col) > TIMESTAMPTZ '…'.
Fixed: DateTimeOffset members were unusable
Until 3.1.0 every comparison on a DateTimeOffset member threw. On a non-nullable one the null guard compared a struct against null and failed with InvalidOperationException: The binary operator NotEqual is not defined for the types 'System.DateTimeOffset' and 'System.Object'. On a nullable one the literal was built as the wrong type — for GreaterThanOrEqual, ParseException: Operator '>=' incompatible with operand types 'DateTimeOffset?' and 'DateTime'. And DataType.Date on any nullable date member threw, because a nullable has no .Date — on a DateTime?, ParseException: No property or field 'Date' exists in type 'DateTime?'. All of these now work.
Fixed: DateOnly members could not be compared
Until 3.1.0 no comparison on a DateOnly member worked. Only IsNull and IsNotNull did. DataType.Date asked it for a .Date it does not have — ParseException: No property or field 'Date' exists in type 'DateOnly' — and DataType.DateTime compared it against a DateTime literal — ParseException: Operator '==' incompatible with operand types 'DateOnly' and 'DateTime'. Both data types now compare a DateOnly as a day, and a nullable DateOnly is guarded like any other nullable date. On PostgreSQL an Equal becomes WHERE "Day" = DATE '2026-09-01'.
IsNull answers a constant on a non-nullable member of the entity itself
With the guard gone, there is nothing left for IsNull and IsNotNull to test on a non-nullable date member of the entity itself, so they answer with the constant the guard already implied: IsNull is false and IsNotNull is true. On PostgreSQL that reaches the database as WHERE FALSE and, for IsNotNull, as no predicate at all. On a non-nullable DateTimeOffset, where both used to throw like every other operator, they now answer. Reached through a navigation, as in Approval.ApprovedAt, they test the navigation instead: IsNull matches the rows with no approval, because a provider reads the member of a missing approval as NULL.
Unchanged: the guard sits outside the comparison
On a nullable member the guard still wraps the whole comparison, so a null row fails every comparison — including the negative ones. A row whose date is unset does not match NotEqual and does not match NotBetween. Combine with IsNull under an Or if you want the unset rows back.

15. Date Values Are ISO 8601, Year-First, or a Declared Format

Condition values for the two date types are now read against an explicit list of formats, never the lenient .NET parser, and re-emitted in round-trip form — at validation and in the builder alike, as the member's own date type. Every deployment accepts ISO 8601 extended calendar dates (2026-09-01, optionally with a time after a T or a space, a fraction, and Z or an offset) and year-first dates with / or . (2026/09/01, 2026.09.01), plus any format the deployment declares. The other ISO 8601 forms are InvalidFormat: basic (20260901), week (2026-W36-2), ordinal (2026-244) and reduced precision (2026-09, 2026-09-01T12). The shipped predicate used to carry your raw text into a DateTime.Parse that the runtime evaluated in the host's culture, so the same filter meant different days on two servers; the host's culture and calendar now play no part.

Day-first and month-first values are now refused
A numeric date that leads with a day or a month — "01/09/2026", "15/09/2026", "09/15/2026", "01.09.2026", "1/9/26", with or without a time — is refused with the new code AmbiguousDateFormat, whatever its numbers, with the field on LogicException.Subject. A server whose culture used to read such values refuses them now, unless it declares the form. The refusal goes by shape on purpose: refusing only values with two valid readings would fail on the 5th of the month and pass on the 15th, so a client would find out in production instead of on its first request. Send ISO 8601 — "2026-09-15", "2026-09-15T12:00:00Z" — or declare the form your clients send once at startup, with DwDates.Configure(o => o.Formats.Add("dd/MM/yyyy")); see DataType → Date formats.
Values the lenient parser guessed at are now InvalidFormat
Anything that is neither an accepted form nor a day-first or month-first date is refused with InvalidFormat. That includes values the lenient parser used to accept without a word: "12:00" was today at noon, "1/9" a day of the current year, and "Sep 2026" and "1 September 2026" were 1 September.
Zones: DateTimeOffset normalizes to UTC, DateTime does not
On a DateTimeOffset member the value is normalized to UTC, and a value carrying no zone is read as UTC — which is what keeps DataType.Date comparing the calendar day you wrote rather than the day it happens to be on the server. On PostgreSQL DataType.Date translates to date_trunc('day', col AT TIME ZONE 'UTC'). DateTime members keep the previous behaviour: a value carrying a zone is converted to the host's local time, which is the reading a timestamp without time zone column is compared against.
C# date objects in Values are written year-first
A DateTime, DateTimeOffset or DateOnly placed in Condition.Values from C# is now written as year-first text — "2026-09-01T12:30:00", "2026-09-01T12:30:00+03:00", "2026-09-01" — instead of the month-first invariant form "09/01/2026 12:30:00", so a C# caller is never refused for sending an unambiguous value. It also ends a silent misreading: that month-first text used to be parsed back in the host's culture, so on a day-first server new DateTime(2026, 9, 1) filtered on 9 January.
A local DateTime keeps its offset on a DateTimeOffset member
A C# DateTime whose Kind is Local — DateTime.Now, or the value Newtonsoft.Json produces from a string carrying an offset — placed in Values with DataType.DateTime against a DateTimeOffset or DateTimeOffset? member, or against a Having alias over such a member's aggregate, is written with its offset: "2026-09-17T15:00:00+03:00". It filters on the moment it holds. Written with no zone it would be read as UTC, which on a host at UTC+3 names a moment three hours later, with no error. Everything else keeps no zone, on purpose. Under DataType.Date a local DateTime is written without one, so DateTime.Today compares the day it was written for — on a host ahead of UTC, local midnight on the 17th is still the 16th in UTC. A DateTime or DateOnly member gets none. A DateTime of Kind Utc or Unspecified gets none, and on a DateTimeOffset member it is read as UTC. Text values — every JSON string System.Text.Json binds — are never touched.

16. PageCount on an Unpaged Result Is 1

When a Filter or Summary carries no Page, PageCount is now 1 — the one page the whole result occupies — and 0 when nothing matched. It used to equal TotalCount: the calculation divided by a page size of 1 whenever none was sent, so a 5,000-row result reported 5,000 pages of one row each.

Check anything that renders a pager
A client that draws page links straight from PageCount drew one link per row on every unpaged endpoint and now draws a single link. Applies to FilterResult<T> — typed and dynamic, sync and async — to SummaryResult, and to SegmentResult<T>, which used to report 0 for an unpaged request with condition sets. PageNumber and PageSize are unchanged — both still report 0 when no page was sent. The exception is a guarded query when the deployment sets DwCaps.DefaultPageSize: the query is given page 1 at that size, and reports it.

17. Select's Constructor Refusal Is Now a Stable Code

The refusal in point 1 used to arrive as an English sentence — Select projection requires a parameterless constructor on type '{T}'. — which a caller could not match on, because the type name was interpolated into it. The Message is now the fixed code SelectTypeMustHaveParameterlessConstructor, and the type's name rides on a new property, LogicException.Subject (string?). LogicException gained a second constructor for it, LogicException(string message, string? subject).

Two things to update
Middleware that string-matched the old sentence stops matching, and anything that scraped the type name out of the message must read Subject instead. The count of stable codes went from 27 to 28 (30 with point 15's AmbiguousDateFormat and point 20's FieldPath[{path}]StartsWithReservedName), leaving one validation failure whose message is a sentence rather than a code — Unsupported combination of DataType '{type}' and Operator '{op}'. See the error code reference.
Guarded queries reach it too
A member carrying [DwNoSelect] makes the policy layer synthesize a projection for a query that sent none — since 3.2.0 whatever the member holds, and beneath another member when its value can reach the result (point 25) — so a typed guarded query on a type with no parameterless constructor raises the same code, even though the caller never asked for a Select. The dynamic terminals project through SelectDynamic and are not affected.

18. A Guarded Query Requires a Prepared Context

A query guarded through ApplyPolicy whose DwPolicyContext never went through DwPolicy.PrepareAsync is refused with a PolicyException carrying PolicyContextNotPrepared — whether or not a policy store is configured, and at the ApplyPolicy call itself, before any terminal runs. A store provider already refused one, because it had no pinned snapshot to answer from; with attributes alone nothing refused it, so the same missing call was a failure in one deployment and silence in another. DwPolicyContext.IsPrepared is public, so you can assert it yourself.

The explicit-options overload does not check
The ApplyPolicy overload that takes explicit options and a resolver is exempt: a host composing its own options owns preparation. The check applies to the overloads that read the ambient DwPolicy configuration, because that is where PrepareAsync is the documented ceremony. A store handed to the explicit overload still refuses an unprepared context on its own.

19. A Segment's Sets Are Combined in the Database

ToListAsync(Segment) turns its condition sets into one query. Union and Intersect join the sets' conditions with OR and AND; Except removes its set's rows with NOT EXISTS on the primary key; and a type with no primary key uses SQL UNION / INTERSECT / EXCEPT. The combined query is then ordered, paged, projected and counted exactly like a Filter.

Until 3.1.0 each set was loaded into a list, and the lists were combined in memory by object reference. That was right only for a tracking query with no Selects. With AsNoTracking(), with Selects, and under ApplyPolicy, which always runs untracked, Intersect returned nothing, Except removed nothing and Union counted a row once for every set that matched it. Every row of every set was read before the page was cut.

  • Results. Untracked, projected and guarded segments return the rows their sets describe. A tracking query without Selects returns the same rows it did.
  • Ordering. Sorting runs in the database, so text follows its collation rather than .NET's string comparison, and NULLs fall where the provider puts them. Orders apply before Selects, so an order field no longer has to be selected. A segment with no Orders comes back in whatever order the database chooses, as a filter does.
  • Reads. Only the requested page is read, plus one COUNT for TotalCount.
  • Providers. On a type with a primary key, only Except needs the provider to translate a correlated EXISTS. A type with no primary key needs every column to be comparable — not PostgreSQL json or SQL Server xml — and support for the SQL set operators its sets use.

20. Members Named Root, It or Parent Are Read as Members

System.Linq.Dynamic.Core reads it, root and parent as keywords, in any letter case, wherever an identifier can stand, and the library writes member paths into its expressions as they are named. Before 3.1.0 it parsed with those keywords on:

  • A navigation named Root or It was read as the row itself. Root.Name filtered, sorted, grouped and aggregated — and through SelectDynamic projected — the row's own Name.
  • A navigation named Parent threw ParseException.
  • An AggregateBy.Alias named root, it or parent failed in Having and in Summary.Orders.

Every expression is now parsed with a ParsingConfig the library owns: the parser's defaults with AreContextKeywordsEnabled = false, so it, root and parent name members like any other identifier. No setting restores the keyword reading.

Fixed: a policy decided on one column while the query read another
Under ApplyPolicy the gate decided on the path the caller named while the database read the row's own column. A dynamic projection of Root.Name returned the values of a [DwDenied] Name, a filter on Root.Name tested the denied column, and a [DwForceWhere] scope reached through a navigation named Root filtered the row's own column instead of the linked record's.
ParsingConfig.Default is no longer read
The library used to parse through the shared ParsingConfig.Default. It no longer reads that instance, so a change a host makes to it does not reach DynamicWhere queries, and the library's own configuration does not reach the host's dynamic LINQ. No setting carries a host's changes to ParsingConfig.Default into the library's parsing.
A path that starts with one of the parser's own words is now refused by name
The parser reads its functions and literals before it looks for a member, and it still does: new, iif, np, isnull, is, as, cast, true, false and null, in any letter case, shadow the first segment of a field path. So 3.1.0 refuses such a path itself, with a LogicException whose message is FieldPath[{path}]StartsWithReservedName and whose Subject carries that first segment, trimmed. It is raised where a path is validated, so a condition Field, an Orders entry, a Selects entry, a GroupBy.Fields entry, an AggregateBy.Field and the member a [DwAlias] stands for all answer alike — guarded or not. A DefaultOrder entry naming one is skipped instead, as an unreadable entry is, because a default order never refuses a query; the startup scan reports it. A member that cannot be reached cannot be filtered, sorted, grouped, aggregated or projected: rename the CLR property and keep the column with [Column("New")].
What a member with one of those names did before 3.1.0
Nothing announced itself. New, Iif, Np, IsNull, Is, As and Cast raised the parser's ParseException, True and False an InvalidOperationException, and Null was read as the null literal — so the predicate compared null with the caller's value and the query returned no rows and no error. A typed Selects entry naming such a member used to work, because a typed projection is built without the parser; it is refused now too, so one rule covers every clause. Under ApplyPolicy the Convenience tier and any dry run give the new code, while the Strict tier outside a dry run answers with the clause's FieldDeniedFor* code and FieldPath "*", as it answers for every name it cannot use. The startup scan reports a DefaultOrder entry naming one as an error.
Names that only look reserved
Only the first segment is the parser's: Owner.New names the member, because the parser looks for a member after a dot. An alias named after one of these words still works — only the path it stands for is checked. And it, root, parent and outerIt, whose keywords this point turns off, are members like any other identifier, as is every predefined type name the parser knows: String, Boolean, Char, Byte, SByte, Int16, Int32, Int64, UInt16, UInt32, UInt64, Single, Double, Decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Math, Convert, Uri, Object and Enum.

21. MaxConditionDepth and MaxConditionSets Refuse Guarded Requests 3.0 Ran

Two caps are new in 3.1.0. Only a query guarded through ApplyPolicy enforces them; an unguarded call is not affected. Both default to 10, refuse a value below 1, freeze with the rest of the posture, and bind from configuration as Caps:MaxConditionDepth and Caps:MaxConditionSets.

CapWhat it countsRefusal
DwCaps.MaxConditionDepthHow deeply condition groups nest. The top group counts as 1 and each level of SubConditionGroups adds 1, counted on the caller's groups before any forced predicate is injected — on a Filter's group, on the deeper of a Summary's conditions and its Having, and on each Segment set separately.PolicyException with CapExceeded and SourceOrigin "MaxConditionDepth cap (10), request had 11"
DwCaps.MaxConditionSetsHow many condition sets one Segment sends, empty sets included.PolicyException with CapExceeded and SourceOrigin "MaxConditionSets cap (10), request had 11"

Nothing bounded either shape before. MaxConditions counts conditions and says nothing about how deeply their groups nest, and a set with no conditions passes every other cap while still adding to the one statement a segment becomes.

A request 3.0 ran can be refused
A guarded filter nested eleven groups deep, or a guarded segment carrying eleven or more condition sets, ran on 3.0.0 and is refused on 3.1.0. A deployment whose clients send such requests raises the cap, in code or from configuration:
DwPolicy.Configure(new DwPolicyOptions
{
    Caps = { MaxConditionDepth = 20, MaxConditionSets = 25 },
}, providers);
{
  "DynamicWhere": {
    "Policies": {
      "Caps": { "MaxConditionDepth": 20, "MaxConditionSets": 25 }
    }
  }
}

22. The Strict Tier Keeps the Policy Trace Off the Result

On 3.0.0 every guarded terminal put its PolicyTrace on the result, in both tiers: FilterResult<T>.Policy from ToList, ToListAsync, ToListDynamic and ToListAsyncDynamic with a Filter, SummaryResult.Policy from ToList and ToListAsync with a Summary, and SegmentResult<T>.Policy from ToListAsync with a Segment. The trace names every field a policy dropped, the attribute or rule that sealed each one, and every predicate injected on the caller's behalf — the detail the strict tier already refuses to hand over through getQueryString — and an API that serializes a result sends it to the caller.

DwPolicyOptions.IncludeTraceInResult (bool?, default null) now decides. null follows the tier: off under DwTier.Strict, on under DwTier.Convenience. true or false overrides the tier in either one. It freezes with the posture and binds from the configuration key IncludeTraceInResult.

Under the strict tier result.Policy is null
Code that reads Policy from a strict-tier guarded result now reads null. The trace is still recorded, on the PolicyQueryable<T>.LastTrace of the handle that ran the query, and audit events are written exactly as before. Set IncludeTraceInResult = true to put the trace back on the result.
var guarded = db.Employees.ApplyPolicy(caller);
var result  = await guarded.ToListAsync(filter);

PolicyTrace? sent     = result.Policy;      // null under DwTier.Strict, unless IncludeTraceInResult = true
PolicyTrace? recorded = guarded.LastTrace;  // recorded whatever the setting says

23. The Strict Tier Answers an Unknown Field and a Denied Field Alike

On 3.0.0 a guarded query told a field that does not exist from one the caller may not use. A name that matched nothing on T failed validation with LogicException ConditionMustHasValidFieldName before any policy decision was made, and a denied field was refused with a PolicyException naming the field — and, where one source decided, that rule or attribute on RuleId and SourceOrigin. A caller probing the strict tier learned which columns exist, including the ones they may never read, one guess at a time, and each refusal confirmed the guess.

Under DwTier.Strict, outside a dry run, the two now answer alike:

  • A name that matches nothing is gated as a field denied for every feature, at the step where a denial is raised — after the caps — so it gets the code a [DwDenied] field gets in that clause: FieldDeniedForWhere, FieldDeniedForSelect, FieldDeniedForOrder, FieldDeniedForGroup or FieldDeniedForAggregate. A name padded with dots or blank segments — NoSuchColumn...., . . . . X — is normalized the way a real path is, so it gets the refusal a padded real field gets rather than failing MaxNavigationDepth.
  • Inside a segment every field refusal is FieldDeniedForSegment, with Feature Segment, whichever clause refused it: a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say FieldDeniedForOrder where a name that matches nothing says FieldDeniedForSegment. Filters and summaries keep their per-clause codes.
  • Every refusal with one of those six codes carries FieldPath = "*", RuleId = null and SourceOrigin = null, whatever the field — a real denied field and an alias included — so the message is the same too: FieldDeniedForWhere: field '*', feature 'Where', tier 'Strict'.
  • A CapExceeded refusal names no path either. MaxNavigationDepth and MaxAuditEvents, the two caps that named a field, used to report its canonical path, which confirmed that the path exists. They report "*", and SourceOrigin still names the cap — except the audit buffer, which since 3.3.0 refuses with the clause's own field refusal under Strict outside a dry run, and names no cap either (point 33).
  • MaxQueryCost is checked after every field has passed its gate, not before. A field weighted by [DwCost] that the caller may not use is refused as denied before its weight can count, exactly as a name that does not exist is, so the budget cannot tell the two apart. An allowed weighted field is still refused with QueryCostExceeded.
  • MissingContextValue has FieldPath "*" and a null SourceOrigin, so it names neither the scope's column nor the context key it reads — together they describe how rows are partitioned.
  • The trace keeps the real path and reason: an unknown name is recorded as Denied, with the reason names nothing on followed by the type's name. With AuditRefusals on, the audit event names the field, the scoped field of a MissingContextValue, or the unknown name the caller sent.
Check anything that reads FieldPath or matches the validation code
Under the strict tier an error response built from PolicyException.FieldPath now says *, and a handler that matched ConditionMustHasValidFieldName for a misspelt field receives a PolicyException instead. It still derives from LogicException, so an existing catch still catches it. Inside a segment, a handler that matched a clause's code receives FieldDeniedForSegment, and a MissingContextValue carries neither the column nor its key. No setting restores the old answer under the strict tier. The convenience tier is unchanged — an unknown field fails validation, a refusal names the field with its RuleId and SourceOrigin, and the cost budget is checked before gating — and a dry run refuses nothing, so an unknown name fails validation there too.

24. MaxConditionValues and MaxAggregates Refuse Guarded Requests 3.0 Ran

Two more caps are new in 3.1.0. As with point 21, only a query guarded through ApplyPolicy enforces them; an unguarded call is not affected. MaxConditionValues defaults to 1000 and MaxAggregates to 50. Both refuse a value below 1, freeze with the rest of the posture, and bind from configuration as Caps:MaxConditionValues and Caps:MaxAggregates.

CapWhat it countsRefusal
DwCaps.MaxConditionValuesThe values one condition carries. The condition carrying the most is the one compared, wherever it sits: a Filter's conditions, a Summary's conditions and its Having, and every set of a Segment.PolicyException with CapExceeded, FieldPath "*" and SourceOrigin "MaxConditionValues cap (1000), request had 1001"
DwCaps.MaxAggregatesThe AggregateBy entries one summary sends, through the Summary terminals and the composable Group and Summary. The count the group-size floor adds for itself is not the caller's and is not counted.PolicyException with CapExceeded, FieldPath "*" and SourceOrigin "MaxAggregates cap (50), request had 51"

Nothing bounded either shape before. An In or a NotIn is one comparison per value, so a single condition could hand the database a predicate of any size while spending one condition from MaxConditions and one field from the cost budget. Every aggregate is a column of every group, and one with no field — a Count — named nothing a [DwCost] weight could be set on, so any number of them cost nothing.

That Count is now charged as well: an aggregate with no Field costs DwCaps.DefaultFieldCost toward MaxQueryCost, where it used to cost nothing. A guarded summary that sat just under its budget can now go over it and be refused with QueryCostExceeded.

Every count cap — MaxConditions, MaxConditionDepth, MaxConditionSets, MaxConditionValues, MaxAggregates, MaxOrderFields and MaxPageSize — is now checked before any field name is resolved, because resolving every name of an oversized request is the work the caps exist to refuse; MaxNavigationDepth still runs once names are resolved. A request that is too large and names a field that does not exist is refused with CapExceeded in both tiers, where 3.0.0 resolved names first and answered ConditionMustHasValidFieldName.

A request 3.0 ran can be refused
A guarded summary computing more than fifty aggregates, or one whose Count aggregates now take it over MaxQueryCost, ran on 3.0.0 and is refused on 3.1.0. So is a guarded condition carrying more than a thousand values — which on 3.0.0 could end the process instead (point 12). A deployment whose clients send such requests raises the cap, in code or from configuration:
DwPolicy.Configure(new DwPolicyOptions
{
    Caps = { MaxConditionValues = 5000, MaxAggregates = 100, MaxQueryCost = 2000 },
}, providers);
{
  "DynamicWhere": {
    "Policies": {
      "Caps": { "MaxConditionValues": 5000, "MaxAggregates": 100, "MaxQueryCost": 2000 }
    }
  }
}

25. A Guarded Query That Sends No Selects Keeps What the Source Carries

A request with no Selects returns whole rows, denied fields included, so a guarded query synthesizes a projection when a denied field could reach the result. 3.2.0 changed when it does so and what the projection keeps. The rules are on Policy configuration.

  • When. A field denied at the top of T asks for it whatever the field holds. A field denied beneath a member asks for it when its value can reach the result: on an entity, beneath a column, an owned or complex member, or a navigation the query loads through an Include, an automatic include or a lazy loader; on a projected row, beneath a member the initializer assigns; in memory, beneath any member. A chain that reaches its rows through a navigation, a SelectMany, a Join or a GroupBy counts every navigation as loaded when it also has an include, or a lambda that builds an object, gets one from an application's method, or captures a query with its own include or projection. A field a subtype of T declares, one a subtype of a member's type declares, and one beneath a member EF Core does not map, count too. A member that can hold an object of any type asks for nothing on its own. It does so in both tiers, typed and dynamic, for a Filter and a Segment. Until 3.2.0 only a simple field denied at the top of T asked for one. A denial beneath a navigation nothing loads never leaves the database, so an entity whose only denials sit there is read exactly as in 3.1.0.
  • What. The allowed members, which replace the allowed scalars. A row a projection builds keeps the members its initializer assigns. An entity keeps its mapped columns, converted and JSON ones included except a converted one that can hold an object of any type, its owned and complex members, and every collection of simple values such as byte[] or List<string>. Rows in memory keep their values. A member holding an object is kept whole when nothing it can hold is denied, narrowed to the allowed fields where the core's narrowing translates, and otherwise left out whole.
Fixed (security): a denial beneath a member was not enforced
With every denied field beneath a member and none at the top of T, nothing was synthesized, and the whole row came back with the denied value in it: in a list or nested object of a row projected before ApplyPolicy, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member — typed and dynamic, in both tiers, for a Filter and a Segment.
Fixed (security): what a query loads was read too narrowly
An include named from the root and reached through Select(o => o.Customer), SelectMany or Join, a projection behind another Select, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or a property of any name each loaded a denied value the gate read as unloaded, and so did an injected DbContext or EF Core 7's asynchronous loader delegate, and a reshaping lambda that got its row from an application's method or from a captured query or object. An application's own collection class hid its own denied members, and a guarded query through a provider wrapping EF Core's, such as LinqKit's AsExpandable, ran tracking, so the context filled in navigations it already held and a masked value became a pending change. A field a subtype declares — a derived entity's, or a subclass's held by a base-typed member — was not read at all, nor was a [DwDenied] on an override, on a public member hidden with new or on an interface member's implementation, and under a "*" deny a path the walk never asked about was allowed. Each came back.
Rows of a derived type come back as T
When a type the model derives from T, or a loaded subclass of a row in memory, declares a denied field, the rows are projected to T, so a derived type's allowed fields are dropped too, and a member declared as a base type is narrowed to it. Over an abstract T the typed terminals fail with SelectTypeMustHaveParameterlessConstructor; the dynamic ones return its members. Query the derived type, OfType<Company>(), to keep its fields. Rows in memory can be any loaded subtype, so there the rows are projected whenever one declares a denied field. A [DwDenied] on an override, on a public member a subtype hides with new, or on an interface member's implementation, through a variant instantiation too, denies the base path for every row, in every clause.
Fixed (security): a denied member that holds no simple value came back
A field denied at the top of T whose own type is not a simple value — a byte array, a list, an owned object, a JSON column — synthesized no projection either, so with nothing else denied the whole row came back with it.
Nested objects and lists come back
In 3.1.0, as soon as any field was denied, every nested object and list of a row projected before ApplyPolicy came back null or empty, and so did an entity's columns holding an object, its owned and complex members and its collections of simple values. They are returned now, whole or narrowed, except a converted value that can hold an object of any type, which the policy cannot see into. A member that cannot be narrowed is left out whole, and the trace records a Dropped decision whose reason starts left out whole.
What a projection leaves out
Once a projection is needed it leaves out an entity's navigations, included ones too, since projecting one would load it: under Convenience name the navigation in Selects to get it narrowed, and under Strict name its allowed fields. It leaves out the objects a row in memory holds, since a kept object is the caller's own and a transform would change it in place, and a value EF Core does not map, which EF Core could compute only by reading the whole entity, the denied columns included. A member with no setter and a member named with one of the parser's words are left out too. A typed query projects into T, so T needs a public parameterless constructor for it, as it already did (point 1).
A forced scope on a list's element type filters rows, not elements
A forced scope declared on a list's element type asks for no projection on its own. It filters the rows that hold the list, never its elements, so Selects naming the list returns every element, those the scope excludes included, as in every release. A projection needed for another reason leaves such a list out whole. Scope the elements where the row is built.

26. Selects Naming a Member Is Gated Against Every Denial Beneath It

When Selects names a navigation with a denied field beneath it, the Convenience tier replaces the entry with the allowed fields beneath it, and the Strict tier refuses it. Since 3.2.0 the gate finds every denial beneath the member, and refuses, with FieldDeniedForSelect, a narrowing it cannot build as gated. See A navigation named in Selects.

Selects namesUntil 3.1.0Since 3.2.0
A navigation whose key, Id, is deniedThe convenience tier narrowed the key away, and the core's typed projection, which adds the key of every nested node it builds, put it back.Refused in both tiers, as naming a sibling of the key already was.
A navigation named through another, Main.Lead, when Main.Id is deniedKept, and the projection added Main's key.Refused: the key of every node the path passes through is gated.
A member typed as a collection the core does not unwrap — IReadOnlyList<T>, IReadOnlyCollection<T>, Collection<T> or an application's own — with a denied field beneath itEvery field beneath it came back, the denied ones included, in both tiers: the projection gate read collections through a narrower list than the attribute walker, and found nothing beneath the member.The gate reads collections the way the walker does. The strict tier refuses the denied field, and the convenience tier's narrowing, which the core cannot project, is refused too.
A member that carries a field denied where no path reaches it: deeper than four segments, inside a framework generic such as Dictionary<string, T>, declared by a subtype of its type, in an entity navigation's owned chain or converted column, or, under a "*" deny, on a path the walk never asks aboutReturned, the denied field included.Refused under Strict. Under Convenience narrowed where the core can narrow it, which builds the declared type, and refused where it cannot.
A member with a denied property that has no setter beneath it, or a rule on a path reached through a cycleNot found, so the member came back with it.Found: the gate reads the providers' rules as well as the walk.

A member that cannot be narrowed at all — a column, a complex property or a member stored as JSON, a member of a row in memory, or one a projection builds some way the core cannot narrow — is refused in both tiers when something beneath it is denied.

Fixed (security): named members carried denied values out
A request that ran on 3.1.0 can now be refused. Under the Convenience tier the refusal names the denied key, the first denied field beneath the member, or, for a denial no path names, the member itself; under Strict its FieldPath is "*". A request that sends no Selects is not refused for such a member: its synthesized projection narrows the member or leaves it out whole (point 25).
What the policy cannot see into
A member typed object, a framework interface or a collection that is not generic, such as IEnumerable, ArrayList or an application's own, is opaque to the policy: it never asks for a projection, a synthesized projection over a projected row or rows in memory leaves it out, and naming it returns whatever it holds. A framework generic holding a policed type, such as Dictionary<string, LineDto>, has no paths beneath it: naming it is refused in both tiers where the core cannot narrow it, narrowed away under Convenience beneath a navigation, and a synthesized projection leaves it out. Hold such values in a list of the policed type instead.

27. DefaultOrder Reaches a Projection That Builds T

In 3.1.0 a Select anywhere in the chain kept a guarded query in its own order, because a default applied after a projection could name a field the projection left out, which EF Core cannot translate. Since 3.2.0 only the outermost Select counts, because it makes the rows the default orders. When it builds T in an object initializer and assigns every field the [DwEntity(DefaultOrder)] names a column, at every level of a nested path, the default applies. A column is a member the EF Core model maps on the entity the Select reads, read directly, through reference navigations or through EF.Property; in memory any assigned field is one. A value the projection computes, by any method or operator, even one EF Core could translate, a member the model does not map, a constructor with arguments, a default field the initializer does not assign, or a nested path through anything but an initializer still leaves the query in its own order: ordering by it could fail where the unguarded query ran.

[DwEntity(DefaultOrder = "CreatedAt desc, Id")]
public class TicketRow
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Title { get; set; } = string.Empty;
}

// 3.1.0: unordered. 3.2.0: ordered by CreatedAt desc, Id.
var rows = db.Tickets
    .Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title })
    .ApplyPolicy(caller)
    .ToList(new Filter());
  • A projection composed on the guarded handle — the guarded Select, or a guarded Filter whose Selects is set — keeps the rest of the chain unordered, even when it keeps every default field. So guarded.Select(["Id", "Title"]).Page(page) pages as it did in 3.0.0, unordered.
  • A Filter composed on the handle that sent orders gets no default later in the chain, even when the policy dropped every one of them, as a composed Order already did not.
A projected query that ran unordered can now be ordered
A guarded query over such a projection that sends no orders now comes back in the declared order, where it used to come back in the database's. Paging through it is stable if the default ends with a unique field.

28. Every Async Terminal Takes a CancellationToken

Since 3.2.0 every asynchronous terminal, guarded and unguarded, has overloads that take a CancellationToken: ToListAsync and ToListAsyncDynamic with a Filter, ToListAsync with a Summary, and ToListAsync with a Segment. The token reaches the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged, so code compiled against 3.1 still binds. That brings the extension methods to 28. A reflection lookup by name alone finds more overloads than it did, and where it found one — ToListAsyncDynamic, on the extension class and on the guarded handle — it now finds several, so Type.GetMethod given only the name throws AmbiguousMatchException; pass the parameter types.

ToListAsync(filter, default) no longer compiles
default fits both bool getQueryString and the new CancellationToken overload, so the call is ambiguous (CS0121). So are ToListAsyncDynamic(filter, default) and ToListAsync(summary, default), on a query and on the guarded handle alike. Write false, a token, or a named argument.
await query.ToListAsync(filter, default);              // CS0121 since 3.2.0
await query.ToListAsync(filter, false);                // as 3.1 read it
await query.ToListAsync(filter, cancellationToken);    // the new overload
The dynamic and summary reads go through EF Core
ToListAsyncDynamic and the async Summary read through EF Core's ToListAsync instead of Dynamic LINQ's ToDynamicListAsync, which had no token to pass on, and the async Summary counts through CountAsync where it counted synchronously. So on an EF Core query a canceled token now reaches the database. The rows and the counts are the same. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread.

29. A Type in a Namespace That Starts with System Is Policed

The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with System for the framework's, so an application namespace such as SystemsCorp.Payroll or SystemX.Domain got no policy beneath its types. A [DwDenied] field on such a type, reached through a member, was returned, filterable and sortable. Only System and the namespaces beneath it are the framework's now.

Fixed (security): an application namespace was read as the framework's
A guarded request that filtered on, sorted by or selected such a field ran on 3.1.0. It is now refused or dropped, as for any denied field.

30. Under Strict, a Path the Query Cannot Compute Is Refused

A member of a row's type is not always a value a database can produce. A shared type with two columns and a getter over them — LocalizedText with Ar, En and IsEmpty — gives two paths that translate and one that cannot. The policy has nothing to say about the third: [DwNoWhere] on Name matches that path and not the ones beneath it. So every check passed, and EF Core threw InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered a caller with neither an answer nor a refusal.

Since 3.3.0 such a path is refused as an unknown name is: the clause's own code, FieldPath "*", in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a Segment. It is refused only where the whole set of members a container can produce is known.

Selects is not one of them. A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so Selects = ["Id", "Name.IsEmpty"] returns the computed value exactly as it did before. Refusing it would take back a projection that has always worked.

Source of the rowsRead fromName.IsEmpty
An entitythe EF Core model: columns, shadow properties, owned and complex members, navigationsRefused
A row a Select built before ApplyPolicythat initializer's own assignments, at every level, both branches of a conditional includedRefused
…where the initializer assigns the member from something else: a method call, a captured value, a subquery, two branches building it two waysnothing — the assignment is not one this shape readsLeft alone, as before
…where the Select copies the member, Name = role.Namethe model, beneath the member it copiesRefused
Rows in memorynothing — the getter runsRuns, as before
Anything beneath a column, converted or notnothing — the converter decidesLeft alone, as before
A framework member: Length, Year, HasValuenothing — the provider translates itRuns, as before
A source the library cannot readnothingLeft alone, as before
A provider in front of EF Core: an expression expander, a decompilernothing — it rewrites what EF Core cannot translateLeft alone, as before
A column only a subtype maps, queried through the basethe queried type's model, which is what EF Core translates againstRefused
A projection a provider that is not EF Core's rannothing — its rules are its ownLeft alone, as before

An anonymous type is left alone whole: EF Core follows each of its members to its argument, so no member of such a row is refused here. Since 3.4.0 a row or a member an application type's constructor builds with arguments has every member the constructor leaves unbound refused, and a clause on the member itself, because EF Core follows a member only through an initializer's binding; 3.3.0 left such a projection alone, and every clause on one failed inside the provider (point 49). A projection is otherwise read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past MaxComplexDepth — eight levels — it stops reading and stops speaking, and under Strictsuch a path still reaches the provider and still fails there, exactly as it did before 3.3.0.

Left alone is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: Name.IsEmpty beneath a column mapped through a value converter still fails inside the provider, as it always has.

An unmapped getter on the entity itself, Display => $"{Code}:{Id}", is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error.

The rule is the model's
A member the model maps nowhere is one the database cannot compute, so the strict tier refuses it wherever the database has to. A member a translator inside EF Core computes without a mapping — a member translator plugin, a replaced query preprocessor — is refused with the rest: map it, or filter on the columns beneath it.

A provider in front of EF Core is a different case, and is left alone. LinqKit's AsExpandable() and DelegateDecompiler's Decompile() exist to rewrite the members EF Core cannot translate, so a member they compute is one the query produces, over a projection and over an entity alike. The test is EF Core's own provider type, from EF Core's own assembly — every other provider is left alone for the same reason turned around: a host's own, registered through ReplaceService<IAsyncQueryProvider, …>, may rewrite or may pass straight through, the library cannot tell, and refusing on that guess would take back a query the rewriting host answers today. A row the library itself projected is read like any other: the core's typed Select null-guards every nested node it builds, and both branches of that guard are read, so composing Select and then filtering refuses what the bare handle refuses.

The refusal raises no [DwAudit] event, for the reason an unknown name raises none: no field was read, and the refusal names none. AuditRefusals records it, and so does the trace. A simulation has no source, so PolicySimulator and /simulate cannot refuse such a path — they show the request running where the strict query refuses it.

31. LastTrace Is Set Before a Request Is Sanitized

Since 3.3.0 PolicyQueryable<T>.LastTrace carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and the reason live, so this is what makes one readable. Code that read LastTrace after catching a PolicyException and expected the earlier request's trace reads this request's now.

32. Configure Takes the Same Posture Twice

Until 3.3.0 the first DwPolicy.Configure won and every later call threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns; one asking for a different posture still throws InvalidOperationException. The comparison happens inside the lock that does the configuring, so no caller needs a lock of its own.

Compared: the tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, RefreshInterval, every cap value, the exposed entity catalogue with every name it answers to and the name each type is reported under, and the provider types in the order supplied. Writing a value's own default down is not a difference: the group floor, and IncludeTraceInResult written as the tier's own answer, are compared by the value that applies. Every other cap is compared as written. A type exposed under two names is reported under the last one, so two catalogues resolving every name alike are still refused when the order differs. Not compared, and not replaced: TokenVault, Services and the provider instances — a second host builds its own, and no two are ever the same reference.

A second host runs with the first host's vault, container and rule stores
In one test process that is what you want. Elsewhere, start a second host only if it is. AddDwPolicies also registers the posture in force rather than the instance it has just built, so a container resolving DwPolicyOptions after a second registration gets the first one's. Code that relied on the second call throwing no longer sees the exception.

33. The Audit Cap Refuses Like Any Other Field, Under Strict

An audited field records one event per use, and the library refuses the query rather than dropping a record when DwCaps.MaxAuditEvents is reached — fail closed, because an access with nothing written down is the one outcome [DwAudit] exists to prevent. Until 3.3.0 that refusal carried CapExceeded and a SourceOrigin naming the cap, while a name matching nothing carried the ordinary field refusal and no origin.

Two answers, and the difference told a caller that the name they had guessed is a real field and an audited one — the inference the strict tier exists to prevent, since an unknown name is never audited and never reaches the cap. Under Strict, outside a dry run, the cap now refuses with the same code, the same FieldPath "*" and the same absent origin as any other field refusal. The request still fails, the trace still records which refusal it really was, and Convenience and a dry run still answer CapExceeded. Code switching on CapExceeded under Strict sees the change.

34. Four More Refusals Name the Clause Under Strict

A strict refusal names no field, and four did. AmbiguousFieldName told a caller that the name they wrote matches more than one field, which is to say at least one; it is refused as an unknown name is since 3.3.0, with the ambiguity kept in the trace for the operator who has to fix the aliases. AmbiguousGroupKey reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it reports "*" and no origin. TransformRequiresMaterialization listed every transformed column on the type to a caller who named none of them. MissingHashSalt and MissingTokenVault named the masked field a deployment forgot to configure for. Those three report "*" and no origin; TransformRequiresMaterialization reports "*" and keeps an origin, which names the method and what to call instead rather than any field.

All four are unchanged under Convenience and in a dry run — the posture's switch or the caller's — where the tier names fields anyway, and the refusal audit still records the real field, as it does for every refusal whose caller-facing path is "*". Code switching on AmbiguousFieldName under Strict, or reading FieldPath off any of the four, sees the change.

The cap counts the canonical path, so an alias standing for a deep path was refused with CapExceeded and an origin stating that path's depth — where a name matching nothing got the clause's own refusal and no origin. Under Strict outside a dry run such a name is refused as an unknown name is since 3.3.0. A caller who wrote the path themselves already knows its depth and still meets the cap, as every over-long request does, and the trace keeps the cap and the depth for the operator.

36. [DwAudit] Records a Read the Request Did Not Name

A request that sends no Selects receives the row. Until 3.3.0 only a field the request spelled out was recorded, so that caller read every audited member of the row with nothing written down — one token past a control whose whole purpose is to answer who read a field.

Every audited member a projection the caller did not name hands back is now recorded for Select: the members the synthesized projection keeps where one is built, and every member the caller may select where none is, since the row then comes back whole. One event per query rather than one per row, and only for a field [DwAudit] names, so a type with nothing audited records nothing. A deployment already running the control sees more events than it did, and DwCaps.MaxAuditEvents — which refuses rather than dropping a record — can now be reached by traffic that did not reach it before. Raise the cap, or drain per request with app.UseDwPolicyAudit().

37. A Path Beneath a Framework-Typed Member Takes That Member's Policy

The attribute walk descends into an application's own types and nowhere else, so no attribute can be placed beneath a member the framework declares the type of. The pipeline validates such a path and the provider translates it all the same: Salary.Value and Salary.HasValue on a decimal?, Secret.Length on a string, Born.Year or Born.Date.Year on a DateTime, Bag.Count on a dictionary, and Lines.Count on an application's own collection class — the collection's own member, not an element's. No fragment named any of them, so each resolved as allowed.

Fixed (security): one segment past a denied member there was no policy at all
Until 3.3.0 a [DwDenied] decimal? was filtered on, sorted by, grouped by with its values as the group keys, aggregated as MAX(Salary.Value) and handed back by a dynamic projection, under Strict. A transformed member gave its stored value the same way, a [DwAudit] member was read with nothing recorded, a [DwCost] member cost the default, and a [DwOperators] restriction did not hold. The behaviour is in 3.2.0 and earlier, in both tiers.

Such a path now takes every fragment of the member it reads, whichever provider supplied it — an attribute and a store rule on Salary both cover Salary.Value: the deny effects per feature, the [DwOperators] restriction (intersected), the [DwCost] weight and the audited features. It does not take what is said to the caller about the member: the alias, the required filter — a filter on TenantId.Value does not satisfy a [DwRequireWhere] on TenantId — the forced scope, and the descriptive facts. A rule naming the sub-path itself still applies alongside.

One feature is one feature: [DwNoWhere] Born refuses WHERE Born.Year and still allows GROUP BY Born.Year. A member nothing denies is read beneath exactly as before, so Name.Length still runs. Where the member is transformed there is no member beneath it to apply the chain to, so Select, Group and Aggregate on the path are refused rather than answered with the stored value. And a member only a subtype of the navigated type declares is not such a path: Zone.Parent, where a subclass of Zone's type declares Parent, is decided by the fragments naming it, as before, so a grant of Zone under a "*" deny does not grant it.

Who is affected: any deployment whose callers can name a path one segment beneath a denied, masked, audited, weighted or operator-restricted member. Such a request now behaves as it does on the member itself: a filter, a grouping or an aggregate on it is refused in both tiers, and a select or an order is refused under Strict and dropped under Convenience. Nothing to do, unless a caller relied on reading Salary.Value, in which case allow the member.

38. A Transformed Member No Path Reaches Is Transformed

The outbound walk transforms along the paths the policy names — the declared types, four segments deep — and a value can sit in the materialized rows where none of them goes. A [DwMask] member five segments down an included or in-memory graph (B.C.D.E.Card, while B.C.D.Pin four segments down was masked), a masked member only a subtype of the row's type declares (Dog.Chip on rows typed Animal, in memory or in a TPH hierarchy), a masked member of an object a dictionary holds, and the far side of a cycle each came back exactly as stored — with no Selects, with a navigation named whole in Selects, and in a dynamic projection holding a real object.

Fixed (security): default configuration, both tiers, at the default caps
No cap had to be raised and no option set. A column masked four segments down was returned in the clear five segments down, under Strict, until 3.3.0.

The rows are now also walked by run-time type, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once — an object reached both ways is not transformed twice. Only members that declare a transform or an audit for Select, or that can lead to one, are read, so a navigation whose type can reach neither is never touched and a lazy loader behind it is not woken, and a model that declares neither anywhere pays for no second pass. The transform is the member's own attributes: no rule can speak to such a member, since no path names it, which is the same answer as "no runtime rule can unmask a field", and a resolver built over no AttributePolicyProvider reads no attribute here either. It runs in a dry run, as transforms always have, and the trace records the path with its stages and the note (declared on the member; no path of the policy names it).

Who is affected: results that used to carry stored values now carry transformed ones, which is the point of the fix and a change to what a caller receives. A transformed member with no setter there now fails the query with InvalidOperationException, as one along a named path always has: give the member a setter, or project into a type that has one. A member typed object, or a collection that is not generic, still says nothing about what it holds and is not read into.

39. A Forced Scope on a Type First Met at the Depth Limit Applies

The attribute walk leaves out what is meaningless around a cycle: a forced scope, a required filter and an alias on a type reached from itself. It returned at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the same walk — and the three were then left out of a shorter path reaching that type directly.

Fixed (security): which member was declared first decided whether a tenant scope applied
Two members of one type, one reached at the depth limit and one directly, and the order the properties were declared in decided whether the [DwForceWhere] on the navigated type reached the shorter path. Until 3.3.0.

The scope, the requirement and the alias now apply on every path within four segments that is not around a cycle, as the documentation always said they did.

Who is affected: a query that ran unscoped is now scoped, so it returns fewer rows; a [DwRequireWhere] that was never demanded may now be demanded, with RequiredFilterMissing; and a member that was reachable only by its real path now also answers to its [DwAlias]. Check a model that declares any of the three on a type reached both at four segments and nearer.

40. Paths Past Four Segments When MaxNavigationDepth Is Raised

Caps.MaxNavigationDepth defaults to 4, the depth the attribute walk reads to, and a host may raise it. A request could then name a path of five or more segments, which no attribute fragment reached, so a [DwDenied] member at segment five was filtered on, grouped by and returned, under Strict. Default configuration was never exposed to this one.

The attributes of the member at the end of such a path are read directly now: the deny family, [DwOperators], the transform stages, [DwCost], [DwAudit], [DwDescribe] and allowed values. What is declared about the queried entity itself is not read there, as it is not around a cycle: [DwAlias], [DwRequireWhere], [DwForceWhere]. Only a resolver that reads attributes does this, which every resolver DwPolicy.Configure builds does.

A transformed member there is still a member, so a row that carries it carries it transformed: Selects naming it returns it transformed, in a typed projection and in a generated row alike, because the chains of the members a projection names past the walk are handed to the outbound walk beside the type's own list. A grouping key and an aggregated field are columns of a generated row, which a summary's own transform finds by that list, and the list stops at four segments, so those two are refused with FieldDeniedForGroup and FieldDeniedForAggregate. Filtering and ordering run on the stored value, as at any depth.

Who is affected: only a deployment that raised the cap. A request naming a denied member past four segments is refused where it ran, and one naming a transformed member past four segments receives the transformed value where it received the stored one.

41. A Page Number Whose Offset Passes Int32 Is an Empty Page

The offset a page skips is (PageNumber - 1) * PageSize, and it was worked out in 32 bits. For a large enough page number the product wrapped: a negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows.

It is worked out in 64 bits and held to int.MaxValue now, in Page and in the three summary methods, guarded or not. A page past the last row is an empty page however far past it is, as it always was for a page number that did not wrap.

Who is affected: any endpoint that passes a page number through from a caller. The policy layer caps PageSize through MaxPageSize and never PageNumber, so a guarded query took the same path. Code that treated the five-hundred as the signal for an out-of-range page now gets an empty page instead.

42. A Query You Run Yourself Is Refused Where Only an Unnamed Member Is Transformed

SelectDynamic, Group, FilterDynamic and Summary on the guarded handle hand back a query for the caller to run, which the library never sees materialized. They are refused with TransformRequiresMaterialization on a type whose values are transformed on the way out, because nothing would apply the transform to the rows the caller reads.

Fixed (security): a type transformed only off the named paths was handed its query
Whether a type is one was read from the paths the policy names. A type whose only transforms sit off them — on a member only a subtype declares, one five segments down, one of an object a dictionary holds — got the query, and its rows exactly as stored: the same gap the outbound walk's second pass closed for the terminals (point 38), one method call away from them. Until 3.3.0.

The refusal now asks what a row of the type can hold as well — any transform attribute anywhere in what the type can reach — which only a resolver that reads attributes is asked. With no named column to list it names the clause: FieldPath is "*" in both tiers, where under Convenience it otherwise lists the transformed columns. The origin, which names the method and what to call instead, is unchanged. A type nothing transforms anywhere still gets its query.

Who is affected: a caller that composed one of those four methods on such a type receives a refusal where it received a query. Materialize through ToListDynamic or ToList(Summary), which transform the rows, or leave the policy deliberately with AsUnguardedQueryable().

43. [DwAudit] Records a Member No Path Names

Point 36 closed the read a request did not spell out. This closes the read the policy has no path for at all. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so, handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down.

Fixed (security): default configuration, both tiers
In a probe with four audited members, two were recorded: the subtype's and the one at segment five were not. It is the same gap the outbound walk's second pass closed for transforms (point 38), and the pass that finds those members finds these.

That pass now reports each audited member it meets where the policy names no path to it, and the terminal records it: one DwAuditEvent per path per query, not per row, with Feature Select, Effect Mask where the member is transformed as well and Allow otherwise, EntityType the queried type's full name, and FieldPath the path through the rows — B.C.D.E.Five, or Hidden for a subtype's member at the root.

  • Only a member its own [DwAudit] audits for Select, and only where the projection carries it: a member the projection left out is not a read.
  • A member the declared types hold within four segments is the gate's and is left to it, and so is a path the projection spells out however long it is. Neither is recorded twice.
  • Recorded in a dry run too, as every audited use is, and read only by a resolver that reads attributes. A model that declares neither an audit for Select nor a transform anywhere pays for no second pass.
  • At DwCaps.MaxAuditEvents it fails closed as the gate does, and the rows are withheld: under Strict outside a dry run the clause's own refusal with FieldPath "*" — FieldDeniedForSegment inside a segment — and CapExceeded otherwise, whose origin names the cap and the undrained buffer.

Who is affected: a deployment already running the control sees more events for models with such members, and MaxAuditEvents can be reached by traffic that did not reach it before. Raise the cap, or drain per request with app.UseDwPolicyAudit().

44. A Number Value Is Read the Way the Parser Reads It

The predicate builder writes a DataType.Number value into the generated expression unquoted, exactly as sent, and validation checked it with byte / short / int / long / float / double / decimal TryParse in the host's culture. The two disagreed.

Fixed: a malformed number was a five-hundred, and a host's culture decided
"1,000", "5-", "+5", ".5", "5.", "-.5", "1.e5", "NaN", "Infinity", "-Infinity" and an integer past UInt64 — or below Int64 when negative — all passed validation and then threw System.Linq.Dynamic.Core.Exceptions.ParseException when the query was built, which a host maps to a server error. "1,5" passed on a German host and was refused on an English one. And "NaN" and "Infinity" were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns instead of filtering. Until 3.3.0.

A value is read in two steps now. First the parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no NaN and no Infinity. An integer must fit UInt64, or Int64 when negative; a real has no bound, so 1e400 still reads as infinity. A suffix (5L, 5m), hex and - 5 are refused as they always were, though the parser would read them.

Then, in a Where condition and for the operators that write the value into a comparison — Equal, NotEqual, In, NotIn, the four orderings, Between and NotBetween — the literal has to compare with the member the condition names. The parser itself is asked, against the member's declared type, so these are refused where the parser used to throw:

  • a literal written with a point and no exponent (1.5) on a nullable integral member (int?, long?, …). A non-nullable int still takes 1.5, exactly as before;
  • an exponent form (1e5, 1E-7) on decimal or decimal?, and a real with more digits than a decimal holds;
  • an integer above Int64.MaxValue on a signed integral member: it reads as a ulong, which none of them converts to;
  • a negative number on ulong or ulong?;
  • any number on a string, bool, Guid, DateTime or char member, or on a collection of simple values such as List<int>;
  • a nullable enum under an ordering operator — equality still works.

A Having condition reads the grammar and stops there: an alias names an aggregate, so there is no member type to ask the parser about. Every refusal is LogicException with InvalidFormat, and it is the same in both policy tiers — a denied field is still FieldDeniedForWhere before any value is read. Nothing that ran before is refused now: every value refused is one the parser refused.

JSON.stringify writes small numbers in exponent form
JavaScript's JSON.stringify(0.0000001) is 1e-7, which a decimal member refuses. Send it as the string "0.0000001".

Who is affected: an endpoint that passed a number through from a caller and mapped ParseException to a 500 now gets a LogicException and a 400, which is what it always should have been. A client sending a locale-formatted number — a comma decimal separator, a thousands separator — is refused on every host instead of working on some. A number a C# caller puts in Values is still written in the invariant culture and is unaffected, except that double.NaN is now InvalidFormat. See DataType → Number values.

45. A null Entry in a Request's List Is a Malformed Request

A request body can say "conditions": [null], "subConditionGroups": [null], "conditionSets": [null], "orders": [null], "aggregateBy": [null] or "selects": [null]. Nothing read a list expecting that.

Fixed: a malformed body surfaced as a server error
The null surfaced wherever it was first touched: a NullReferenceException from the sort-order check, from the ordering, or — under a policy — from inside the copy the sanitizer takes before it reads anything; and an ArgumentNullException for a null aggregate (parameter "aggregate"), a null summary order (parameter "order") and, from the name lookup, a null or blank Selects entry (parameter "name"). A host maps those to a five-hundred, for a request that was simply malformed. Until 3.3.0.

Each is a LogicException now: ListOf[Conditions]MustNotHasNullEntry, ListOf[SubConditionGroups]MustNotHasNullEntry, ListOf[ConditionSets]MustNotHasNullEntry, ListOf[Orders]MustNotHasNullEntry and ListOf[AggregateBy]MustNotHasNullEntry. A Selects entry that is null or blank — empty or white space — is ConditionMustHasValidFieldName, the refusal a null or blank GroupBy.Fields entry has always had.

The walk runs in every method that takes a shape, before anything else reads the lists, with or without a policy, in both tiers, sync and async: the composables Where(ConditionGroup), Order(List<OrderBy>), Select, SelectDynamic, Group and Summary, and every terminal for a Filter, a Segment and a Summary. Filter and FilterDynamic compose Where, Order and Select, so each list is walked as its clause is reached. Under ApplyPolicy it runs at the top of the sanitizer, before the caps and before the gate: it is about the request's shape, not a policy decision.

  • A list that is itself null still means what it meant — most readers read it as empty.
  • A ConditionSet whose ConditionGroup is null is still ArgumentNullException, and so is a null Summary.GroupBy.
  • A null element inside Condition.Values still reads as the empty string: Text and Enum compare with it, and every other data type refuses it with InvalidFormat.
  • Filter.Clone(), Segment.Clone() and Summary.Clone() copy a null entry as a null entry instead of throwing NullReferenceException, so the refusal belongs to the method that runs the request and reads the same for a copy.

Who is affected: any endpoint binding a request body it does not validate itself. Such a body used to produce a 500 and now produces a LogicException, which middleware written for this library already maps to a 400. Code matching on NullReferenceException or on the ArgumentNullException parameter names "name", "order" or "aggregate" to detect this needs updating. See Error Codes Reference.

46. A Declared Purpose Can Carry Page Caps of Its Own

MaxPageSize and DefaultPageSize bound what one response carries, and a deployment sets them for its screens. An export or a report read the same rows under the same field policy and was held to the same page: it stopped at the first page, or walked the rest one page at a time — a statement and a count per page, and no single snapshot of the data.

Since 3.4.0 DwCaps.Purposes gives a declared purpose page caps of its own. It is a DwPurposeCaps, an IDictionary<string, DwPageCaps>, and a DwPageCaps holds int? MaxPageSize and int? DefaultPageSize. A query runs under them when its DwPolicyContext.Purpose names that purpose, trimmed and compared without regard to letter case, as a purpose-bound rule matches.

// appsettings: "Caps": { "MaxPageSize": 1000, "DefaultPageSize": 100,
//   "Purposes": { "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 } } }

context.Purpose = "excel";                       // before the read, set by the host
FilterResult<TenantRow> file = await rows
    .ApplyPolicy(context)
    .ToListAsync(filter, cancellationToken);     // one statement, up to 10,000 rows
bool truncated = file.TotalCount > file.Data.Count;
  • A context naming no purpose, or one no entry names, runs under the deployment's caps, so a read that forgets to declare itself is bounded like a screen's.
  • A value left null takes the deployment's cap. A value set is at least 1, so no purpose can switch a bound off, and the default page a purpose runs under is bounded by the maximum it runs under.
  • Only the two page caps are replaced. A page above the purpose's maximum is refused with CapExceeded, in both tiers, never trimmed: ToList and ToListAsync, typed and dynamic, a Summary's page, a Segment's page and the composable Page(PageBy).
  • It binds from Caps:Purposes:<name>:MaxPageSize and :DefaultPageSize, where any other key refuses to start, freezes with the posture, and is compared by the caps that apply when DwPolicy.Configure is called again.
The purpose is the host's statement, never the caller's
A request that could name its own purpose could name its own page caps, and whatever purpose-bound grants exist. Set it in the endpoint that serves the export; never bind it from a request header, a query string or a body.

Who is affected: nobody who declares no purpose. A second DwPolicy.Configure declaring a purpose the first did not, with caps that differ from the deployment's, asks for a different posture and is refused.

47. A Context Stores Its Purpose Trimmed

Setting DwPolicyContext.Purpose stores the value trimmed, and a null, empty or blank value is stored as null. A rule's purpose was already trimmed where the rule is built, and both compare without regard to letter case, so a context declaring " export " now matches a purpose-bound rule for export — it used to match none — and means to a rule what it means to a purpose's page caps (point 46).

Who is affected: a host that set a purpose with surrounding spaces: its purpose-bound rules apply to it now. A blank purpose reads back as null.

48. The Parts of a Struct Take the Struct Member's Policy

A path beneath a member whose type is an application's own struct — a value type outside the System namespaces — or a collection of them takes that member's policy, as a path beneath a framework-typed member has since 3.3.0 (point 37). A struct is a value, not a navigation. The attribute walk read one as a navigation whose members are separate fields, so a denial of the struct reached none of them.

Fixed (security): a denied struct's parts had no policy
With [DwDenied] on an Iban struct, Iban.Number was filtered on — a test for a guessed value — sorted by, grouped by with its values as the group keys and handed back by a dynamic projection, under Strict. Until 3.4.0, in both tiers.
  • Taken from the struct member, from every provider, attributes and rules alike: its deny effects per feature, its [DwOperators] restriction (intersected with the path's own), its [DwCost] weight and its audited features. Not taken: its alias, required filter, forced scope and descriptive facts, which come from the path's own member.
  • The path's own attributes still apply beside the struct member's ([DwNoOrder] on LocalizedText.En), and precedence decides as usual: a rule allowing Soft.Number replaces an overridable deny on Soft, and no rule replaces a sealed [DwDenied] on the struct.
  • Every struct on the path decides it: Card.Inner.Ar takes Card's and Inner's policy. A struct inside a class navigation counts (Owner.Name.Ar takes Owner.Name's); the class itself does not, so a denial of Customer still denies only that path.
  • A struct member that is itself transformed refuses Select, Group and Aggregate on its parts; a part that declares its own transform is transformed as any member is.
  • Past the attribute walk's depth the attributes of each struct the path passes through, and of the path's own member, are read directly.
  • PolicyResolver.Explain names the struct member's attribute as DecidedBy, and PolicySchemaBuilder.Describe (POST /schema) reports each part with its struct member's denials.
// Name.Ar and Name.En: FieldDeniedForWhere, and FieldDeniedForOrder under Strict; still selectable
[DwNoWhere, DwNoOrder]
public LocalizedText Name { get; set; }
A nullable struct too
A member declared Iban? is named through Value — Iban.Value.Number — and since 3.4.0 the policy reads such a path as it names it, Iban.Number, so the same rules hold there (point 53).
A filter on the whole struct fails in the builder
A condition whose field is the struct itself — Name, not Name.Ar — fails with InvalidOperationException ("The binary operator NotEqual is not defined for the types '…' and 'System.Object'."), guarded or not, in both tiers. [DwNoWhere] on the member turns it into a refusal.

Who is affected: a caller that filtered, sorted, grouped or selected a part of a denied, restricted, weighted or audited struct member is refused, dropped, charged or recorded now, as for the member itself: a filter, a grouping or an aggregate on a denied part is refused in both tiers, and a select or an order is refused under Strict and dropped under Convenience.

49. Under Strict, a Path Through a Member a Constructor Builds Is Refused

EF Core follows a member only through an initializer's binding. new LocalizedText(t.NameAr, t.NameEn).Ar cannot be translated, while new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar is t.NameAr and translates to the column. Point 30 left a projection built with a constructor with arguments alone, reading it as saying nothing about which member each value sets.

Fixed: a five-hundred where the strict tier promises a refusal
A filter, an order, a grouping key, an aggregated field or a segment condition naming Name.Ar over new LocalizedText(t.NameAr, t.NameEn) passed every check the policy made, and EF Core threw InvalidOperationException ("could not be translated"). Until 3.4.0.

Under Strict, outside a dry run, such a path is now refused as an unknown name is: the clause's own FieldDeniedFor* code and FieldPath "*", and the trace records the member exists on the type and the query cannot compute it, so it is refused as an unknown name is.

  • A row built by its own constructor, a positional record row (Select(t => new TenantRecord(t.Id, t.NameAr))), has every member a clause names refused; an unfiltered, unsorted read of it still works.
  • A clause on the constructed member itself, ORDER BY Name, is refused too: EF Core can neither order by nor compare a value it would have to build on the client. It can still be selected.
  • A constructor with an initializer beside it (new Money(t.Amount) { Currency = t.Currency }): a member the initializer binds (Money.Currency) stays usable, and a member left to the constructor (Money.Amount) is refused.
  • A conditional: a constructor in either branch refuses what that branch leaves unbound, whatever the other branch binds, because EF Core reads the member through both.
  • Left alone, as before: an anonymous type, whose constructor names its members; the framework's own types (new DateTime(y, 1, 1)), which a provider may translate; a collection; a type the EF Core model stores as a column on any entity, through a value converter or as a spatial point; a projection with no EF Core entity behind it; a provider that is not EF Core's own; rows in memory; the convenience tier and a dry run, which fail as the unguarded query does.
  • A selection through such a member still works: EF Core evaluates the last projection on the client.

Who is affected: no query that ran — each refused request failed inside the provider before. To make the parts usable, build the member with an initializer.

50. A Typed Selection Through a Struct Returns Its Values

The typed projection — Select<T>, and a Filter's or a Segment's Selects on the typed terminals — skipped every value-typed member a path went beneath.

Fixed: a struct's part came back as its default
Select(["Name.Ar"]) over a LocalizedText struct returned the struct's default, an empty name, with nothing refused, guarded or not. Until 3.4.0.

An application's own struct is built member by member now, as a class navigation is: the row carries what was named and nothing beside it, so Name.En keeps its default when only Name.Ar is named. Nested structs are built level by level, a collection inside a struct is carried, and the struct is read with a plain member access, so it works on EF Core, through the projection's initializer or evaluated on the client, and in memory. A nullable struct is named through Value (Alias.Value.Ar), as the path validator reads it, and built where it has a value; Alias.Value named whole, or Alias.HasValue alone, leaves it unbound.

  • Still unbound: a class navigation inside a struct, whose siblings are built; a struct in which nothing named can be set; and a path beneath a framework-typed member (Born.Year, Code.Length) — a typed row cannot hold the year apart from the date, and binding the date whole would return more than the path names. The dynamic terminals carry such a path as it is named.
  • A projection the policy synthesizes for a request with no Selects narrows a member holding an application's own struct with a denied member, held directly or in a list, any type a list can be assigned to, or an array, as it narrows a class: the allowed members come back and the denied one does not. It used to read a struct as a type the core cannot build and leave it out whole — left out whole: the core cannot build '…', which it narrows into — so the allowed members came back empty. A nullable struct, or structs in a collection of another shape, are still left out whole.

A guarded selection through a struct carries only what was named. The gate added the key, Id, of every node a selected path passes through, since the builder adds a class's key, and read a struct as one: DeniedBy.Value.Why came back with the struct's Id beside it, typed and dynamic, and a struct whose Id is denied had a selection of its other members refused and was left out of a synthesized projection. Neither builder adds a struct's key, so neither does the gate.

Who is affected: a typed query naming a path beneath a struct receives the values instead of defaults, a guarded query with no Selects over a row holding such a struct receives its allowed members instead of an empty struct, and a guarded selection through a struct holding an Id no longer receives that key unless it names it.

51. A Typed Selection Beneath a Collection of Structs Carries Only What It Names

The typed projection bound every collection of values whole. That is right for strings, numbers and dates, which have no member a policy names. A struct has.

Fixed (security): each element came back whole, a denied member included
Pairs.Shown over a List<Pair> handed back every Pair whole, a [DwDenied] member included, in both tiers, while the policy had approved only Pairs.Shown. Selecting Pairs whole was, and is, refused when an element member is denied, and the dynamic terminals were never affected: they project Pairs.Select(v => new(v.Shown)). Present since the policy layer shipped in 3.0.0.

Each element of a collection of an application's own structs is built member by member now, Pairs.Select(v => new Pair { Shown = v.Shown }).ToList(), into a List<T>, any member type a List<T> can be assigned to, or a T[]. It works on EF Core, where the projection reads the list from a query, and in memory.

  • A null collection stays null.
  • A collection of nullable structs, a collection of another shape, and an element in which nothing named can be set are left unbound: nothing is carried rather than everything.
  • A collection of scalars — strings, numbers, dates — is still bound whole.
Changed beside it: a list a projection builds with a collection initializer
Pairs = new List<Pair> { new Pair { … } } in the source projection is a list EF Core cannot select from again, so a typed selection beneath it now fails with InvalidOperationException ("could not be translated"), guarded or not, as the dynamic terminals always did; it used to return each element whole. Build such a list from a query, o.Lines.Select(l => new Pair { … }).ToList(), or name the collection whole where nothing in it is denied.

Who is affected: a typed selection naming a member beneath a collection of structs receives that member and nothing beside it. One over a list a projection builds with a collection initializer, on EF Core, fails until the list is built from a query.

52. A Transform on a Member of a Struct Is Applied

The outbound walk read a struct as a boxed copy, and the compiled setter unboxed a second copy to write into, so a transform declared on a member of an application's own struct landed on a temporary.

Fixed (security): every transform on a struct's member was skipped
[DwMask] with any strategy — Hash and Tokenize included — [DwGeneralize], [DwFormat], [DwTruncate], [DwDefault] and [DwMutate] on a struct's member emitted the stored value: in both tiers, from rows in memory and from EF Core, typed and dynamic, wherever the struct came back in a whole row or was selected whole. A class member was never affected, nor a dynamic selection of a path beneath the struct, which builds an object of its own. Present since the policy layer shipped in 3.0.0.

The setter writes into the box in place now, and both passes of the walk write each changed struct back where it was read from, innermost first: into the member that held it, into its position in a list or an array, or into the outer struct that held it. Nullable structs returned whole, lists and arrays of structs, structs inside structs, a struct inside a class navigation and a struct past the attribute walk's depth are all transformed, and a member the policy names is transformed once, by the pass along its path. A source of rows in memory is still transformed in place, its structs included, as class members are.

Behaviour change (fail-closed): a struct that cannot be written back fails the query
A struct a transform changed that cannot be written back now fails the query with InvalidOperationException rather than emit the stored value: a struct held by a member with no setter (the message says it passes through a struct held by a member with no setter), structs held in a collection that cannot be written by position such as a HashSet<T> (the message asks to hold the structs in a list or an array), and a struct that is a dictionary's value (the message says it cannot be written back where it was read from).

Who is affected: every deployment with a transform on a struct's member returns the transformed value where it returned the stored one. One that holds such a struct where it cannot be written back sees the query fail until the member gets a setter or the structs move into a list or an array. A member inside a nullable struct that a selection names through Value is transformed as well (point 53). See Transforms.

53. A Member of a Nullable Struct Is Policed as the Policy Names It

A query reaches a member of an Iban? through the nullable, Iban.Value.Number, because that is the member it compiles. The attribute walk looks through the nullable and names the same member Iban.Number, and so do the type's transforms, audits and fragments. Looked up as the query spells it, the path matched none of them.

Fixed (security): a nullable struct's members had no policy
A [DwDenied] on the nullable member, or on a member of the struct, did not stop a filter, a sort, a grouping key or a selection of that member, typed or dynamic, under Strict; Iban.HasValue was open; and a mask on a member of the struct was not applied when a selection named it through Value, as a result column or as a group key. Present since the policy layer shipped in 3.0.0; the typed selection change of this release (point 50) had widened it to the typed terminal before release.

A Value after a nullable struct of the application's is now dropped wherever the policy reads a path — the resolver, the outbound walk, the summary's key and aggregate transforms, the group floor and the past-depth transforms — so Iban.Value.Number is decided as Iban.Number and Iban.Value as Iban, and the nullable's own members, HasValue included, are read as the nullable member. A framework nullable, Salary.Value, is unchanged.

  • With [DwDenied] Iban? Iban, Iban.Value.Number and Iban.HasValue are refused as Iban is: in every clause under Strict, and as a filter, a group or an aggregate under Convenience.
  • A [DwDenied] member inside a nullable struct (Pair.Value.Hidden) is refused, and an allowed one (Pair.Value.Shown) stays usable.
  • A mask on a member inside a nullable struct (Pair.Value.Code) is applied where a selection names it through Value: on the typed terminal, on the dynamic one — the walk steps through Value on the generated row — as a group key (PairValueCode) and past the walk's depth. The group floor a masked member declares applies.
  • PolicyResolver.Resolve and Explain report the path as the policy names it, Iban.Number; the trace keeps the caller's spelling.

Who is affected: a caller that reached a denied, masked, restricted or audited member of a nullable struct through Value is refused, dropped, masked or recorded now, as through the member itself.

54. Bind Reports a Refused Value as InvalidOperationException

DwPolicyConfiguration.Bind documents InvalidOperationException for a value a property refuses: a cap below one, a purpose's page cap below one, a hash salt too short, a snapshot age or refresh interval that is not positive. The binder calls each setter by reflection, so such a value arrived as a TargetInvocationException instead. Changed in 3.4.0.

It arrives as the documented InvalidOperationException now. The message starts A configured policy value was refused: and InnerException is the property's own exception (ArgumentOutOfRangeException, ArgumentException), however deep the binder wrapped it. A purpose's page cap is bound inside a dictionary, where Microsoft.Extensions.Configuration.Binder 8 adds an InvalidOperationException of its own around the TargetInvocationException; it is reported the same way. AddDwPolicies binds through Bind, so a host that starts with such a value fails the same way.

Who is affected: code that caught TargetInvocationException around Bind or AddDwPolicies catches InvalidOperationException instead. A host that let startup fail sees a different exception type. See Configuration from a file.

See also