# ADR 0004: Server-side filtering for SignalR DataSource via DevExpress CriteriaOperator string + `[FilterCriteriaParam]` hub injection

## Status

Proposed (2026-07-15)

> **Note:** Proposed / refinable. The design was settled across a multi-turn design discussion (this ADR captures it in place of the `adr-author` interview). Implementation-level details (filter-service whitelist specifics, function-translation caveats) are expected to sharpen during execution and this ADR will be updated.

## Context

MgGrid grids currently load the **full** dataset from the server (`GetAll`) into an `AcObservableCollection`, bound directly to DevExpress `DxGrid.Data`; DevExpress then filters / sorts / pages **in the browser** over the whole set. For large tables (thousands / tens-of-thousands of rows) this is inefficient. FruitBank works around it with a hardcoded **15-day** server window (`Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderSignalREndpoint.cs`), which also blocks access to older data.

Current state (verified):
- `MgGridSignalRDataSource : GridCustomDataSource` exists but is **unwired** (zero instantiation sites); live grids (`FruitBankGridBase` → `MgGridBase`) bind the full collection to `DxGrid.Data`.
- The client already produces the DevExpress `CriteriaOperator` (used for local filtering, and as a cache key via `CriteriaOperator.ToString()`).
- The transport already carries a consumer base-filter (string; now `CustomFilterCriteria` — a **criteria** string; renamed `FilterText` → `CustomFilterText` → `CustomFilterCriteria` 2026-07-16 as its semantics settled) + `ContextIds` (object[]) with every `GetAll` (`AcSignalRDataSource.GetContextParams()`). `ContextIds` is the live mechanism (domain-scoped typed params); `CustomFilterCriteria` is latent / unused in FruitBank.
- A prior **expression-based** server filter exists but is **test-only**: `AcExpressionNode` + `AcSerializerCommon.ApplyQueryFromNode` / `ExecuteQueryFromNode` serialize and execute arbitrary LINQ expression trees server-side (including arbitrary `Expression.Call` to `Queryable.Count`/`Sum`/`Any`).

Timing — decide now: filtering must move server-side to scale grids and unlock all-data access, AND the expression path (a remote-code-execution-shaped injection surface) must be replaced with a safe mechanism before more grids are built on it.

**In scope:** the universal client→server filter mechanism for the SignalR DataSource + MgGrid — **v1 = filtering**.
**Out of scope:** server-side **paging** (v2 — `skip`/`take`/`count`); full removal of `AcExpressionNode` from the serializers (separate effort); the change-tracking / rollback model (untouched — v1 stays in the load-all model, just server-filtered).

## Decision

Adopt a **criteria-string** server-side filter for the SignalR DataSource. The DevExpress grid filter travels as a `CriteriaOperator` **string** (bounded query DSL), applied server-side to `IQueryable<T>`; `AcExpressionNode` is **not** used for grid filtering.

| Concern | Decision | Layer |
|---|---|---|
| Filter representation | DevExpress `CriteriaOperator` **string** (`ToString()` client → `Parse()` server), NOT a serialized expression tree | — |
| Wire carriage | Rides in the always-sent `SignalParams` metadata (`SignalParams.FilterCriteria`), NOT a positional method param | AyCode.Services (`ISignalParams`) |
| Server binding | Hub (`AcWebSignalRHubBase`) injects `SignalParams.FilterCriteria` into a param marked `[FilterCriteriaParam]`, resolved **once** from the cached `AcMethodInfoModel.ParamInfos` (zero per-call reflection). **Convention (mandatory):** the `[FilterCriteriaParam]` parameter is the **LAST** parameter and optional (`string? filterCriteria = null`) — old clients bind positionally and the trailing param defaults to null via the existing trailing-defaults transport mechanic (no skip logic needed). The hub **validates the convention at registration time** (not-last / not-optional → loud startup diagnostic, zero per-call cost); the attribute's XML `<summary>` MUST state the mandatory last-position requirement | AyCode.Services.Server |
| Mismatch diagnostic | If a `FilterMode.Server` grid sends `FilterCriteria` but the method has **no** `[FilterCriteriaParam]` param → **warning-log** the mismatch: a real misconfiguration (grid declared `Server`, endpoint can't filter). **Precise** — never fires for `FilterMode.Local` grids. Log level / per-tag suppression is an implementation decision | AyCode.Services.Server |
| Unified criteria model (`CustomFilterCriteria` + automatic) | There is **one criteria kind**, not a free-text channel + a criteria channel. `CustomFilterCriteria` — the consumer-supplied **base scope** (a DevExpress `CriteriaOperator` string; renamed `FilterText` → `CustomFilterText` → `CustomFilterCriteria` 2026-07-16) — is AND-merged with the grid's **automatic** `FilterCriteria` (column filters + `MgTagBox`) into one effective criteria → **one parser, one whitelist**. Rationale: a raw free-text string interpolated server-side is an injection risk; a parsed + whitelisted `CriteriaOperator` is safe and standard. **Sequencing:** v1 — `CustomFilterCriteria` already gives the server-side base scope (rides the positional GetAll param today, but parsed as criteria → security win now); the grid's automatic filters stay local until v2. v2 — base + automatic merge into a single `SignalParams.FilterCriteria`. **Trusted/untrusted nuance:** merging consumer-base (trusted) with user-filter (untrusted) into one re-parsed string means the whitelist covers both; if the base needs a non-whitelisted / relation column, carry it as a **separate trusted criterion**, not merged into the user part. Origin: the dead TourIAm consumer — a LogLevel `DxTagBox` scoping ~100k+ log rows to Error/Warning by default. | AyCode.Services |
| Application | Shared **filter-service**: parse → **per-entity field whitelist** → apply to the endpoint's `IQueryable<T>` **before** `.ToListAsync()` (SQL WHERE). Requires `DevExpress.Data` (non-UI) on the server | AyCode.Services.Server |
| Client forwarding (`FilterMode`) | MgGrid carries a `FilterMode` enum (**Local** / **Server**). `Server`: the grid forwards its `CriteriaOperator` into `SignalParams.FilterCriteria` and a filter change triggers a server reload. `Local`: the grid filters the loaded set client-side — no forward, no round-trip (today's behavior). `FilterMode` is a **grid-level behavioral policy (intent)**, NOT a client-side claim about server capability — this is the distinction from the rejected `HasFilterTextParam`, so it does not reintroduce that drift. The server's cached reflection stays the single authority on *capability*; `FilterMode` decides *participation*. Default = user decision (lean `Local`) | AyCode.Blazor |
| Rollout | Gradual, per-endpoint opt-in: add `[FilterCriteriaParam] string? filterText` + one filter-service call. No endpoint-signature-position fragility, no per-endpoint duplication of filter logic | consumer plugin |
| Scope | **v1 = filtering** (load-all model, change-tracking untouched). **v2 = paging** (`skip`/`take`/`count` + `{items,total}` envelope) extends the same `SignalParams` channel later | — |

## Consequences

**Positive:**
- **Bounded to a WHERE predicate — no RCE.** Replaces the `AcExpressionNode` surface (which executed arbitrary server-side expression trees) with a criteria DSL that can only express a filter predicate over the queried entity. The worst a tampered filter string can do is **add / remove / widen filter conditions over the same query** — not execute code. (The residual "which columns / relations / functions" surface is closed by the whitelist — see Negative.)
- Wire format is a plain string → vendor-neutral on the wire (`SignalParams.FilterCriteria` metadata field); the framework stays DevExpress-free on the wire.
- **Universal + gradual**: the injection logic lives **once** in the hub; endpoints opt in with an attribute + one service call. No positional-param fragility (the `ContextIds` object[] is variable-length).
- **Single source of truth**: the server's cached reflection is authoritative on filter support; no drift-prone client-side flag duplicating it.
- **Loud, not silent, precise**: a `FilterMode.Server` grid whose endpoint lacks `[FilterCriteriaParam]` triggers a server warning (a real misconfiguration signal), never a silent no-filter drop — and `FilterMode.Local` grids never false-trigger it.
- Efficient: filter applied to `IQueryable` before materialization → SQL `WHERE`.
- Unblocks large grids and (with v2) all-data access, retiring the 15-day hack.
- Change-tracking untouched in v1 (still load-all, just a smaller server-filtered set).

**Negative:**
- Adds a `DevExpress.Data` dependency to the server plugin (currently DevExpress-free server-side; it has **DevExtreme**, a different product line). `DevExpress.Data` is UI-agnostic and runs in the Razor/MVC plugin — not a Blazor dependency.
- **Field-whitelist is mandatory — DevExpress does NOT provide it.** The criteria DSL bounds the *shape* to a WHERE predicate (no arbitrary code — see Positive), but `CriteriaOperator` places **no restriction on which columns / relations / functions** a criteria may reference. Within the WHERE surface a tampered filter string can still: (a) filter on a non-displayed / sensitive column as a **blind-injection oracle** (`[PasswordHash] LIKE 'a%'` → rows-or-not leaks the value character by character); (b) navigate **relations** (`[Customer.CreditCard]`) into other tables; (c) use expensive functions / aggregate subqueries (**DoS**). The filter-service therefore enforces a **zero-config default whitelist**: only the entity's OWN mapped scalar columns are filterable; relation-path traversal and aggregate operators are DENIED by default; un-translatable criteria features are rejected, not client-eval'd. **Parse failure or over-length criteria (length cap) → hard reject** — never "no filter → return everything". Per-property opt-out via a **`[NotGridFilterable]`** attribute (defined in **AyCode.Core** so every entity layer can reference it) — name settled, **implementation DEFERRED**: do NOT build it in the first round; file a TODO entry when the filter-service implementation reaches it.
- **v1 double-filter semantics (collation) — documented risk, decision deferred.** Under `FilterMode.Server` with direct `Data` binding (v1), the server filters via SQL (typically case-insensitive collation) AND DevExpress re-applies the same criteria locally (LINQ-to-objects, case-sensitive) — rows returned by the server can be hidden locally (accented / case-differing text). Mitigation path: the filter-service **Debug-logs the incoming criteria string**; a criteria-normalization method (e.g. forcing case-insensitive comparisons) is the candidate fix; decide after the first working end-to-end trial.
- Opt-in discipline (narrowed): a **missing** `[FilterCriteriaParam]` attribute is now caught by the mismatch warning; but an attribute **present with the injected value unused** (developer forgot the filter-service call) is still a silent no-filter.
- **One grid-level policy knob (`FilterMode`)**: minimal ceremony (an enum with a sensible default), but a genuine config surface. It is grid *intent*, not a server-capability claim, so it does not reintroduce the `HasFilterTextParam` drift; and it removes the false-positive warning for intentionally local grids.
- SQL-translation caveats: some criteria functions may not translate via linq2db → **function whitelist**; complexity / timeout caps needed (leading-wildcard `Contains` on a large unindexed table = full scan / DoS); parse failure must **hard-reject** (never "no filter → return everything").
- `MgGridSignalRDataSource` (currently unwired) needs finishing, and `MgGridBase` diverting off direct `DxGrid.Data` binding onto it — but only for the eventual ServerPaged case; **v1 filtering can ride the existing load path** (server returns a smaller filtered set into the same collection).

**Follow-ups required:**
- File SIGDS + GRID TODO entries: `[FilterCriteriaParam]` attribute (lives beside the other SignalR attributes in AyCode.Services; XML `<summary>` MUST state the mandatory last-position + optional convention) + hub injection + registration-time convention validation + **mismatch warning-log** (AyCode.Services.Server); `SignalParams.FilterCriteria` field (AyCode.Services); filter-service — zero-config whitelist default + parse/length hard-reject + criteria Debug-log (AyCode.Services.Server); MgGrid `FilterMode` (Local / Server) + criteria serialization into `SignalParams.FilterCriteria` on `Server` (AyCode.Blazor); add `DevExpress.Data` to the server plugin. `[NotGridFilterable]` (AyCode.Core) is **deferred** — TODO-only when the filter-service lands.
- **Enabled extension — IMPLEMENTED (2026-07-16), local mode**: MgGrid declarative **quick-filter panel**. Shipped shape (simplified from the earlier two-wrapper draft): a `FilterPanel` RenderFragment slot on `MgGridBase` that **itself wraps** its content in the styled `.mg-grid-filter-panel` container and renders it **below the toolbar** (composed into the grid's `ToolbarTemplate`; 2026-07-16) — no separate panel component (the planned `MgGridFilterPanelBase` → `GridFilterPanel` pair was built, then removed as redundant nesting). Consumers place `MgGridFilterPanelTagBox` components directly in the slot, each with a `FieldName` (consistent with grid columns / DevExpress `FieldName`). **Seam split (2026-07-16):** `MgTagBox` stays a pure, behavior-free `DxTagBox` seam (universal — no grid wiring; a grid-wired universal seam would even clobber a consumer's `@bind-Values`); the grid filter component is the derived `MgGridFilterPanelTagBox : MgTagBox, IMgGridFilterPanelComponent` — future filter components (dropdown/textbox/checkbox) implement the same contract over their own Mg* seams, with the shared glue to be extracted into a helper when the second one lands. On selection each component calls `DxGrid.SetFieldFilterCriteria(FieldName, criteria)` via `IMgGridBase.SetFilterPanelCriteria` (2026-07-16 rename: the earlier `*QuickFilter*` member/type names unified onto the component's `FilterPanel` name); DevExpress **AND-combines the per-field criteria automatically** (+ the user's filter row) — no manual aggregation, no clobber (**NOT** `SetFilterCriteria`, which replaces the whole filter). **Ownership nuance:** the per-field criteria slot is SHARED with the grid's filter row — the panel only manages criteria its component recognizes as its own shape (`IMgGridFilterPanelComponent.OwnsCriteria`; for the tag box the `IN(FieldName, ...)` it writes), so a value typed into the filter row (visible UI) survives panel hide/clear. The panel has a built-in right-aligned **Clear** button (`IMgGridBase.ClearFilterPanel`, enabled only while a panel-owned criteria is active) clearing the panel-owned filters only. A toolbar **Show/Hide Filters** toggle (`IMgGridBase.HasFilterPanel` / `IsFilterPanelVisible` / `ToggleFilterPanel`) appears only when the grid has a filter panel. Implementation notes: imperative `Values` writes inside DevExpress editors must be wrapped in `BeginUpdate()`/`EndUpdate()` (parameter-tracker throws otherwise); first consumer: Partner grid (Currency tag box). The grid's `FilterCriteria` is the single source: locally it filters the bound `Data`; under `FilterMode.Server` the same `FilterCriteria` reaches `GridCustomDataSource.GetItemsAsync(options.FilterCriteria)` → the criteria-string path — so the component is built **once** for both. Needs a server distinct-values source under `FilterMode.Server` (see v2 paging).
  **Persistence & visibility reconcile (2026-07-16):** the panel criteria persists with the grid layout (`GridPersistentLayout.FilterCriteria` — the AutoSave tier and the server-roamed UserSave tier alike, so it can arrive on another machine/browser). The panel components register with the grid (`IMgGridFilterPanelComponent`, weakly held) and the grid **reconciles** them after layout restore / user-layout load / layout reset / panel toggle: visible panel → each component re-reads and re-selects its field's criteria; hidden panel → the components' **own** criteria are **cleared** — a panel filter must never filter invisibly (this also means hiding the panel deliberately discards the selection); criteria the component does NOT own (shared per-field slot with the filter row — e.g. `Contains("EUR")` typed into the column filter) is left untouched. The panel content is always rendered (CSS-hidden when toggled off) so the components stay registered — that is how the grid knows *which* fields are panel-owned. Known edge: a roamed layout landing where the grid has **no** `FilterPanel` at all cannot be selectively cleared — AyCode.Blazor `MGGRID_ISSUES.md` **ACBLAZOR-GRID-I-D4T7**.

  **Restore race — root cause + LEVEL-TRIGGERED heal (2026-07-17, multi-agent audit):** a restore has **two** inputs arriving in **arbitrary order** — the criteria (async layout restore via JS interop) and the lookup `Data` (async load). Consumers routinely bind a **pre-created empty** collection that is later filled **in place** (`AcObservableCollection.Replace`), and DevExpress **snapshots the editor's item list, detecting `Data` changes BY REFERENCE ONLY** — so an in-place fill leaves the editor holding an empty list and *no* asserted `Values` can resolve into tags (re-rendering does not help; `DxListEditorBase.Reload()` is the only re-read, and it carries no documented settle guarantee). Two successive **edge-triggered** recovery gates (reference-change, then reference+count-diff) each shipped and each failed the same way: they require an *arming* pass that records the pre-fill state, so the **last-filled** lookup — whose fill lands after its last parameter pass — is systematically stranded (rows filter, `Clear` enabled, chips empty). The audit refuted the `TextFieldName`-type, windowed-data, nullable-column, column-interference and shared-collection hypotheses; the surviving cause is purely this edge-vs-level mismatch. **Fix:** the component now tracks the *standing fact* "this editor's snapshot was taken while `Data` was empty" — armed on **every** parameter pass regardless of criteria presence, cleared only by a heal — and heals (`Reload()` + fresh-instance `Values`) from **both** `OnParametersSet` **and** `SyncFromGrid` (so `IMgGridBase.RefreshFilterPanel()` finally honours its documented contract), plus one deferred post-render re-assert covering `Reload()`'s undocumented settle timing.

  **Second defect — hijacked `ValuesChanged` destroying the criteria (2026-07-17, console-proven):** the component owns `ValuesChanged`, but the callback cannot distinguish a genuine user edit from (a) the **echo** of the component's own programmatic `Values` write and (b) an **editor-internal revalidation drop** — console diagnostics caught DevExpress raising `ValuesChanged([])` unprompted right after a heal (the transient mid-`Reload`/in-place-`Reset` state rejects the not-yet-resolvable tag), which the handler misread as "user cleared the box" → wrote the criteria to `null` → the layout **AutoSave persisted the destruction** (the user's saved filter was permanently lost on every failing visit; the only upside: no invisible filter). The lookup content itself was proven resolvable (the same value is user-selectable from the same 20-item windowed list), killing the data-window explanation for the *current* repro. **Fix (2nd attempt, superseded):** a render-tick-windowed callback guard shipped and was **refuted the same day** by console evidence — the drop arrives through a JS-interop hop whose latency is unbounded in render ticks, so it landed *after* the window closed and destroyed the criteria again. **Fix (final, "safe order") — remove the drop's precondition instead of filtering callbacks:** (1) **no value is ever asserted into an empty editor** — while the lookup `Data` is empty the criteria stays untouched (rows filter, the Clear button signals it) and the editor holds no value the revalidation could drop; (2) the heal `Reload()`s the **empty** editor — nothing to drop ⇒ no spurious `ValuesChanged` is raised at all; (3) **one delayed assert** (~750 ms, generously covering the measured ~300 ms JS hop; re-reads the criteria at fire time) puts the selection into the settled editor — its echo rewrites the same criteria, which is harmless; (4) **ownership-guarded clearing**: an empty selection may only clear criteria the component OWNS — a filter-row value (`Contains("EUR")`) can never be wiped by an empty callback, genuine or echoed. Every ambiguous path still resolves toward **clearing** (never an invisible filter). Residual: the delay is measured-generous, not contractual — the v2 envelope/pull model removes the heal (and the delay) entirely.

  **Planned v2 (supersedes the heal):** fold the pull model into the already-planned layout envelope (`MgGridLayoutSave { Version, GridLayoutJson, FilterPanelLayoutJson }`): strip panel-owned criteria from the DX layout at **save** time (when registered components *can* answer `OwnsCriteria`) and store them as plain `field → values`; on restore **park** them and apply criteria **and** selection atomically only once the owning component is ready (registered **and** `Data` non-empty). This makes "can this criteria be displayed yet?" true **by construction** instead of detected: no `Reload()` dependency, no rows-filtered-while-chips-empty window, `ACBLAZOR-GRID-I-D4T7` fixed by construction, and the pending-reconcile flag, the snapshot heal and `RefreshFilterPanel()` all become deletable (net complexity **down**, consumer boilerplate → zero). Standalone pull (without the envelope) was **rejected**: at auto-load time no component is registered yet, forcing shape-sniffing of the DX criteria string, which collides with the shared filter-row slot. An `INotifyCollectionChanged` subscription was also **rejected**: strong collection→component references on page-shared collections outliving `OnDemand` tab components, in a design that holds components weakly precisely because the DX seams expose no disposal hook — a lifetime bug traded for a timing bug, fixing none of the structural issues. Design decision to record when v2 lands: a parked entry with no owner this session stays parked and is **re-persisted** on save, so a temporarily absent panel cannot silently destroy a saved filter.
- Verify: `DevExpress.Data` version matching the client DevExpress Blazor major; license coverage on the server package feed; linq2db translation of `CriteriaToExpressionConverter` output (function whitelist).
- **Quick-search (`SearchText`) — confirmed SEPARATE from `FilterCriteria`.** Per DevExpress docs (Filter API): *"Search settings do not affect the applied filter criteria"* — `SearchText` filters in parallel, NOT folded into `FilterCriteria`. So server-side search does **not** ride the criteria-string path for free; it needs its **own** handling: a thin `SearchText → OR-Contains criteria` adapter over searchable columns (then it rides the same channel), or a separate search field. Same whitelist / DoS caveats apply (search = OR-Contains across many columns).
- AyCode.Blazor cross-ref: no `docs/adr/` folder there yet → note in MGGRID docs or a future AyCode.Blazor ADR.
- v2 paging ADR (`skip`/`take`/`count` + `{items,total}` envelope) when large-list pressure requires it.
- `AyCode.Core/docs/adr/README.md` index row for 0004.

## Alternatives considered

- **`AcExpressionNode` (serialized expression tree)** (rejected): serialize the DevExpress filter as a full LINQ expression tree and execute server-side (the existing test-only path). Rejected — executes arbitrary client-supplied `Expression.Call` server-side (RCE-shaped injection); sandboxing arbitrary expression trees is effectively intractable. Being deprecated independently.
- **DevExtreme `DataSourceLoader`** (rejected for the Blazor grids): the server already references `DevExtreme.AspNet.Data`, with a commented-out `DataSourceLoader.Load` prototype (`ShippingController.cs:110-127`).
  - *Fit:* DevExtreme is the JS-widget product line for the nopCommerce **admin Razor** grids — a different UI stack from the FruitBank **Blazor WASM** app grids.
  - *Cost:* using it for the Blazor grids means translating the DevExpress-Blazor `CriteriaOperator` model into DevExtreme `loadOptions` — an impedance mismatch. Reasonable for the nop admin Razor grids; wrong for the Blazor app grids.
- **Positional `string? filterText` parameter per endpoint** (rejected): append `filterText` as a trailing positional param.
  - *Cons:* with a variable-length `ContextIds` object[], "which position" is fragile; and it is per-endpoint signature editing. The `SignalParams` + attribute-injection removes both problems.
- **Naming convention (`filterText` param name) instead of an attribute** (rejected in favor of the attribute): bind by a consistently-named param + developer discipline. Rejected — silent failure on typo / rename; `[FilterCriteriaParam]` is self-documenting, rename-safe, and its param index precomputes into the cached method model.
- **Client-side opt-in flag (`HasFilterTextParam` on MgGrid)** (rejected): a per-grid bool **claiming** whether the endpoint has the filter param. Rejected — duplicates the server's authoritative cached reflection (drift-prone), and a forgotten flag = silent no-filter. Superseded by the `FilterMode` (Local / Server) **policy** enum + server mismatch warning: `FilterMode` declares the grid's own intent (not a server claim), the server's reflection stays the single authority on capability, and a `Server`-declared grid hitting an unsupported endpoint surfaces as a precise warning. The distinction is intent-vs-capability-claim, not "flag vs no-flag".
- **Framework choke-point auto-applies to `IQueryable` results** (deferred, not chosen for v1): the hub inspects the method result and, if `IQueryable<T>`, applies filter + page + count generically → zero endpoint changes.
  - *Cost:* requires endpoints to return `IQueryable<T>` (deferred) instead of `List<T>` — a bigger contract change with an `IQueryable`-lifetime concern (connection alive until the framework materializes).
  - Rejected for v1 (the per-endpoint attribute opt-in, still returning `List<T>`, is lower-risk and gradual). Revisit for v2 paging, where the framework must own `skip`/`take`/`count` anyway.

## Related

- SIGDS topic (the DataSource this extends): [`AyCode.Services/docs/SIGNALR_DATASOURCE/README.md`](../../AyCode.Services/docs/SIGNALR_DATASOURCE/README.md) + its `_ISSUES` / `_TODO` companions.
- Transport (SignalParams, hub dispatch, `AcWebSignalRHubBase`): [`AyCode.Services/docs/SIGNALR/README.md`](../../AyCode.Services/docs/SIGNALR/README.md) and [`AyCode.Services.Server/docs/SIGNALR/README.md`](../../AyCode.Services.Server/docs/SIGNALR/README.md).
- Deprecation context (the path being replaced): `AcExpressionNode` in [`AyCode.Core/Serializers/Expressions/README.md`](../../AyCode.Core/Serializers/Expressions/README.md) + `AcSerializerCommon.ApplyQueryFromNode` / `ExecuteQueryFromNode`.
- Cross-repo (client): AyCode.Blazor `MgGridBase` / `MgGridSignalRDataSource` (MGGRID topic) — no `docs/adr/` folder there yet; cross-ref to be added.
- Related TODOs: to be filed (`ACCORE-SIGDS-T-*` + `ACBLAZOR-GRID-T-*`) — see Follow-ups.
- v2 paging: a forthcoming ADR when server-side paging lands.
