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.
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).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).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).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).\ 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.
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.
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).
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.
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.
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.
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.
.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.
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.
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 asresult.Category.Name, not as a flatCategoryName. - Dotted paths through collection navigations (e.g.,
Category.Vendors.Id) generate aSelectlambda per collection segment — the result is a nested collection of dynamic objects accessible asresult.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.Nameandresult.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.
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.
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.
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.
"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
DateTimeOffsetmember is compared against aDateTimeOffsetliteral; aDateTimemember against aDateTimeliteral; aDateOnlymember against aDateOnly, as a day under both data types. - A nullable member is unwrapped with
.Valueunder its guard, soDataType.Dateemits{field}.Value.Date— or{field}.Valueon aDateOnly?, which is already a day. - A
Havingcondition names an aggregate alias rather than a member, so the type comes from what the alias stands for: aMinimum,Maximum,FirstOrDefaultorLastOrDefaultreturns one of the values it read, so it has that member's type — nullable if the member is. On PostgreSQL aHavingonMaximumof atimestamptzcolumn becomesHAVING max(col) > TIMESTAMPTZ '…'.
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.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 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.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.
"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.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.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.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.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.
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).
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.[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.
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
Selectsreturns 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.
Ordersapply beforeSelects, so an order field no longer has to be selected. A segment with noOrderscomes back in whatever order the database chooses, as a filter does. - Reads. Only the requested page is read, plus one
COUNTforTotalCount. - Providers. On a type with a primary key, only
Exceptneeds the provider to translate a correlatedEXISTS. A type with no primary key needs every column to be comparable — not PostgreSQLjsonor SQL Serverxml— 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
RootorItwas read as the row itself.Root.Namefiltered, sorted, grouped and aggregated — and throughSelectDynamicprojected — the row's ownName. - A navigation named
ParentthrewParseException. - An
AggregateBy.Aliasnamedroot,itorparentfailed inHavingand inSummary.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.
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. 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.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")].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.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.
| Cap | What it counts | Refusal |
|---|---|---|
DwCaps.MaxConditionDepth | How 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.MaxConditionSets | How 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.
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.
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 says23. 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,FieldDeniedForGrouporFieldDeniedForAggregate. 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 failingMaxNavigationDepth. - Inside a segment every field refusal is
FieldDeniedForSegment, withFeatureSegment, 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 sayFieldDeniedForOrderwhere a name that matches nothing saysFieldDeniedForSegment. Filters and summaries keep their per-clause codes. - Every refusal with one of those six codes carries
FieldPath = "*",RuleId = nullandSourceOrigin = 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
CapExceededrefusal names no path either.MaxNavigationDepthandMaxAuditEvents, the two caps that named a field, used to report its canonical path, which confirmed that the path exists. They report"*", andSourceOriginstill names the cap — except the audit buffer, which since 3.3.0 refuses with the clause's own field refusal underStrictoutside a dry run, and names no cap either (point 33). MaxQueryCostis 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 withQueryCostExceeded.MissingContextValuehasFieldPath"*"and anullSourceOrigin, 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 reasonnames nothing onfollowed by the type's name. WithAuditRefusalson, the audit event names the field, the scoped field of aMissingContextValue, or the unknown name the caller sent.
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.
| Cap | What it counts | Refusal |
|---|---|---|
DwCaps.MaxConditionValues | The 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.MaxAggregates | The 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.
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
Tasks 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 anInclude, 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, aSelectMany, aJoinor aGroupBycounts 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 ofTdeclares, 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 aFilterand aSegment. Until 3.2.0 only a simple field denied at the top ofTasked 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[]orList<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.
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.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.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.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.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.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).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 names | Until 3.1.0 | Since 3.2.0 |
|---|---|---|
A navigation whose key, Id, is denied | The 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 denied | Kept, 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 it | Every 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 about | Returned, 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 cycle | Not 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.
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).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 guardedFilterwhoseSelectsis set — keeps the rest of the chain unordered, even when it keeps every default field. Soguarded.Select(["Id", "Title"]).Page(page)pages as it did in 3.0.0, unordered. - A
Filtercomposed on the handle that sent orders gets no default later in the chain, even when the policy dropped every one of them, as a composedOrderalready did not.
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.
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 overloadToListAsyncDynamic 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.
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 rows | Read from | Name.IsEmpty |
|---|---|---|
| An entity | the EF Core model: columns, shadow properties, owned and complex members, navigations | Refused |
A row a Select built before ApplyPolicy | that initializer's own assignments, at every level, both branches of a conditional included | Refused |
| …where the initializer assigns the member from something else: a method call, a captured value, a subquery, two branches building it two ways | nothing — the assignment is not one this shape reads | Left alone, as before |
…where the Select copies the member, Name = role.Name | the model, beneath the member it copies | Refused |
| Rows in memory | nothing — the getter runs | Runs, as before |
| Anything beneath a column, converted or not | nothing — the converter decides | Left alone, as before |
A framework member: Length, Year, HasValue | nothing — the provider translates it | Runs, as before |
| A source the library cannot read | nothing | Left alone, as before |
| A provider in front of EF Core: an expression expander, a decompiler | nothing — it rewrites what EF Core cannot translate | Left alone, as before |
| A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused |
| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Left 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.
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.
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.
35. MaxNavigationDepth on a Name the Caller Wrote as One Token
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.
[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.
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.
[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.
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.
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 forSelect, 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
Selectnor a transform anywhere pays for no second pass. - At
DwCaps.MaxAuditEventsit fails closed as the gate does, and the rows are withheld: underStrictoutside a dry run the clause's own refusal withFieldPath"*"—FieldDeniedForSegmentinside a segment — andCapExceededotherwise, 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.
"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-nullableintstill takes1.5, exactly as before; - an exponent form (
1e5,1E-7) ondecimalordecimal?, and a real with more digits than adecimalholds; - an integer above
Int64.MaxValueon a signed integral member: it reads as aulong, which none of them converts to; - a negative number on
ulongorulong?; - any number on a
string,bool,Guid,DateTimeorcharmember, or on a collection of simple values such asList<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(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.
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
nullstill means what it meant — most readers read it as empty. - A
ConditionSetwhoseConditionGroupis null is stillArgumentNullException, and so is a nullSummary.GroupBy. - A null element inside
Condition.Valuesstill reads as the empty string:TextandEnumcompare with it, and every other data type refuses it withInvalidFormat. Filter.Clone(),Segment.Clone()andSummary.Clone()copy a null entry as a null entry instead of throwingNullReferenceException, 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:ToListandToListAsync, typed and dynamic, aSummary's page, aSegment's page and the composablePage(PageBy). - It binds from
Caps:Purposes:<name>:MaxPageSizeand:DefaultPageSize, where any other key refuses to start, freezes with the posture, and is compared by the caps that apply whenDwPolicy.Configureis called again.
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.
[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]onLocalizedText.En), and precedence decides as usual: a rule allowingSoft.Numberreplaces an overridable deny onSoft, and no rule replaces a sealed[DwDenied]on the struct. - Every struct on the path decides it:
Card.Inner.ArtakesCard's andInner's policy. A struct inside a class navigation counts (Owner.Name.ArtakesOwner.Name's); the class itself does not, so a denial ofCustomerstill denies only that path. - A struct member that is itself transformed refuses
Select,GroupandAggregateon 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.Explainnames the struct member's attribute asDecidedBy, andPolicySchemaBuilder.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; }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).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.
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.
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
Selectsnarrows 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.
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.
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.
[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.
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.
[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.NumberandIban.HasValueare refused asIbanis: in every clause underStrict, and as a filter, a group or an aggregate underConvenience. - 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 throughValue: on the typed terminal, on the dynamic one — the walk steps throughValueon the generated row — as a group key (PairValueCode) and past the walk's depth. The group floor a masked member declares applies. PolicyResolver.ResolveandExplainreport 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
- Error Codes Reference → the 31 stable validation messages.
DataType→ context for points 14 and 15, and how a deployment declares its date formats.FilterResult<T>→ context for point 16.- Cache configuration → context for point 8.
SelectDynamic→ context for points 1 and 10.AggregateBy→ context for point 13.Operator→ context for the long-list fix in point 12.- Policy configuration → context for points 21 to 26: the caps, the trace on a result, what a strict refusal carries, and what a denied field does to a projection.
- Security & k-anonymity → why the strict tier hides which fields exist (point 23), and the denials the gate could not see (points 25, 26 and 29).
- Default order → context for point 27.
- Materialization → context for point 28: every async terminal and its overloads.
- Page caps per purpose → context for points 46 and 47.
- Access control → context for point 48: the parts of a struct.
Select<T>→ structs context for points 50 and 51.- Transforms → through the graph context for point 52.
- Access control → a nullable struct context for point 53.
- Configuration from a file → context for point 54.