Skip to content

3.3.0 — configuring twice, a path the query cannot compute, and public Clone - #6

Merged
Sajadh92 merged 54 commits into
masterfrom
feat/configure-once-and-unmapped-paths-3.3.0
Sep 21, 2026
Merged

Sajadh92 merged 54 commits into
masterfrom
feat/configure-once-and-unmapped-paths-3.3.0

Conversation

@Sajadh92

@Sajadh92 Sajadh92 commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

DCMP's third change request, after DCMP-161 shipped on 3.2.0 and went into production across six modules. Two items were places DCMP still had code the package should own, three were documentation corrections — and seven rounds of review on top of them found six more security fixes and two over-blocks, each of which is in here too.

Every fix has a test that goes red when the fix is removed.

From DCMP

  • DW-16. DwPolicy.Configure threw on every call after the first, 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, and DCMP shipped a lock and a hand-written re-registration around it.
    • A second call asking for the posture already in force now does nothing and returns. A different posture still throws, which is the property worth keeping, and the comparison runs inside the lock that configures, so no caller needs one of its own.
    • Compared: the tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, RefreshInterval, every cap, the exposed entity catalogue — the same types, every name each answers to, and the name each is reported under — and the provider types in order. The group floor and IncludeTraceInResult are compared by the value that applies rather than by whether somebody wrote it down; every other cap is compared as written.
    • 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, so they stay as the first call left them — a second host runs with the first host's vault, container and rule stores, documented as a limit.
    • AddDwPolicies registers the posture in force rather than the instance it just built. This suite's own bootstrap drops the lock and the check the feature replaces, which is the code DCMP is deleting.
  • DW-17. A path beneath an undecorated member, whose leaf no database can compute, reached EF Core and threw — a five-hundred where the strict tier promises a refusal.
    • RowShape.Expresses reads the query the rows come from: an entity's EF Core model, a projection's own initializers, and a member a projection copies from the entity.
    • It answers "cannot be computed" only where the whole set of producible members is known. Rows in memory, a framework member the provider translates, a value beneath a converted column, a projection built without an object initializer, a member assigned through a sequence operator, any provider but EF Core's own, the convenience tier and a dry run are all unchanged.
    • Selects is exempt: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before.
    • The refusal is the unknown-name one, so a caller cannot tell a member that does not exist from one that cannot be computed, or from a field they may not use.
  • D-1. [DwEntity(DefaultOrder)]'s own remarks still said a projected query takes no default. It has since 3.2.0.
  • D-2. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows and says nothing about it. Every aggregation entry leads with that now, the in-memory overload included.
  • D-3. Clone() is public on Filter, Segment and Summary — every node new, a condition's values the caller's own objects in a new list.

What the review found on top

Seven rounds, three agents each — security, over-blocking against 3.2.0, documentation against code.

  • Security: [DwAudit] could be evaded in one token. A request that sent no Selects received the whole row with nothing written down, where naming the same field recorded it. A use is what the request reads now, not only what it spells out: every audited member the query hands the caller back is recorded for Select, one event per query rather than per row. That is the members a synthesized projection keeps, the audited paths inside a member it keeps whole, and — where no projection is built, or where a dry run applies none — every audited member the row carries. A navigation nothing loads is not recorded, because the caller receives null for it. Deployments running [DwAudit] will see more events, and MaxAuditEvents refuses rather than dropping a record — raise the cap, or drain per request with app.UseDwPolicyAudit().
  • Security: the audit cap told a real field from a name that matched nothing. An unknown name is never audited and never reaches the cap, so CapExceeded plus an origin naming the cap, against the ordinary field refusal, said which names are real and audited. Under Strict outside a dry run the cap refuses with the clause's own field refusal; the request still fails, and the trace still records which refusal it was.
  • Security: four refusals named a field under Strict. An ambiguous name said the caller's guess matched at least one real field, and is refused as an unknown name is now; AmbiguousGroupKey, TransformRequiresMaterialization, MissingHashSalt and MissingTokenVault name the clause. The refusal audit still records the real field.
  • Security: MaxNavigationDepth told a real alias from a name that matched nothing. 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 made-up name got the clause's own refusal. Under Strict outside a dry run such a name is refused as an unknown name is. A caller who wrote the path themselves already knows its depth and still meets the cap.
  • Over-block: a provider wrapping EF Core was held to EF Core's model. LinqKit's AsExpandable(), DelegateDecompiler's Decompile() and a host's own through ReplaceService exist to rewrite what EF Core cannot translate. A query that ran in 3.2.0, and still runs unguarded, was refused. The test is EF Core's own provider type from EF Core's own assembly; every other provider is left alone.
  • Over-block: a member a subquery builds was read against the entity's model. Lines = o.Lines.Select(l => new LineRow { … }).ToList() holds LineRows, not Lines, so a LineRow member the entity declares but does not map was refused although the query runs.
  • Also fixed: a blank GroupBy.Fields entry failed with ArgumentNullException where every sibling clause guards a blank first, so a malformed clause read as a broken endpoint rather than a four-hundred; an alias spelled like another member of the same type passed the startup scan and then merged two columns on the way out, losing one value — an alias spelling its own member in a different case still emits, because a column does not collide with itself; the startup scan itself threw AmbiguousMatchException out of ValidateModel() where one name answered to two members, taking every other type's report with it; and the four blocking reads that made the 3.2.0 publish run fail on an unrelated timing test.

Behaviour changes to call out

Breaking points 30 to 36, each with its own section on the breaking-changes page.

  • A path the query cannot compute is refused under Strict (30), LastTrace is set before a request is sanitized (31), Configure takes the same posture twice (32), the audit cap refuses like any other field under Strict (33), four more refusals name the clause under Strict (34), MaxNavigationDepth on a name the caller wrote as one token (35), and [DwAudit] records a read the request did not name (36).
  • Code that switched on AmbiguousFieldName or CapExceeded under Strict, read FieldPath off any of the four refusals, relied on a second Configure throwing, or counted audit events, sees the change.

Verification

All of it re-run against this branch head.

  • Both legs pass: EF Core 8 (3024 tests) and the EF Core 6.0.22 floor (2299 tests). All four projects build with zero warnings.
  • 50 mutation checks: removing any fix turns tests red — both halves of the posture comparison, each arm of Expresses, the provider test, the conditional reader, the trace timing, the audit recording in each of its branches, all five refusals, the alias rules, the dry-run union and the deep copy.
  • A reflection guard walks every settable value on DwPolicyOptions and DwCaps and requires the posture comparison to refuse each one, so a value added later cannot be forgotten silently.
  • DCMP's own shape on PostgreSQL 17, EF Core 9 and Npgsql 9: name.en and name.ar still filter, name.isEmpty is refused rather than throwing, an order on it is refused, Selects still returns it, a second host with the same posture starts, a different tier is still refused.
  • A 264-call behavioural sweep of the demo API against 3.2.0, with a control copy of 3.2.0 on its own freshly seeded database: no status differs and no primary signal differs. Sixteen calls differ on a secondary signal, and every one is the harness reading noise: five are the seeder nulling UpdatedAt on a different pair of products per database, and eleven are row order on a query with no ORDER BY, where the rows themselves are the same set.
  • EF Core binary compatibility checked against 6, 7, 8, 9 and 10: 29 references, all resolved.
  • Gates: the version gate, the site build and the hidden-character scan all pass.

Review round 8, over the whole codebase (2026-09-21)

Eleven defects found by a pass over all four packages, the demo API and the docs. Each was reproduced with a probe before it was fixed, and each fix is mutation-checked. Four items first left as report-only were then fixed as well.

Security

  • Critical, default configuration, present in 3.2.0: a path beneath a framework-typed member (Salary.Value, Secret.Length, Born.Year, Bag.Count, Lines.Count) matched no fragment and resolved as allowed: deny, mask, audit, cost and operator rules were all skipped. It takes the policy of the member it reads.
  • High, default configuration: a transformed member no policy path reaches (segment five, a subtype's member, an object a dictionary holds, the far side of a cycle) came back as stored. The rows are also walked by run-time type.
  • High: a type first met at the attribute walk's depth limit read as a cycle, so declaration order decided whether [DwForceWhere], [DwRequireWhere] and [DwAlias] applied on a shorter path.
  • High, only with MaxNavigationDepth raised: a denied member past four segments was not policed.
  • Medium: the four methods that hand back a query were not refused where the only transformed member is one no path names.
  • Medium: [DwAudit] on a member no path names was returned unrecorded.
  • Medium: a client disconnect cancelled the audit drain. The drain has its own thirty-second budget.
  • Hardening: a token vault can hold a key (DwToken.KeyFor(scope, value, key), new constructors on RedisTokenVault and EfTokenVault), so a copy of the store no longer gives the values back; existing tokens are adopted, unkeyed mappings retired on request.

Correctness

  • Two Redis writers moving one rule could leave a stale copy; the commit is conditional on the owner entry.
  • The page offset overflowed Int32.
  • A guarded Summary whose Having carried a null list threw NullReferenceException.
  • A number value is read as the expression parser reads it, in the invariant culture and against the member's type; what used to be the parser's exception is LogicException InvalidFormat. (Validator.cs, freeze lifted for this change.)
  • A null entry in a request list, and a null or blank Selects entry, is a LogicException with or without a policy, where it was a NullReferenceException or ArgumentNullException.

Packaging and performance

  • Microsoft.Extensions.Caching.Memory 6.0.2 is named directly (CVE-2024-43483 on the EF Core 6 floor).
  • The test project restores with no open advisory on either leg.
  • A reflection-cache lookup takes no lock and allocates nothing on a hit.

Verification of round 8

  • Both legs pass at the new head: EF Core 8 (3256 tests) and the EF Core 6.0.22 floor (2427 tests). All four projects build with zero warnings. The counts above are those of the earlier head.
  • 67 further mutation checks, M52 to M118: removing any round 8 fix turns tests red.
  • Demo API sweep, head against the previous head on one seeded database: 216 GET endpoints, 238 calls, no status difference, no real answer difference.
  • Docs: breaking-changes points 37 to 45 on the site, mirrored in DOC.md; llms.txt, README and release notes synced.

🤖 Generated with Claude Code

Sajadh92 and others added 30 commits September 20, 2026 12:29
…ath no database can compute is refused

DW-16: DwPolicy.Configure takes a second call asking for what is already in
force, inside the lock that does the configuring, so an integration suite
starting many hosts over one composition root needs no check of its own. A
different posture is still refused: the tier, the flags, the salt, the store
failure mode, both intervals, every cap, the exposed catalogue and the provider
types are all compared. The token vault, the service provider and the provider
instances are not, since a second host builds its own. AddDwPolicies registers
the posture in force rather than the instance it just built. The suite's own
bootstrap drops the lock this feature replaces.

DW-17: a path beneath a member with no fragment, whose leaf no database can
compute, is refused in the strict tier where the provider used to throw.
RowShape.Expresses reads the query the rows come from: an entity's model, a
projection's initializers, and a member a projection copies from the entity. It
answers false only where the whole set of members a container can produce is
known, so rows in memory, a framework member the provider translates, a value
beneath a converted column and a source the library cannot read are all left
alone. The trace records the refusal, and the trace is now assigned before
sanitizing so a refusal leaves it readable.

D-3: Clone is public on Filter, Segment and Summary. A caller reading the same
request again with another page rebuilt it around the caller's own clauses,
which leaves both requests holding one condition tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lic Clone, and the group floor

Version 3.3.0 in the four packages, with release notes, and every documented
version reference with it.

DW-16 and DW-17 are written up in DOC.md, llms.txt, the README highlights, the
breaking-changes page (points 30 to 32), the configuration page and the security
page. Clone gets a section on the Filter, Segment and Summary pages and an entry
in the shapes table.

D-1: [DwEntity(DefaultOrder)]'s own remarks said a projected query takes no
default. It has since 3.2.0, and the remarks now say what makes one take it.

D-2: DwCaps.MinGroupSize ships on at 5, so a guarded summary drops groups of
fewer than five rows and says nothing about it. The aggregation sections lead
with that rather than leaving it to the caps table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ture value is proved compared

A clause composed on the guarded handle — Where, Order, Select, Page — now
passes the source's shape to the sanitizer, so it refuses a path the query
cannot compute exactly as a terminal does. Without it the same filter was
refused through ToList and ran to the provider's own failure through Where.

The posture comparison is now checked by reflection: every settable value on
DwPolicyOptions and DwCaps is changed in turn and the second configure must
refuse it. A value added later and forgotten in the comparison would let a
second host run with a posture it did not ask for, and this fails until somebody
decides which column it belongs in. TokenVault and Services are named as the two
that are not compared, and a stale name there fails too.

Also probed: a member a projection never assigns fails unguarded, which is why
refusing it guarded is right rather than an over-block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t a loaded run can meet

The 3.2.0 publish run failed on one test — the store conformance watch, which
waits up to ten seconds for a store to report a change — while the same suite
had just passed twice on CI. The cause is not that store: four reads in the
wrapper suite blocked a thread-pool thread each for a whole query, which is
enough, on a two-core runner in Release, to leave the watch's reader unscheduled
until its budget runs out.

Those four reads are awaited now, through a ZwKit.CodeAsync that matches Code,
and the build is warning-free again (xUnit1031). The watch test waits on the
watch rather than on the clock, and its budget is sixty seconds: only a store
that never reports waits that long, and a budget tight enough to catch a real
hang is also tight enough to fail a healthy run under load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pe can compute

The walk looked through an entity type's derived types for a member the queried
type does not map, and counted it as a column. EF Core does not: it translates a
member against the type the query is over, and a member mapped one level down
fails with "Translation of member 'Licence' on entity type 'ZyParty' failed.
This commonly occurs when the specified member is unmapped" — which is the
five-hundred this release exists to turn into a refusal. So the look through the
derived types made DW-17 miss the case it was written for.

The queried type's own model decides now. Query the derived type to filter on
such a member, which already worked and still does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A projection the library can read into was refused for a member the initializer
does not build, whatever provider ran it. That is EF Core's answer to give: a
provider of its own may evaluate the getter in memory, as rows in memory do, and
refusing there turns a query that worked into a refusal.

A projected shape now records whether it was read through EF Core's provider,
and answers "cannot say" otherwise. An entity shape is unaffected: it has a
model, which is EF Core's by definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e is compared by every name, and a refused segment keeps its trace

What the security review of the branch found.

High: handing the posture in force back with a policy source beside it was read
as "the same posture" and did nothing. The instance carries the same values by
definition and says nothing about the sources, so the shortcut skipped the only
comparison that mattered: the resolver was never rebuilt, the source was never
consulted, and every rule in it — a denial included — quietly did not apply.
DwPolicy.Options is what AddDwPolicies registers, so the call is one a module
adding its own store would write. The shortcut compares the sources now.

Low: the catalogue was compared by what Entities reports, which is the last name
each type was exposed under, while every earlier name stays resolvable. Two
catalogues reporting the same pairs could answer differently to an
administrative request. DwEntityCatalog.SameAs compares both name maps.

Low: the segment terminal still assigned LastTrace after sanitizing, so a
refused segment left the previous request's trace on the handle — and a strict
refusal names no field, which makes the trace the only record of which field it
was.

Ruled out by the same review, with probes: the refusal is indistinguishable from
an unknown name and from a denial in every clause, caps and cost budget
included; Expresses can only add a refusal, never skip a check; the trace record
reaches no caller; forced predicates never pass through the name resolver; and
public Clone gives no TOCTOU, since sanitizing clones before it reads anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ute, and a cap's own default is not a difference

What the over-blocking review of the branch found.

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 naming an unmapped
getter — on the entity, across a navigation, on an owned or JSON member —
returned its value on 3.2.0 and was refused here, in both tiers of the strict
path and in a Segment. Five shapes, on both EF legs. The refusal now applies to
the clauses the database has to compute: a filter, an order, a grouping key, an
aggregated field, and each of those inside a Segment.

The posture comparison read IsMinGroupSizeSet, which nothing in enforcement
reads. The documented appsettings sample writes "MinGroupSize": 5, so a host
binding it and a host on the defaults enforce the same floor and were refused —
the second host this feature exists to let start. The floor that applies is
compared now, not whether somebody wrote it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nder it

From the docs-against-code review:
- A constructor with arguments does not leave a projected query in its own
  order; only a projection with no initializer does. A Filter carrying Selects
  is ordered like any other filter — it is a composed Select that leaves the
  rest of the chain unordered.
- Count was listed as a framework member that runs. No path can name it: the
  library's own validation refuses Lines.Count before any of this is asked.
- Three places still said a second Configure is refused, on pages that now
  describe the opposite.
- LastTrace's sub-bullet still said the trace holds the last call that got past
  sanitizing.
- AddDwPolicies does not "do nothing": it binds, changes no posture, and
  registers the one in force. Its exception list said "and" for two alternative
  causes.
- The [DbFunction] example cannot be reached by a property path. The rule behind
  it is the model's, and that is what the text says now.

From the fixes in this branch: Selects is not refused, a column only a subtype
maps is refused through the base type, another provider's rows decide for
themselves, and the posture comparison reads the floor that applies rather than
whether it was written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ruled out

The first round's over-blocking and posture probes, adopted as tests. They cover
what the fixes must not take back: a projection naming an unmapped getter on the
entity, across a navigation, on an owned member and on a JSON member; the
framework members the provider translates; and the posture comparison property
by property, the token vault, the service provider, provider instances and
provider order included.

One case asserted the opt-out being compared, which the fix removed on purpose;
it asserts the value that applies instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l is read on both branches

Two findings from the second review, both about which rows the new refusal
is allowed to speak for.

A provider that wraps EF Core exists to rewrite the members EF Core cannot
translate: LinqKit's AsExpandable, DelegateDecompiler's Decompile. Until now
the projection branch asked whether EF Core ran the query and the entity
branch did not ask at all, so the same wrapper over an entity was held to EF
Core's model and a query that ran in 3.2.0, and still runs unguarded, was
refused. Both branches now ask the same question, and it is asked of EF
Core's own provider rather than of IAsyncQueryProvider, which a wrapper may
implement and a provider that is not EF Core's may implement too. The
provider is matched by name, so no internal type is referenced and EF Core 6
to 10 answer alike.

A projected member assigned by a conditional recorded nothing, so nothing
beneath it could be refused. That is not an exotic shape: the core's own
typed Select null-guards every nested node it builds, so composing Select
and then filtering lost the refusal the bare handle gives. Both branches are
now read — a nested initializer, a member copied from the entity, a value
built and left empty, a null — and a member one branch assigns two ways, or
assigns in a way this shape cannot read, is left alone rather than refused on
half of what builds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and Clone says what it copies

IncludeTraceInResult defaults to the tier's own answer, and the tiers are
equal before it is read, so a host writing that answer out enforces exactly
what a host leaving it null does. It was compared as written, so the second
host was refused — the same shape the group floor's comparison already had
fixed, applied to the one other value with a default of its own.

Clone's newly public contract said the copy shares no object with the
original. Every node is copied; a condition's values are the caller's own
objects in a new list, which the internal method that implements it has
always said. The three public contracts now say it too, along with the
projection exemption on the method that implements it, whose summary still
described the behaviour before Selects was exempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anged under it

The documentation review found eight places where the text and the code had
parted company, and the code review left three more to write down.

Corrected: llms.txt still said the entity row reads a derived type's model,
which one commit had already removed everywhere else, and two comments in the
code said the same; the table's "Runs" cell for a converted column promised
behaviour the library does not control, so the cells that only mean "not
refused" now say "left alone", with a line saying what that is and is not;
two of the four places saying a second Configure is refused were missed; a
Segment carries no grouping key and no aggregated field; the group floor is
now on every aggregation entry rather than on four of seven; and the security
page's new section no longer arrives between numbered channels six and seven.

Written down: the refusal is EF Core's own provider's to make, so a provider
in front of it is left alone; a row the library itself projected reads like
any other; the refusal raises no [DwAudit] event, as an unknown name raises
none; a simulation has no source, so it cannot refuse such a path at all; the
trace flag and a type's reported name in the posture comparison; Clone's real
contract; and a condition's values being read more than once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t stays ruled out

Four files, 120 or so facts: the source table row by row, the posture
property by property, every clause that refuses and every one that does not,
the mapping shapes a refusal must not misread — JSON owned types, primitive
collections, TPH, TPT, table splitting, keyless entities — and the shapes
that decide whether a projected member can be spoken for.

Where a probe asserted a contract the review then settled the other way, it
asserts the settled one: a wrapping provider is answered for the same way
over either kind of row, Clone shares the caller's values, and the two
divergences that stay — a simulation cannot refuse an uncomputable path, and
a condition's values are read more than once — are pinned as the limits they
are rather than as bugs.

One probe drove DwPolicy's static provider list by reflection to reach a
comparison; it now reads the comparison itself, since the list is
process-wide and driving it made every suite configuring the posture in
parallel fail. The mapping probes stay off the floor leg: they map with
ToJson and a primitive collection, which EF Core 6 has neither of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… from nothing is claimed as nothing

The third review found the base-type walk in the provider test catching the
very thing the test exists to exclude. A provider built by deriving from EF
Core's own rewrites what EF Core cannot translate exactly as one built by
wrapping it does, and was held to EF Core's model while the wrapping one was
left alone. EF Core's own provider derives from object in 6, 7, 8, 9 and 10,
and a DbSet's queryable carries that exact type through every operator, so an
exact comparison matches every EF Core query the walk matched and stops
matching a subclass.

A projected member assigned from something the shape cannot read — a method
call, a captured value, a subquery, two branches building it two ways — had
its name recorded by the level above it, so the shape answered "yes, the
query can express this" for a path no database computes. Only a proven no is
acted on, so no query behaves differently; the shape now says it cannot say,
rather than making a claim it cannot support.

The review's probes are kept, including the one that drives EF Core's own
in-memory provider: it is EF Core's provider, so its rows are EF Core's to
speak for, and the getter fails there as it fails in SQLite. A probe adopted
in round 2 drove DwPolicy's static provider list by reflection, which refused
the bootstrap of any suite configuring the posture in parallel — about one
floor-leg run in eight. It reads the comparison key directly now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… read

The provider test is EF Core's own type rather than a type derived from it,
and a rewrite inside EF Core's own pipeline — a [DbFunction], a
member-translator plugin, a replaced query preprocessor — leaves EF Core's
provider in place, so a member it computes without a mapping is refused with
the rest.

A projection is read only as far as its initializer can be read: a nested
initializer, a member copied from the entity, a value built and left empty, a
null, and a conditional over those. A member assigned from a method call, a
captured value, a subquery, or by two branches that build it two ways is left
alone and still fails in the provider, as it did before 3.3.0, and past
MaxComplexDepth the shape stops reading and stops speaking. The source table
says so, and the sentence promising a deep copy no longer says it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven places where the text and the code had parted company.

The security page said a path the policy leaves alone fails as the unguarded
query does. Most of them run: rows in memory and a framework member return
rows, and beneath a converted column the provider decides. It was the one
page round 2's "left alone is not a promise that the path runs" never
reached, and it carried the stronger claim.

The in-memory summary entry was the one aggregation entry with no group
floor, and the floor applies there: ApplyPolicy takes an IEnumerable and
hands it to the same guarded queryable, so a summary read that way drops
groups of fewer than five rows like any other. README's own bullet had lost
the Segment clauses the other three documents carry.

A cap's default is not generally compared by the value that applies: only the
group floor's getter answers that way, and DefaultPageSize is applied as the
smaller of itself and MaxPageSize while being compared as written. The
generalisation is now the one true sentence.

A [DbFunction] maps a method, and no field path can name a method, so it was
never an example of a member the refusal reaches; a member translator plugin
and a replaced query preprocessor are. The website's Condition page, which is
where a caller reads about Values, now carries the limit that a value is read
more than once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ads from

The fourth review found the last over-block, and the plainest one: the
ordinary DTO-with-nested-DTO-list projection.

Lines = o.Lines.Select(l => new LineRow { Label = l.Sku }).ToList() was
recorded as a member copied whole from the entity's Lines navigation,
because the routine that reads a member chain strips a sequence operator off
the head of the expression. That is right where it was written for — a
filtered include reads its navigation through Where, OrderBy, Skip and their
kin, and the member it reaches is the navigation — and wrong here: the row's
Lines holds LineRows, not Lines, so every path beneath it was read out of the
entity's model. A LineRow member the entity happens to declare and not map
was refused, though the query runs and ran in 3.2.0.

The reader now declines to strip those operators, so such a member records
nothing and is left alone, which is what the source table already said about
a member a subquery builds.

The probes are kept, along with the round-3 one that still asserted through
the base-type walk the same round replaced, and a shadow property's refusal
is recorded as the limit it has always been: a field path names CLR members,
and a shadow property has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JSON Cookbook claimed thirteen examples and shipped twelve: DOC.md
carried an empty heading for a tenth whose body sat under a duplicated
thirteenth, and the website and the sidebar numbered around the gap. The
examples are numbered without one now, in all three places, and the count is
the one on the page. The routes are unchanged.

llms.txt still generalised the posture comparison to "a cap's own default",
which the other documents had already narrowed: only the group floor's getter
answers with the value that applies, and every other cap is compared as
written.

A null assignment on its own records nothing and is left alone, so listing it
among the shapes a projection is read through was wrong; it carries weight as
a branch beside one that assigns. The site's own metadata, which reaches
every page through the JSON-LD, said .NET 6 through 9 where the library
targets 6 through 10. The in-memory summary overload's floor is written down
in llms.txt too, and DOC.md now says its breaking points are numbered as that
document numbers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ovider test reads the assembly

The fourth review found an inference channel the strict tier had left open.
An audited field records one event per use, and the query is refused rather
than the record dropped once MaxAuditEvents is reached — fail closed, because
an access with nothing written down is the one outcome [DwAudit] exists to
prevent. That refusal carried CapExceeded and a SourceOrigin naming the cap,
while a name matching nothing carried the ordinary field refusal and no
origin. An unknown name is never audited and never reaches the cap, so the
difference told a caller, one guess per request, which names are real and
audited — which is to say exactly the fields [DwAudit] is put on. Under
Strict, outside a dry run, the cap now raises the clause's own field refusal:
same code, FieldPath "*", no origin. The request still fails, the trace still
records which refusal it really was, and Convenience and a dry run still
answer CapExceeded.

The provider test now reads the assembly a type came from as well as its
name, so a type declared under EF Core's own name elsewhere is no longer
taken for EF Core's provider and rows that run are not refused on the
strength of a name.

The review's probes are kept. Two of them asserted contracts settled the
other way: a host that replaces EF Core's query provider is left alone like
any other provider the library cannot read, and a spoofed provider name no
longer reaches the model. A round-3 probe walked every posture value, printed
what it found and asserted nothing; the comparison is guarded for real
elsewhere, and the probe is gone rather than standing in for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es alone

The audit cap's refusal under Strict is a behaviour change and a closed
channel, so it is written where both belong: the security page's channel
list, the breaking-changes page as point 33, DOC.md's own list, the release
notes and README's upgrade note. Convenience and a dry run are unchanged, and
the request still fails either way.

The provider rule's reason is corrected. It is not that a provider deriving
from EF Core's rewrites in the same way — a host replacing EF Core's query
provider through ReplaceService may pass straight through. It is that the
library cannot tell the two apart, and refusing on that guess would take back
a query the rewriting host answers today. A projection that does not build
its rows with an object initializer — an anonymous type, a constructor with
arguments — says nothing about which member each value sets, so no member of
such a row is refused, which the text now says rather than implying the
opposite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y where it was announced

The change was written down where it was introduced and left contradicted in
eight other places: llms.txt still said the cap throws CapExceeded "in both
tiers and in dry run", with a SourceOrigin naming it; the caps table, the
audit section, the error-code table and the refusal-audit note said the same;
the configuration page said every CapExceeded under Strict has FieldPath "*",
which now describes a code that tier no longer raises for the buffer; and
breaking point 23 still listed the audit cap beside MaxNavigationDepth as a
cap that names its cap in SourceOrigin. llms.txt also still counted eight
inference channels after a ninth was added to it.

The provider rule's reason was corrected in two documents and left standing
in three: a provider built by deriving from EF Core's own does not
necessarily rewrite anything — a host registering one through ReplaceService
may pass straight through — and the real reason is that the library cannot
tell the two apart, so it leaves every provider but EF Core's own alone. None
of the three mentioned that the test reads the assembly as well as the name,
which the method's own remark had also stopped saying. The sentence about a
projection built without an object initializer reached two documents and not
the other two.

The companion packages' llms.txt entries still declared a dependency on
DynamicWhere.ex 3.1.0, and the NuGet release notes did not mention the audit
cap at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… clause

A strict refusal names no field, so that a denied one, a misspelling and a
field that does not exist cannot be told apart. The fifth review found four
that named one anyway.

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
now, and the trace records which fields it matched, 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. TransformRequiresMaterialization listed
every transformed column on the type to a caller who had named none of them.
MissingHashSalt and MissingTokenVault named the masked field a deployment had
not configured for. Those three name the clause and carry no origin.

The convenience tier and a dry run are unchanged: they name fields anyway.

ResultTransformer.cs held a raw NUL and a raw unit separator inside two
literals, which made every grep and ripgrep treat the file as binary and skip
it silently. It was the only such file in the solution, and it is where one
of these four lived, which is plausibly why four rounds of review did not see
it. Both are written as escapes now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nce operator hides

The four refusals are written up as breaking point 34, as the security page's
eighth channel, in the release notes and in README's upgrade note.

Two limits the review named are written down rather than changed. [DwAudit]
records a field the request names, plus a default-order field the query adds:
a request that sends no Selects names nothing, so an audited field the row
carries back is returned with no record. Closing that redefines what a use is
and changes the event volume of every deployment already running the control,
which is a decision for its own release; the documentation now says plainly
what is recorded and what is not, and how to be recorded — name the fields,
or audit Where, which a filtering request cannot leave out. A member assigned
through a sequence operator, o.Lines.ToList() included, is left alone for the
same reason a subquery's rows are: what the row holds is not always what the
navigation holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eld it reads

[DwAudit] exists to answer who read a field, and a request that sent no
Selects read every audited member of the row with nothing written down. The
same caller naming one field recorded it; naming none recorded nothing, which
made an empty Selects one token past the control.

A use is what the request reads, not only what it spells out. The members a
projection the caller never named hands back are recorded for Select: what
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. Both
sets are paths whose policy the walk has already resolved, so the recording
costs no resolution of its own, and only a field [DwAudit] names produces an
event — one per query, not one per row.

The library's own specification said the opposite, deliberately: a
synthesized projection was the library resolving a policy on its own behalf
rather than an access by the caller. It is an access — the caller receives
the value. That test now pins the read, and the suites that filter on an
audited field name a projection of their own so each one still measures the
clause it is about.

A deployment already running the control sees more events than it did, and
MaxAuditEvents refuses rather than dropping a record, so a buffer that was
never reached can now be: raise the cap, or drain per request with
app.UseDwPolicyAudit().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[DwAudit] recording a read the request did not name is a security fix and a
behaviour change, so it is written as breaking point 35, as the third of the
bypasses that are not channels, in the release notes, in llms.txt's channel
list and audit section, and in README's upgrade note. What was written last
round as a limit is the behaviour now: a use is what the request reads, not
only what it spells out.

Each place says what a deployment has to do about it: more events than
before, one per query rather than per row and only for a field the attribute
names, and MaxAuditEvents refuses rather than dropping a record, so a buffer
that was never reached can now be — raise the cap, or drain per request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntation

The sixth review found both halves of a mistake I made in the fifth.

Three of the four refusals that now name the clause read the posture's DryRun
alone, where everything else in the layer reads the union of the posture's
and the caller's. A dry run is what lets one canary subject run unenforced
while everyone else stays enforced, and a canary meeting the hidden refusal
is a canary that cannot preview the posture it exists to preview. All three
read the union now, as the ambiguous-name branch already did.

The helper that decides what the masking pipeline's refusals name was
inserted between Apply's documentation and Apply itself, so the block
attached to the helper instead: three CS1572 warnings, and Apply lost its
entry in the XML that ships with the package — including the exception this
release changed. The helper sits below Apply now, and all four projects build
with no warnings again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as well as the announcement

The transform refusal keeps its SourceOrigin under Strict — it names the
method and what to call instead, never a field — where four documents said
all of them carry none. It also lists every transformed column, not every
masked one: generalized, truncated and formatted fields are in that list too.

llms.txt is the reference, and it still described the old behaviour in eight
places: the ambiguous-name rule, the two lists of codes that throw regardless
of tier, the group-key collision, the FieldPath and SourceOrigin reference
blocks, five rows of the error-code table, and a 3.3.0 history block that did
not mention the change at all. The configuration page's strict-refusal
section and DOC.md's blocked-action semantics — the two places the error
tables point a reader at — now name the four as well.

The security page's title counted its bypasses as channels: eight inference
channels and three bypasses, which is what the page itself says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…three more the review found

The sixth review's security pass, including one more regression of my own.

Hiding the field from the caller hid it from the audit too. RefusalAudit
records AuditPath ?? FieldPath, and the four refusals changed in the fifth
round set the path to "*" without setting AuditPath, so an audited refusal
said "*" where it used to name the field. A record of a refusal that cannot
say which field was probed answers nothing, which is what AuditPath exists
for; all four set it now.

A blank GroupBy.Fields entry reached the resolver and failed with
ArgumentNullException, where every sibling clause guards a blank first. That
is neither a refusal nor the malformed-clause failure an endpoint turns into
a four-hundred — it reads to an operator as the endpoint being broken. The
grouping-key loop guards it like the rest, and a blank name now fails a
guarded query exactly as it fails an unguarded one in every clause.

An alias spelled like another member of the same type passed the startup scan
and then merged two columns on the way out: the rename wrote one column under
the other's name and the other's value was gone. ValidateModel reports it,
and a rename onto a name the row already carries is not applied, so a
deployment that never scans keeps both columns.

Left as they are, with reasons: a store refusal reports the strict tier
because it does not soften in the convenience one, which its own remarks and
the reference both say; and a group key holding a unit separator can invent a
collision it cannot hide, which is the over-refusing direction and older than
this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as may not shadow a member

Three lines the sixth review's fixes earned: the four refusals that name the
clause still record the real field for AuditRefusals, as every refusal with a
caller-facing "*" does; a blank name fails a guarded query exactly as it
fails an unguarded one, in every clause; and an alias spelled like another
member of the same type is reported by ValidateModel, with a generated row
keeping both columns under their own names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sajadh92 and others added 2 commits September 21, 2026 02:46
…d four more the review found

The seventh review, and four of the six are mine from the sixth.

The audit recorded what a projection would have kept, and a dry run applies
no projection — so a dry run recorded a list the caller never received while
handing them every member, the audited denied ones included. It records what
the row carries there, with the effect the policy decided.

It also recorded every member the caller MAY select rather than every member
the query hands back: a navigation nothing loads was logged as read, though
the caller receives null for it, and the false event counted against a cap
that refuses rather than dropping a record. And it stopped at the top level,
so an audited column of an owned type was handed back unrecorded — the same
hole one level down. A member kept whole now records the audited paths inside
it, and a member the source does not carry records nothing.

The rule that stops a rename landing on a name the row already carries read a
case-insensitive map, so an alias spelling its own member differently — total
for Total — looked like a shadow of itself and the public name stopped being
emitted, which 3.2.0 emitted. A column does not collide with itself.

The scan asked reflection for one property by name with IgnoreCase, and a
type with two members answering to one name — a base member hidden with new,
two spelled in different cases — took an AmbiguousMatchException out of a
method documented to return a report, losing every other type's errors with
it. It walks the type's own properties now.

Last, one older than this branch: MaxNavigationDepth counts the canonical
path, so an alias the caller wrote as one token was refused with the cap's
own code and an origin stating that path's depth, where a name matching
nothing got the clause's refusal. Under Strict such a name is refused as an
unknown name is; a caller who wrote the path themselves still meets the cap,
which is what the existing test pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…en as one token

The audit rule is stated precisely rather than loosely: a member kept whole
records the audited paths inside it, a navigation nothing loads records
nothing because the caller receives null for it, a value the source does not
carry records nothing, and a dry run records everything the row carries, a
denied member included.

The navigation cap's answer to an aliased name is breaking point 35, and the
audit read moves to 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 21, 2026 00:02
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Deploying doc-dynamicwhere with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9ce3016
Status: ✅  Deploy successful!
Preview URL: https://f67f2e6c.doc-dynamicwhere.pages.dev
Branch Preview URL: https://feat-configure-once-and-unma.doc-dynamicwhere.pages.dev

View logs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It includes significant behavior and security-related changes in the core policy/configuration path and warrants final human review despite strong accompanying tests and documentation.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

This PR prepares the 3.3.0 release of DynamicWhere.ex by updating package/docs versions and documenting (and testing) several behavioral changes in the policies layer—most notably allowing idempotent DwPolicy.Configure calls with the same posture, refusing “uncomputable” member paths under Strict (instead of letting EF Core throw), and making Clone() public on the request types.

Changes:

  • Bump versions to 3.3.0 across the core package, policy provider packages, README, and docs site.
  • Update policy enforcement/configuration behavior and related documentation (configure-twice semantics, strict refusal behavior, audit-related behavior notes).
  • Add/adjust tests to cover new/changed behaviors (including new probes and async-safety improvements in tests).
File Description
README.md Updates install/version snippets and adds 3.3.0 highlights/upgrade notes.
OfficialWebsite/​package.json Bumps docs site version to 3.3.0.
OfficialWebsite/​lib/​seo.ts Updates JSON-LD OS string to include .NET 10.
OfficialWebsite/​lib/​nav.ts Updates site version and renumbers JSON Cookbook nav entries for 12 examples.
OfficialWebsite/​app/​page.tsx Updates homepage “JSON Cookbook” example count.
OfficialWebsite/​app/​layout.tsx Updates home title to include .NET 10.
OfficialWebsite/​app/​docs/​policies/​security/​page.tsx Expands/updates security narrative (channels/bypasses + new sections).
OfficialWebsite/​app/​docs/​policies/​providers/​page.tsx Updates provider install commands to 3.3.0.
OfficialWebsite/​app/​docs/​policies/​page.tsx Updates “configure twice” guidance and security page title text.
OfficialWebsite/​app/​docs/​policies/​configuration/​page.tsx Updates configuration docs (configure-twice section, MinGroupSize note, strict refusal notes).
OfficialWebsite/​app/​docs/​policies/​admin/​page.tsx Updates AspNetCore provider version and simulation note re: uncomputable paths.
OfficialWebsite/​app/​docs/​page.tsx Updates docs landing page version and install snippet.
OfficialWebsite/​app/​docs/​installation/​page.tsx Updates installation instructions/version and .NET version wording.
OfficialWebsite/​app/​docs/​extensions/​to-list-summary/​page.tsx Adds MinGroupSize “ships on at 5” callout.
OfficialWebsite/​app/​docs/​extensions/​to-list-async-summary/​page.tsx Adds MinGroupSize “ships on at 5” callout.
OfficialWebsite/​app/​docs/​extensions/​summary/​page.tsx Adds MinGroupSize “ships on at 5” callout.
OfficialWebsite/​app/​docs/​extensions/​group/​page.tsx Adds MinGroupSize “ships on at 5” callout.
OfficialWebsite/​app/​docs/​examples/​page.tsx Updates cookbook count and renumbers example cards (12 total).
OfficialWebsite/​app/​docs/​classes/​summary/​page.tsx Documents Summary.Clone() being public since 3.3.0.
OfficialWebsite/​app/​docs/​classes/​segment/​page.tsx Documents Segment.Clone() being public since 3.3.0.
OfficialWebsite/​app/​docs/​classes/​filter/​page.tsx Documents Filter.Clone() being public since 3.3.0.
OfficialWebsite/​app/​docs/​classes/​condition/​page.tsx Adds note about values being read twice (validation + predicate build).
OfficialWebsite/​app/​docs/​ai/​page.tsx Updates AI reference version text to 3.3.0.
DynamicWhere.Tests/​Policies/​Sx6RenameProbes.cs Adds tests/probes around alias shadowing and unknown-name normalization behavior.
DynamicWhere.Tests/​Policies/​Sx6BlankNameProbes.cs Adds tests around blank field handling across clauses/tiers.
DynamicWhere.Tests/​Policies/​S4Model.cs Adds EF Core model types used by new/updated policy probes.
DynamicWhere.Tests/​Policies/​Rv3InMemoryProviderProbe.cs Adds probe ensuring EF Core InMemory provider behavior matches expectation for refusal scenarios.
DynamicWhere.Tests/​Policies/​ReviewTrackingWrapperTests.cs Converts blocking async calls to awaited form to reduce flakiness.
DynamicWhere.Tests/​Policies/​ReviewPostureComparisonTests.cs Adds tests verifying posture comparison semantics for configure-twice behavior.
DynamicWhere.Tests/​Policies/​ReviewMappingProbes.cs Adds mapping-shape probes for RowShape.Expresses across EF Core features.
DynamicWhere.Tests/​Policies/​ReviewJoinEvalTests.cs Adds async helper (CodeAsync) to avoid blocking on async in tests.
DynamicWhere.Tests/​Policies/​Pr6MappingProbes.cs Adds EF Core 8-only mapping probes (JSON/complex/primitive collection).
DynamicWhere.Tests/​Policies/​PolicyStoreConformanceTests.cs Adjusts timing/awaiting strategy to reduce watch/poll flakiness.
DynamicWhere.Tests/​Policies/​PolicyEndpointTests.cs Updates endpoint bootstrap to rely on new idempotent configure behavior.
DynamicWhere.Tests/​Policies/​PolicyBootstrap.cs Introduces shared test bootstrap posture configuration helper.
DynamicWhere.Tests/​Policies/​PolicyAuditTests.cs Updates audit tests to account for new “no Selects” recording behavior.
DynamicWhere.Tests/​Policies/​Ov7ScaleProbes.cs Adds scale/collision probes covering audit volume and refusal behavior.
DynamicWhere.Tests/​Policies/​Hx6DryRunProbes.cs Adds probes around which “dry run” switches are honored by various refusals.
DynamicWhere.Tests/​Policies/​Dx7MiddlewareSurfaceProbes.cs Adds probes validating middleware behavior with new audit recording.
DynamicWhere.Tests/​Policies/​Dx7AuditReadProbes.cs Adds probes validating what constitutes an audited “read” after changes.
DynamicWhere.Tests/​Policies/​Dv5Model.cs Adds model types for documentation-vs-code probes.
DynamicWhere.Tests/​Policies/​DocsReview33ProbeB.cs Adds combined probes verifying docs claims (trace wording, floor behavior, etc.).
DynamicWhere.Tests/​Policies/​Ar7ScanProbes.cs Adds probes for startup scan behavior around reflection edge cases.
DynamicWhere.Tests/​Policies/​Ar7OracleProbes.cs Adds probes for strict refusal parity and posture comparisons.
DynamicWhere.Tests/​Policies/​Ar7EfAuditProbes.cs Adds EF Core probes ensuring unloaded/unassigned members aren’t over-audited.
DynamicWhere.Tests/​Policies/​Ar7BlankProbes.cs Adds probes for blank-name guards across clauses and tiers.
DynamicWhere.Tests/​Policies/​Ar7AuditProbes.cs Adds adversarial probes for audit semantics and refusal/audit ordering.
DynamicWhere.Tests/​DynamicWhere.Tests.csproj Adds EF Core InMemory package; excludes specific probe suites on the EF Core 6 “floor” leg.
DynamicWhere.Tests/​CloneTests.cs Adds tests for new public Clone() deep-copy behavior.
DynamicWhere.ex/​Policies/​Validation/​PolicyModelValidator.cs Extends alias validation to catch alias-vs-property-name collisions.
DynamicWhere.ex/​Policies/​Source/​PolicyQueryable.cs Ensures LastTrace is set before sanitize; adjusts strict refusal field naming and rows-shape usage in sanitization.
DynamicWhere.ex/​Policies/​Masking/​TransformPipeline.cs Adjusts strict refusal field naming for missing salt/vault while preserving audit path.
DynamicWhere.ex/​Policies/​Discovery/​DwEntityCatalog.cs Adds catalog equality comparison used for configure-twice posture checks.
DynamicWhere.ex/​Policies/​Config/​DwPolicyConfiguration.cs Updates AddDwPolicies docs and registers the in-force posture instance into DI.
DynamicWhere.ex/​Policies/​Config/​DwPolicy.cs Implements “configure twice with same posture is a no-op” and posture/provider comparison logic.
DynamicWhere.ex/​Policies/​Attributes/​DwEntityAttribute.cs Updates DefaultOrder remarks to reflect projected-query behavior.
DynamicWhere.ex/​DynamicWhere.ex.csproj Bumps core package version to 3.3.0 and updates release notes.
DynamicWhere.ex/​Classes/​Complex/​Summary.cs Makes Summary.Clone() public and documents deep-copy semantics.
DynamicWhere.ex/​Classes/​Complex/​Segment.cs Makes Segment.Clone() public and documents deep-copy semantics.
DynamicWhere.ex/​Classes/​Complex/​Filter.cs Makes Filter.Clone() public and documents deep-copy semantics.
DynamicWhere.ex.Policies.Redis/​DynamicWhere.ex.Policies.Redis.csproj Bumps Redis provider package version to 3.3.0 and prepends release notes.
DynamicWhere.ex.Policies.EntityFrameworkCore/​DynamicWhere.ex.Policies.EntityFrameworkCore.csproj Bumps EF Core provider package version to 3.3.0 and prepends release notes.
DynamicWhere.ex.Policies.AspNetCore/​DynamicWhere.ex.Policies.AspNetCore.csproj Bumps AspNetCore provider package version to 3.3.0 and updates release notes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +12 to +15
/// <summary>
/// DOC PROBE ONLY — not part of the suite. Checks the documented "Configuring twice" comparison
/// list against what SamePosture actually compares.
/// </summary>
Sajadh92 and others added 21 commits September 21, 2026 03:09
The class comment called it a doc probe "not part of the suite", but it holds
eleven facts with assertions, is excluded nowhere, and runs in CI — and two of
those facts are the reflection guard the "Configuring twice" documentation
rests on. The comment now says what the class is and why it is kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y has

The CI run on 09386e0 — a commit holding one XML comment — failed on
A_watch_yields_the_version_on_every_write with OperationCanceledException at ten
seconds, while the same suite had just passed three times on the commit before
it. This is the flake 073614c diagnosed for the store conformance watch: the
reader runs on the thread pool, and three thousand tests on a two-core runner
can leave it unscheduled until the budget runs out. That commit fixed the
conformance watch and left this one, which is the same shape, at ten seconds.

The budget is sixty seconds now and the write loop waits on the reader rather
than on the clock, so it stops writing the moment the watch has reported twice.
The assertions are untouched: removing the watcher registration from
InMemoryPolicyStore still fails the test, so the longer budget buys patience,
not a pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The offset is (PageNumber - 1) * PageSize, worked out in 32 bits, and for a
large enough page number the product wraps. A negative offset is an error on
SQL Server and PostgreSQL, so a request became a 500, and it is the first page
again on SQLite and in memory, so a page far past the last row returned rows.
The policy layer caps PageSize and not PageNumber, so a guarded query took the
same path.

It is worked out in 64 bits now and held to what Skip can take, in Page and in
the three summary terminals. 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.

Found by round 8's own pass. The mutation (the 32-bit product back) turns four
tests red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…till policed

The attribute walk names the paths of the declared types, four segments deep,
and a path that matches no fragment is allowed. Round 8 found four places a
value sat where no fragment reached, each reproduced with a probe first. All
four are in 3.2.0 as well.

Beneath a member whose type the framework declares. Salary.Value and
Salary.HasValue on a decimal?, Secret.Length on a string, Born.Year on a
DateTime, Lines.Count on an application's own collection class: the pipeline
validates each, the provider translates each, and no attribute can be placed
there. A [DwDenied] decimal? was filtered on, sorted by, grouped by with its
values as the keys, aggregated, and handed back by a dynamic projection, under
Strict. A generalized member gave its stored value the same way, an audited one
was read with nothing recorded, and a weighted one cost the default. Such a
path takes every fragment of the member it reads, whichever provider supplied
it. What is said to the caller about the member, its alias, its required
filter, its forced scope and its description, stays the member's alone. A
subtype's member is not such a path: a grant of Zone does not grant what a
subtype of Zone declares.

Past the walk's depth. Caps.MaxNavigationDepth can be raised above four, and a
denied member at five segments was then filtered on, grouped by and returned.
The attributes of the member at the end of such a path are read directly.

Where a transform cannot be applied. On either kind of path there is nothing
to apply the member's transform to, so every way the path hands a value back,
Select, Group and Aggregate, is refused. Filtering and ordering follow the
member's own decision, as they do along a named path.

In the rows themselves. The outbound walk transformed along named paths only,
so a masked member five segments down, one only a subtype of the row declares,
and one on an object a dictionary holds all came back as stored, at the
default caps, under Strict. The rows are walked by run-time type as well, and
a member that declares a transform and was not transformed along a path is
transformed by its own attributes, once. Only what can lead to such a member
is read, and a model with no transform attribute pays nothing.

And the walk itself returned at its depth limit with the type still marked as
being inside it, so a type first met there read as a cycle wherever it was met
again. What a cycle leaves out, a forced scope, a required filter and an
alias, was then left out of a path reaching the type directly. Which of two
members was declared first decided whether a tenant scope applied.

Thirteen mutations, each red. One of them was a regression of this commit's
own first cut, caught by round 5's deny-by-default tests: the member a path
reads was taken for any segment the navigated type did not declare, so a
subtype's member inherited the grant of the member above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…of what they read

The audit middleware drained the request's events with the request's own abort
token. A client that closed the connection, as the rows arrived or the moment
they had, cancelled the write that follows the response: the sink threw, the
middleware logged it, and the events went with the context. An audited read
with nothing written down, for the price of a socket.

The drain has a budget of its own now, thirty seconds, which the caller cannot
cancel and a hung sink cannot outlast. The new test fails on the old code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Where a rule lives is read before the transaction that moves or deletes it, so
two writers of one rule could read the same answer. The slower one then cleaned
up after a copy the faster had already moved and left that writer's copy
behind, with no owner entry pointing at it: a rule still applying to a caller
nobody any longer wrote it for, which no later write or delete could find.

The transaction is conditional on the owner entry now. A writer that loses the
race is told nothing was stored, as a failed commit always was, and writes
again. Eight writers moving one rule four hundred times each leave one copy
under the owner; without the condition the same test fails every run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to
CVE-2024-43483 (GHSA-qj66-m88j-hmgj), so a host on the EF Core 6 floor resolved
a vulnerable version through all four packages. 6.0.2 is the patched one. A
host on EF Core 8 or later already resolves a newer version and sees no change.
`dotnet list package --vulnerable --include-transitive` is clean for the four
shipping projects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… a hit

Every property lookup of every query, several per field, took one global lock
to read the cache configuration, copied it, allocated an input object to carry
four references into the tracking call, built a closure over the copy whether
or not the entry was already cached, and then, under LRU, which is the default,
wrote the entry's last-access time under that entry's lock. Request threads
querying one entity type queued behind each other twice per lookup.

One million lookups of one cached member, before and after:

    1 thread    152 ms, 167 MB allocated    ->   35 ms,  22 MB
    8 threads  2697 ms                      ->  108 ms

The configuration in force is a private copy nothing edits, read with one
volatile read; a caller outside still gets a copy. A hit returns before any
closure is built. The tracking call takes its arguments directly. A last-access
time is refreshed once it is a second old rather than on every read: eviction
only asks which entries are oldest, and one read a moment ago is already among
the newest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ns unguarded

A request body carrying "conditions": null or "subConditionGroups": null
overwrites the list's initializer. The pipeline takes such a group, and every
other reader in the gate checks for it. The group floor's walk over Having,
which looks for its own reserved alias, did not, so with the floor on, which is
the default, a summary that runs unguarded failed guarded with a
NullReferenceException. The floor still applies to such a summary; three tests,
each red without the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d the outbound walk documents its second pass

Governing and Unwalked are asked of every path a query resolves, and nearly every one is a single
member or within the walk's depth: both answer before splitting anything. Find is the provider's
own again, and GraphWalker.Apply documents the two parameters the second pass added, which had cost
the project two CS1573 warnings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he navigation above it is kept

bd9ac24 refused Select, Group and Aggregate on any path the walk cannot name
whose member is transformed. That is right beneath a member: Bonus.Value is a
decimal where Bonus is what is rounded, so there is nothing to apply the chain
to. It was wrong for a member past the walk's depth, which is still a member.

Run against a database, the first cut showed it. The projection gate asks what
each loaded path denies, and a masked column five segments down an Include
chain now answered "Select": the gate synthesized a projection and left the
whole included navigation out, where the outbound walk's second pass would
have masked the column and kept the rest. Closing a leak by withholding what
the policy allows is an over-block, and the EF Core test written to confirm
the fix is what caught it.

A member past the walk keeps the Select effect the election gave it. Named in
a projection it comes back transformed: the chains of members a projection
names past the walk are handed to the outbound walk beside the type's own list
and applied by path, so a generated row is reached as a typed one is. As a
grouping key or an aggregate it is still refused, because a summary's own
transform finds its columns by the type's list, which stops at four segments.

Rd8EfCoreTests runs the round's findings on SQLite: the provider translates
Price.Value, which is what made it a way round a denial; a masked column only
a derived entity maps is masked on a hierarchy's rows; and the five-segment
Include keeps its graph. Five more mutations, each red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed member is transformed

SelectDynamic, Group, FilterDynamic and Summary hand back a query for the
caller to run, which the library never sees materialized, so they are refused
with TransformRequiresMaterialization on a type whose values are transformed
on the way out. Whether a type is one was read from the paths the policy
names. A type whose only transforms sit off them, on a member a subtype
declares or five segments down, got the query, and its rows exactly as stored:
the same gap the outbound walk's second pass closed for the terminals, one
method call away from them.

The refusal asks what a row of the type can hold as well. With no named column
to list, it names the clause. A type nothing transforms anywhere still gets
its query. The new test fails on the old code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s show it

The gate records a use by path, before the query runs. A member only a subtype
of the row declares, or one past the four segments the policy names, has no
path the gate could ask about: handed back inside a row or a navigation kept
whole, it was read with nothing written down. Two of four audited members in
the probe were recorded; the subtype's and the one at segment five were not.
It is the same gap the outbound walk's second pass closed for transforms, and
the pass that finds those members finds these.

The pass reports each audited member it meets where the policy names no path to
it, once per path, and the terminal records it for Select with the effect it
came back with. A member the declared types hold within the walk's depth is
the gate's and is left to it, and so is a path the projection spells out,
however long: a typed projection builds the real types, so without that a
named deep column was recorded twice. A member the projection left out is not
a read. At the cap it does what the gate does: the rows are withheld, with the
clause's own refusal where the tier hides existence.

The pass runs for a model that declares an audit as it does for one that
declares a transform, and for neither otherwise. Seven mutations, each red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… no attribute provider

Which fragments match a path beneath a framework-typed member is about the
type's shape, not about who supplied the fragments, so a resolver built over a
store alone answers as one that reads attributes does. The remark on Candidates
said both lookups were asked only of a resolver that reads attributes; only the
read past the walk's depth is. The remark now says so, and a test pins it.

Mutation M78: gating the governing-member match on ReadsAttributes turns the
new test red in both tiers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he README and the release notes

Breaking points 37 to 43 on the site and 31 to 37 in DOC.md: a path beneath a
framework-typed member takes that member's policy, a transformed member no path
reaches is transformed, a type first met at the depth limit is not a cycle,
paths past four segments where MaxNavigationDepth is raised, a page offset past
Int32, the four composable methods refused where only an unnamed member is
transformed, and [DwAudit] recording a member no path names.

llms.txt carries each of them in the section it belongs to, plus the audit
drain's own budget, the Redis conditional commit, the patched
Microsoft.Extensions.Caching.Memory, the null Having lists and the cache lookup
that takes no lock. Three statements the 3.3.0 audit change had already made
false are corrected, and so is the transforms page, which called the walk's
depth a configured cap and a deeper field quietly unprotected.

Verified: site build, version gate, Release build with no warnings, and a scan
of every changed file for hidden characters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Five packages the test project resolved carried an open advisory, none of them
shipped: System.Net.Http 4.3.0 and System.Text.RegularExpressions 4.3.0 through
xunit's NETStandard.Library graph, the native SQLite build EF Core's provider
asks for (every SQLitePCLRaw.lib.e_sqlite3 up to 2.1.11), SSH.NET 2023.0.0
through Testcontainers 4.0.0, and on the floor leg System.Text.Json 6.0.0
through EF Core 6's SQLite provider. Each patched version is named directly,
and Testcontainers moves to 4.15.0, which asks for SSH.NET 2026.0.0. Its
builders take the image in the constructor now; the parameterless one is
obsolete.

dotnet list package --vulnerable --include-transitive is clean on both legs,
and the floor leg still resolves EF Core 6.0.22.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…host's culture does

The builder writes a number into the expression unquoted, exactly as sent, and
validation checked it with TryParse in the host's culture. The two disagreed.
A thousands separator, a trailing sign, a leading plus, ".5", "5.", "NaN",
"Infinity" and an integer past UInt64 all passed and then failed in the parser
with its own exception, which a host maps to a server error; "1,5" passed on a
German host and not on an English one; and "Infinity" was written into the
expression as a name, so on a type with a member called Infinity the condition
compared two columns.

NumberValue reads a value in two steps. The first is the parser's own grammar
for a number, in the invariant culture, within the range the parser holds an
integer in. The second asks the parser whether that literal compares with the
member the condition names, because its promotion rules are its own: 1.5 does
not compare with an int?, 1e5 does not with a decimal, and no number does with
a string or with a list of numbers. The common pairs are settled without
asking. A HAVING alias has no member to ask about, so only the grammar is read
there. Everything refused is a LogicException with InvalidFormat, and every
value refused is one the parser refused: nothing that ran is refused now.

Validator.cs is one of the four frozen files. Sajjad lifted the freeze for this
change on 2026-09-21 ("fix all 4", told the fix sits there); the edit is the
two Number branches and nothing else.

A test runs 32 member paths against 30 literals and 10 operators and holds the
pipeline to the parser's answer, worked out in the test. Mutations M79 to M89
all turn it red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…efused as one

A request body can say "conditions": [null], "orders": [null] or
"selects": [null]. Nothing read a list expecting that, so the null surfaced
wherever it was first touched: a NullReferenceException from the sort-order
check, from the ordering, or, under a policy, from the copy the sanitizer takes
before it reads anything, and an ArgumentNullException from the name lookup.
A host maps those to a server error.

RequestShape walks a filter, a segment, a summary, a condition group, a
grouping and an ordering once, and every method that takes one runs it before
anything else reads the lists, with or without a policy. A null condition,
sub-group, condition set, order or aggregate is a LogicException naming the
list, ListOf[Conditions]MustNotHasNullEntry. A projection name that is null or
blank is refused with InvalidField, as a grouping field always was, where it
used to be an ArgumentNullException. A list that is itself null still means
what it meant.

Clone copies a null entry as a null entry rather than failing on it, so the
refusal is the running method's and reads the same for a copy.

Mutations M90 to M103 all turn the new tests red. One probe that recorded the
old ArgumentNullException for a blank projection name now pins the refusal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gives no value back

A vault stores a mapping under the scope and a plain SHA-256 of the value. The
remark on DwToken.KeyFor argued a key would protect nothing, since the store is
already secret. That holds only while the store holds the values, and it does
not: it holds digests, and a tokenized column is nearly always drawn from a
space small enough to hash whole. A backup, a replica or a dump of the vault
gives back every phone number in it, and with them the value behind every token
ever handed out.

DwToken.KeyFor(scope, value, key) is an HMAC-SHA256 under a key of at least
sixteen bytes, over the scope and the value, written "hmac:{scope}:{hex}", so
one value in two scopes shares no digest. RedisTokenVault and EfTokenVault take
the key in a new constructor, and the store and the key then have to be taken
together. InMemoryTokenVault draws a key of its own, since its mappings die
with the process anyway. The unkeyed KeyFor and the unkeyed constructors are
unchanged, and their remarks now say what they are.

A deployment that adds a key keeps every token it has issued. A value first met
under the key is looked up under its unkeyed key too, and the token found there
is the one written under the keyed key. The unkeyed mapping stays until the
vault is told to retire it, which is safe once every instance holds the key:
one still running without it would mint a new token for a value whose unkeyed
mapping is gone. A vault that retires does so the first time it meets a value,
whether it wrote the keyed mapping or found it, so values adopted before
retiring began go too. Another key is another vault: every value is met for the
first time again.

Tests run the durable suite under a key on SQLite, and adoption, retiring and
an eight-way race on a real Redis and a real PostgreSQL. Mutations M104 to M118
all turn them red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, the site, DOC.md, the README and the release notes

Breaking points 44 and 45 on the site and 38 and 39 in DOC.md: a Number value
is read the way the parser reads it, and a null entry in a request's list is a
malformed request. The vault key is additive, so it is documented where
tokenization and the vaults are: a new section on the transforms page, the
use-case and provider pages, the security checklist, and sections 18, 24, 27,
28 and 31 of llms.txt, which names every new public member.

Statements the three changes made false are corrected: the Number row that said
TryParse in the server's culture, the ParseException examples "1,000" and
"NaN", the NullReferenceException and ArgumentNullException rows, the claim
that a vault's key is public so anyone can compute it, and the count of stable
error strings, which is thirty-one.

Verified: site build, version gate, Release build with no warnings, and a scan
of every changed file for hidden characters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Sajadh92
Sajadh92 merged commit 4c88836 into master Sep 21, 2026
5 checks passed
@Sajadh92
Sajadh92 deleted the feat/configure-once-and-unmapped-paths-3.3.0 branch September 21, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants