diff --git a/docsSrc/ADRs/0001-Quotations-Architecture.md b/docsSrc/ADRs/0001-Quotations-Architecture.md new file mode 100644 index 00000000..f64eb2fe --- /dev/null +++ b/docsSrc/ADRs/0001-Quotations-Architecture.md @@ -0,0 +1,1371 @@ +--- +title: ADR 0001: FSharp.Azure.Cosmos.Quotations architecture +category: Architecture Decision Records +categoryindex: 4 +index: 1 +--- + +# ADR 0001: FSharp.Azure.Cosmos.Quotations architecture + +## Status + +**Accepted**, 2026-10-02. The decisions were confirmed with the maintainer between 2026-09-26 and 2026-09-28 (§2 and §18), and the maintainer decided on 2026-10-02 to keep the design in the documentation as this record. Implementation follows the roadmap of §16, one pull request per step, starting with Phase 0. + +## Context + +`FSharp.Azure.Cosmos` wraps the Azure Cosmos DB .NET SDK v3 with F# computation expressions for item operations. Querying today means raw SQL strings in a `QueryDefinition` or the SDK LINQ provider through `IQueryable`. F# `query { }` reaches that provider only after `LeafExpressionConverter` lowers quotations to `System.Linq.Expressions`, which loses F# semantics (options, pipelines, records, unions) on the way. The SDK translator itself inlines captured values as literals, gets null versus undefined wrong, rejects record projections, generates invalid SQL for `Take().Skip()`, loses decimal precision, is closed to extension and caches nothing, and its SQL model, parser and translator are internal to the SDK (§1). Two earlier branches of this repository rewrote quotations into LINQ expressions with a global mutable registry; that approach is rejected. + +The maintainer's Cosmos DB application will be the first real consumer, so its query shapes are requirements (§21), and the maintainer's earlier F# LINQ layer over Marten supplies a corpus of 489 cases and a list of defects that must not be repeated (§20). + +## Decision + +A new package family in this repository translates F# quotations into Cosmos DB NoSQL queries. The key decisions: + +- **Quotation-only translation.** Native `Microsoft.FSharp.Quotations.Expr` is translated into Cosmos NoSQL SQL text plus `@parameters` and executed through the public `QueryDefinition` / `Container.GetItemQueryIterator<'T>` / `FeedIterator<'T>` pipeline – never through `System.Linq.Expressions`, the SDK LINQ provider or SDK internals reached by reflection (§1, §4). +- **Own SQL AST and validator.** `FSharp.Azure.Cosmos.Sql` holds an AST that mirrors the SDK grammar `sql.g4` and its `SqlObjects`, a validator for the rules the grammar cannot express, a deterministic SDK-compatible printer, the function catalog as data and a hand-written scalar parser; it has no raw-text case and no SDK dependency (§5, §6). +- **One front-end pivot.** A standalone `cosmosQuery { }` builder that mirrors FSharp.Core naming without inheriting `QueryBuilder`, a functional `Query` module, compiled queries and auto-quoted method arguments all lower through one normalizer into one `QueryModel` (§3, §7, §8). Captured values are always parameters, and plans are cached per translator under a structural shape key (§11). +- **Symbolic optional pairs.** `option`, `voption`, `Skippable`, `Nullable` and nullable references translate as a pair of has-value and value expressions, so every composition works the same in predicates, projections, sort keys and group keys; the truth tables are documented and tested, and ordering between optional values has SQL meaning (§9, §20.3, §20.4, §20.5). +- **Pluggable translators without a global registry.** Immutable `Translator` instances are created from options that carry plugins in two tiers (user plugins, built-in packs), with conflicts detected at creation; an attribute route and a programmatic route register translators; geometry goes through an `IGeometryProvider` contract over abstract operations, so the core references no geometry library (§8). +- **System.Text.Json + FSharp.SystemTextJson only.** Every serializer-dependent fact comes from one `IJsonLayout` service read from the real serializer options, for the set-ups that it has been compared with the serializer for; the others are refused. Newtonsoft, the SDK default, is not supported and yields a `LayoutRequired` error (§2, §10). +- **net10.0 for every project**, `System.Collections.Immutable` and `System.Collections.Frozen` in the implementation instead of F# collections, and IcedTasks allowed behind a Debug-build probe, which Phase 0 failed in Debug, so library code keeps `task` for now (§2, §4, §16). +- **Struct tuples preferred.** Struct forms are used inside the implementation and documented first; reference forms stay accepted and give byte-equal SQL under the tuple-kind law (§2, §4, §7, §20.11). +- **No C# source generator.** fsc has no generator pipeline; the plan cache, compiled queries and an optional later build tool and analyzer capture the same benefits (§11). +- **Executable spec conformance.** Every emitted query parses under the SDK grammar; printer and parser laws, golden baselines, an interpreter ported from the SDK's test-only offline engine, three-way differential property tests (F# evaluation, interpreter, emulator) through a Hedgehog MSTest adapter, metamorphic oracles and a plugin conformance kit check the semantics. Database tests follow the maintainer's conventions: Slim, Fat and Default generator variants, class-scoped read-only datasets for property tests and a database per write test (§13, §22). + +## Consequences + +- Queries become pure, container-independent values with parameterised, cacheable SQL text that keeps F# meaning for options, records, unions, tuples and pipelines, and the translator stays open to extension without global state. +- The existing package drops `net8.0`, so the lockstep version becomes a major release (2.0.0); `Microsoft.Azure.Cosmos` moves to 3.62.0, which raises the minimum for every package through central package management (§2, §5). +- Consumers of the Quotations package take FSharp.SystemTextJson and IcedTasks as dependencies, and clients on the SDK default serializer must switch to System.Text.Json to use it (§2, §5). +- Some results deliberately differ from F# evaluation – ordering between optional values, `undefined` from integer division by zero – and are documented and reported through warning codes (§9, §20.3, §20.15). +- The repository gains a C# grammar test project, shared test infrastructure, two generator projects, a Hedgehog MSTest adapter that is also proposed upstream and a weekly Windows-emulator lane, and the function catalog has to follow SDK updates (§5, §13, §16). +- The `IQueryable` helpers of `FSharp.Azure.Cosmos` stay for APIs that require the SDK LINQ provider (§21.5). + +## Design document + +The rest of this record is the agreed design document, kept complete. Sections 20–22 take precedence where they refine earlier text. Facts marked *(verified)* were checked against sources or run in `dotnet fsi` during the design work; facts marked *(unverified)* still need a Phase 0 probe. + +> Written 2026-09-26 from an eight-part research sweep (the Cosmos SDK v3 LINQ internals, EF Core with its Cosmos provider and NetTopologySuite plugins, JasperFx/Marten/Weasel, the F# quotation machinery and prior art, the Cosmos NoSQL query-language specification, AOT and source-generator options, cross-ecosystem prior art, this repository); three independent architecture proposals were compared, synthesised, reviewed for completeness and revised. +> +> Updated 2026-09-27: maintainer decisions (§2); keyset paging (§9a); gap check against the maintainer's earlier F# LINQ layer over Marten, 489 cases (§20); requirements from the maintainer's Cosmos application (§21); database test architecture following the maintainer's conventions (§22); string equality overloads and the culture policy (§20.6); struct tuples over reference tuples (§2, §4, §10, §20.11). +> +> Updated 2026-09-28: maintainer answers on test data and property tests – generator variants Slim, Fat and Default (§2, §22.6); property tests through a Hedgehog MSTest adapter project modelled on `Hedgehog.NUnit` and `Hedgehog.Xunit` (§5, §14, §16, §22.6); `_etag`/`_ts` through comparer ignore-lists and two `.Hedgehog` generator projects (§2, §18 item 12). +> +> Updated 2026-10-03: Phase 0 findings – the IcedTasks Debug-build probe fails in Debug for three builders (§2, §12, §16, §17, §18 item 13); container cost, capacity and the leftover sweep on the Windows emulator (§22.3, §22.4, §18 item 14). +> +> Updated 2026-10-10: Phase 0 results and the outcome of the reviews of the Phase 0 pull requests – the query semantics, index usage and the Newtonsoft dependency (§16); the adapter upstream, its code style and the `DateOnly` and `TimeOnly` generators (§2, §16, §18 item 12, §22.6); the shared collection functions and the rule of one module per pipeline (§4); `dotnet test` through FAKE (§5); the spec model in two pull requests (§6, §16); the literal type provider rejected for the baselines (§13); sorted test categories, Pascal-case assertion functions and `ImmutableArray` test data (§22.2, §22.7). The Windows-emulator lane is weekly instead of nightly (§2), the shared collection functions are in the namespace `FSharp.Azure.Cosmos` (§4), and the adapter projects take the repository's formatting and nullness checking (§18 item 12). +> +> Updated 2026-10-12: the layout is built narrow first – it supports the set-ups that it has been compared with the serializer for and refuses the others – and the layout of every setting of FSharp.SystemTextJson, built in #61 to #63, is shelved (§2, §10, §16); FAKE 6.2.0 has `DotNet.testMTP`, which replaces the hand-written `dotnet test` call (§5). + +## 1. Context + +`FSharp.Azure.Cosmos` wraps the Azure Cosmos DB .NET SDK v3 with F# computation expressions for item operations and Debug-safe `FeedIterator` → `IAsyncEnumerable`/TaskSeq helpers. Querying today means raw SQL strings (`QueryDefinition`) or the SDK LINQ provider through `IQueryable` (`src/Cosmos/IterationExtensions.fs:116-131` is the only coupling to `Microsoft.Azure.Cosmos.Linq`). F# `query { }` only reaches that provider after `LeafExpressionConverter` lowers quotations to `System.Linq.Expressions`, losing F# semantics (options, pipelines, records, unions) on the way. Two unmerged branches (`origin/copilot/implement-linq-translation-methods`, `origin/copilot/handle-fsharp-language-constructs-in-linq`) tried exactly that rewriter approach with a global mutable registry; it is rejected here. + +The SDK's own translator has well-known limits *(verified in its source and issue tracker)*: it inlines closure values as literals (no parameterisation), gets null vs undefined wrong (`x == null` → `(x = null)` misses undefined, issue #4864), rejects record/constructor projections (#3728), generates invalid `TOP`-in-subquery SQL for `Take().Skip()` (#908), loses decimal precision (#3606), is closed to extension (#5329/#5382) and caches nothing. Its SQL AST (`SqlObjects`), ANTLR parser (`sql.g4`), planner and translator are all `internal` with no third-party `InternalsVisibleTo` – we own our model but may copy (MIT) its grammar, node inventory, printer rules, golden baselines and the test-only offline interpreter. + +### Goals + +1. Translate **native `Microsoft.FSharp.Quotations.Expr` only** (never `System.Linq.Expressions`, never the SDK LINQ provider) into Cosmos NoSQL SQL text + `@parameters`, executed through the public `QueryDefinition` / `Container.GetItemQueryIterator<'T>` / `FeedIterator<'T>` pipeline and the repo's existing `FeedIterator<'T>.AsAsyncEnumerable`. +2. Cover F# constructs: pipelines (`|>`, `<|`, `||>`, `|||>`, `<||`, `<|||`), composition (`>>`, `<<`), quotation splicing, point-free forms, `option`/`voption`/`Skippable`, records, anonymous records, classes with inheritance, unions, scalar wrappers, struct and reference tuples, `match`, `if`, `let`, `List`/`Array`/`Seq`/`String`/`Option` module functions, comparison/arithmetic/bitwise operators, closures, `[]` helpers. +3. Open, DI-free extensibility for operations and types (geospatial through *any* library, vectors, full text, UDFs, custom scalars) with the core referencing no geometry library. +4. Executable spec conformance: every emitted query parses under the SDK grammar; semantic claims are checked by an interpreter, differential/metamorphic property tests and the emulator. +5. Fit repo conventions: F# 10, **net10.0**, CPM, FAKE, MSTest 4, Unquote, Hedgehog (properties through an MSTest adapter project, §5, §22.6), FsToolkit, TaskSeq, `voption`/struct tuples, `task` and IcedTasks CEs (full names, never hand-rolled `ValueTask`/`Task` wrappers), XML docs (`fsdocs --strict`), Fantomas. + +### Non-goals + +Routing through LINQ; Newtonsoft document layouts (§2); a C# source generator for F# consumers (impossible, see §11); NativeAOT as a shipped feature (SDK is not trim/AOT annotated, issue #4458; FSharp.SystemTextJson is reflection-based); cross-container joins, CTEs, write statements (the language has none); silent client-side projection fallback; binding to SDK internals via reflection at runtime. + +## 2. Decisions confirmed with the maintainer (2026-09-26 to 2026-09-28) + +| Decision | Choice | +|---|---| +| Placement | This repository, separate projects and packages (see §5). | +| Serializer baseline | **System.Text.Json + FSharp.SystemTextJson only.** Newtonsoft (the SDK default serializer) is **not supported** (2026-09-27): no Newtonsoft layout ships, and a client on the SDK default serializer gets a `LayoutRequired` error telling it to set `UseSystemTextJsonSerializerWithOptions`. `IJsonLayout` stays as a small seam so a third party can add a layout. Consequence chosen for simplicity: the core package references FSharp.SystemTextJson directly and the separate adapter package is dropped (§5, §10). | +| Query surface | **Both** a computation expression and a functional quoted-lambda API over one translator, plus compiled queries. The FSharp.Core `query { }` gap table is in §3. | +| Target framework | **net10.0 for every project** – existing library, tests, build project and all new packages (2026-09-27). The SDK is already pinned to 10.0.401 in `global.json`; only the TFMs are still `net8.0`. Because the existing `FSharp.Azure.Cosmos` package drops `net8.0`, the lockstep version becomes a major bump (2.0.0). | +| IcedTasks | **Allowed, but gated** (2026-09-27): `valueTask`, `valueTaskUnit`, `cancellableTask`, `cancellableValueTask`, `taskUnit`, … with full CE names, replacing hand-rolled `ValueTask (task { … })` wrappers. IcedTasks 0.11.9 (2025-09-05, the newest stable release, MIT) targets net6.0, net9.0, netstandard2.0 and netstandard2.1 with FSharp.Core ≥ 6.0.4, ≥ 9.0.300 for net9.0 (the build a net10.0 consumer gets); netstandard2.0 also needs Microsoft.Bcl.AsyncInterfaces *(verified in the 0.11.9 nuspec)*. Gate: library code uses an IcedTasks CE only once every Phase 0 Debug-build probe (§16) passes in both Debug and Release with none skipped, because SDK 10.0.400+ stopped lowering `taskSeq` to state machines in Debug (dotnet/fsharp#20466). **Gate result (Phase 0): failed in Debug** *(verified)* – `cancellableTask`, `cancellableValueTask` and `coldTask` return wrong data or block when one value is started more than once; `valueTask` and `valueTaskUnit` pass in both configurations; the maintainer then scoped the gate per builder (2026-10-03): `valueTask` and `valueTaskUnit` are allowed in library code, the other builders stay out until every probe of theirs passes with none skipped (§12, §16, §17, §18 item 13). **Fixed upstream:** our TheAngryByrd/IcedTasks#67, released in IcedTasks 0.11.10-beta001 (prerelease, 2026-10-03), creates the state machine per start; with it all 30 probes pass in Debug and Release, none skipped, so the gate now allows `valueTask`, `valueTaskUnit`, `cancellableTask` and `cancellableValueTask`; other builders need probes first. | +| Collections in the implementation | **`System.Collections.Immutable` and `System.Collections.Frozen`, not F# collections** (2026-09-27): F# `list`/`Map`/`Set` are slow; they are allowed only where pattern matching on them clearly improves readability. Rules in §4. | +| Multi-value data | **Struct tuples over reference tuples** (2026-09-27): struct tuples for values grouped in the implementation, in contracts and as keys; anonymous struct records or named records, never tuples, as multi-value results of public functions and members; reference tuples only where the language or FSharp.Core forces them. User-facing documentation shows the struct forms first; the reference forms stay accepted and give byte-equal SQL, except for a tuple materialised into JSON whose two types the layout encodes differently, where one twin is refused (the tuple-kind law of §7). Rules in §4, user-facing forms in §20.11. | +| Ordering between optional values | **SQL meaning**: true only when both values are present; absent values are excluded, unlike F# (2026-09-27, §20.3). | +| Members with a plain `[]` | **Error until the path is declared** through `JsonPropertyName`, `[]`, `MemberPaths` or a computed property (2026-09-27, §21.2). | +| Type discriminator filter | **Automatic behind a translator option**, default off; explicit forms always available (2026-09-27, §21.1). | +| C# grammar test project | Approved (2026-09-26). | +| Versioning | Lockstep with `FSharp.Azure.Cosmos` through the single `CHANGELOG.md` (2026-09-26). | +| Windows-emulator CI lane | **Yes**: non-blocking, from Phase 0 (2026-09-27). Decided as nightly; **weekly** since the review of #45 (2026-10-10), because the lane exists only for the answers in which the two emulators differ and the Linux lane of every pull request checks the rest. The default lane stays the Linux vNext emulator. | +| SDK version | `Microsoft.Azure.Cosmos` 3.60.0 → **3.62.0** in Phase 0 (2026-09-27). | +| Superseded LINQ-rewriter branches | **Deleted** after their construct lists are mined into `Reference/Construct_Mapping.md` (2026-09-27). | +| Shared-dataset test pattern | **Yes**: class-scoped seeded datasets for the read-only conformance suites, database-per-test for write tests (2026-09-27, §22.2). | +| Generator variants | **Slim, Fat and Default** (2026-09-28): Slim – every optional member absent, every collection empty; Fat – every optional member present, every collection non-empty, no empty value at all; Default – total random: every optional member independently present or absent, every collection independently empty or not, every value from its full range. `Default` replaces the balanced fourth variant the plan proposed before and supersedes the older `Default` (pinned values)/`Random` (full ranges) pair of the maintainer's generator conventions; there is no `Random` variant (§22.6). | +| Property tests | **Through a Hedgehog MSTest adapter in its own project** (2026-09-28), modelled on `Hedgehog.NUnit` and `Hedgehog.Xunit` of `hedgehogqa/fsharp-hedgehog`: `[]`-attributed test methods, never `Property.check*` calls inside `[]` bodies (§5, §14, §22.6). | +| `_etag`/`_ts` in whole-document comparisons | **Comparer ignore-lists** (2026-09-28, §22.7). | +| Generator projects | **Two `.Hedgehog` projects** next to the test-model library (2026-09-28, §5, §22.6; project names proposed: `Cosmos.Sql.Hedgehog`, `Cosmos.Quotations.Tests.Models.Hedgehog`). | +| Hedgehog MSTest adapter location | **Both**: in this repository as `tests/Hedgehog.MSTest` and as a pull request to `hedgehogqa/fsharp-hedgehog` (`src/Hedgehog.MSTest`), opened on 2026-10-10 as hedgehogqa/fsharp-hedgehog#487; the in-repository copy is replaced by the package once upstream publishes it (2026-09-28, §5, §16). Since the review of #43 (2026-10-10) the in-repository copy follows the coding guidelines of this repository, and the upstream pull request keeps upstream's style in a copy of its own (§18 item 12). | +| Writing property tests | **None**: property tests only read class-scoped datasets; round trips stay hand-written slim/fat tests with per-test databases (2026-09-28, §22.2, §22.6). | +| Variant selection in property tests | **`AutoGenConfig` types per variant** in the `.Hedgehog` projects, named on the class or method, plus **`GenAttribute<'T>` subclasses** in the test projects for single parameters (2026-09-28, §22.6). | +| Delivery | **One pull request per Phase 0 step** from `main`; this record lives in `docsSrc/ADRs` and is updated in the same pull request as the decision it records (2026-10-02, §16). | +| `Newtonsoft.Json`, which the SDK loads at run time | **Declared by no package of this repository** (2026-10-10): as today, the SDK's own build check asks each consumer to reference it (§16, §18 item 16). | +| Scope of the JSON layout | **Narrow first** (2026-10-12): `IJsonLayout` describes the set-ups that it has been compared with the serializer for – the defaults of FSharp.SystemTextJson and the production profile of §21.3 – and `JsonLayout.ofSerializerOptions` refuses every other set-up with the reason; a type that it cannot describe is `NotDescribed`. Optional members are guarded the robust way (§9), so `OptionalEncoding` is not needed. The layout of every setting of the library (#61, #62, #63) is shelved as drafts, to be continued later (§10, §16). | + +## 3. Why not FSharp.Core `query { }` alone – gap table and builder decision + +*(verified in fsi)* A builder that inherits `Microsoft.FSharp.Linq.QueryBuilder` keeps every inherited custom operation, may add its own `[]` members, and a `Run` member on the derived type hides the base `Run` overloads and receives the whole CE body as `Expr>`. So the standard syntax *can* be reused. The gaps below are what `QueryBuilder` (verified member list from `FSharp.Core/Query.fsi`) cannot express or expresses wrongly for Cosmos. + +Legend: ✓ usable as is · ≈ usable with translator-side semantics · ✗ missing or not expressible in Cosmos SQL. + +| Need (Cosmos SQL clause / execution concern) | FSharp.Core `QueryBuilder` | Gap and plan | +|---|---|---| +| `SELECT TOP n` | `take n` only | ≈ `take` alone → `TOP @n`; explicit `top` keyword for readability | +| `OFFSET n LIMIT m` must appear together | `skip`/`take` independent; lone `skip` untranslatable | ≈ `skip` + `take` → `OFFSET @m LIMIT @n`; a lone `skip` → `OFFSET @m LIMIT 2147483647` with warning `CQ1020` (the maintainer's application pages with an optional offset and an optional count) | +| `SELECT VALUE ` vs object projection | `select` only | ≈ translator emits `SELECT VALUE` always: scalar → `VALUE expr`, record/anonymous record → `VALUE {"k": …}` | +| `DISTINCT` | `distinct` ✓ | ✓ | +| `ORDER BY a ASC, b DESC` | `sortBy`/`thenBy`(+`Descending`) ✓; `*Nullable` variants are `Nullable<'T>`-only | ≈ option/voption keys need undefined/null ordering semantics; `*Nullable` operations irrelevant for F# records | +| `ORDER BY RANK RRF(…)` (hybrid search) | none | ✗ `orderByRank` + `RRF`/`FullTextScore`/`VectorDistance` markers | +| `GROUP BY` + aggregates | `groupBy`/`groupValBy` return `IGrouping` (group *items*); Cosmos returns keys + aggregates only | ✗ dedicated `groupBy key into g` whose continuation may only use `g.Key` and aggregates | +| `JOIN t IN c.tags` (nested-array self join) | nested `for t in c.Tags do` (SelectMany) ✓ | ✓ | +| `join … on`, `groupJoin`, `leftOuterJoin` | present (`IsLikeJoin`, `on`) | ✗ Cosmos has no cross-container joins, no `ON`, no outer joins → not exposed; subquery joins `JOIN (SELECT VALUE …)` need their own operation | +| `EXISTS`/`ARRAY`/scalar subqueries inside `WHERE`/`SELECT` | `exists`/`all`/`contains` are terminal only | ≈ `List.exists`/`Array.contains`/`List.isEmpty` etc. inside predicates → `EXISTS(SELECT VALUE t FROM t IN …)`, `ARRAY_CONTAINS`, `ARRAY_LENGTH` | +| Terminal aggregates | `count`, `sumBy`, `averageBy`, `minBy`, `maxBy` ✓ | ✓ → `SELECT VALUE COUNT/SUM/AVG/MIN/MAX` | +| `head`, `headOrDefault`, `exactlyOne(OrDefault)`, `find` | present, return `'T` or `default` | ≈ `TOP 1`/`TOP 2` + client check; we return `voption` instead of `default` | +| `last`, `lastOrDefault`, `nth`, `skipWhile`, `takeWhile` | present | ✗ not expressible → must be rejected; with a standalone builder they do not exist at all (compile-time rejection) | +| Execution options (partition key, `MaxItemCount`, continuation, consistency, session token, throughput bucket, metrics, `EnableScanInQuery`, `MaxConcurrency`) | none; `query` returns `IQueryable<'T>` | ✗ `withPartitionKey` in the CE; the rest belongs to execution (`QueryRequestOptions`) | +| Result type | `IQueryable<'T>` executed by the SDK LINQ provider | ✗ `Run` returns a pure `CosmosQuery<'T>` value executed by our `Container` extensions | +| Parameterisation of captured values | after LINQ lowering the SDK inlines literals *(verified)* | ✗ translator hoists captured values into `@p` and caches the SQL template | +| Vector, full text, spatial `ST_*`, `IS_DEFINED`/`IS_NULL`, `RegexMatch`, `DocumentId`, UDFs | none | ✗ marker functions (`Cosmos.Functions`, `Cosmos.Geo`) + pluggable translators | +| `option`/`voption`/`Skippable` semantics | only `Nullable<'T>` awareness | ✗ explicit truth table (§9) | +| `into` continuations, several `where` clauses | ✓ | ✓ | +| Compile-once templates | none | ✗ `CosmosQuery.compile` | +| Struct tuples (`select struct (…)`, `let struct`/`match … with struct` destructuring) | FSharp.Core's LINQ lowering throws on `select struct (…)` ("unreachable" in `RewriteTupleType`) and on every `TupleGet` over a struct tuple (`ArgumentException` in `LeafExpressionConverter`); `sortBy`/`groupBy` with a struct key and struct anonymous record projections work *(verified in fsi, FSharp.Core 10.1.401)* | ✗ both tuple kinds translate, to the same SQL under the tuple-kind law of §7; `LeafExpressionConverter` is never used (§20.11) | + +**Decision: a standalone `CosmosQueryBuilder` that mirrors FSharp.Core's naming and attributes (`Quote`/`Run`, `[]`, `MaintainsVariableSpace`, `AllowIntoPattern`), not a subclass.** Rationale: (a) the non-translatable inherited operations (`last`, `nth`, `skipWhile`, `takeWhile`, `join … on`, `groupJoin`, `leftOuterJoin`, `*Nullable`) then fail at compile time instead of at translation time; (b) return types can be F#-idiomatic (`headOrDefault : 'T voption`, `groupBy` yielding key + aggregate scope) instead of `IGrouping`/`default`; (c) `QuerySource<'T>` becomes a phantom type with no `IQueryable`/`IEnumerable` source requirement, so queries are pure, container-independent module-level values; (d) the F# spec quirk that `Run` overloads resolve through `LowPriority`/`HighPriority` extension modules is reproduced for scalar terminals. Rejected alternative: inheriting `QueryBuilder` – verified to work, kept as a fallback if users demand literal `query { }` compatibility (a thin subclass could forward to the same translator). + +## 4. Layered architecture and data flow + +```text +user code cosmosQuery { for p in items do where (p.Age > minAge); sortBy p.Name; take 10; select {| … |} } + Query.where <@ fun (p : Person) -> p.Nick.IsSome && p.Tags |> List.contains "vip" @> + │ Expr +[1] Front end CosmosQueryBuilder (Quote/Run) · Query.* functions · CosmosQuery.compile/ofReflected Quotations pkg + │ CosmosQuery<'T> = { Expr; Cardinality } (pure, container-independent) +[2] Normalizer pipes, composition, beta, let-inlining, ReflectedDefinition, witnesses, copyOfStruct, + $"…", 2m, &&/||, tuple/record projection elimination; bounded fixpoint Quotations pkg + │ first-order Expr +[3] Parameterizer Value/ValueWithName/module values/closed subtrees → typed ParameterSlot; shape key Quotations pkg + │ Expr with slot leaves + ShapeKey + slot values +[4] Translator operator chain → QueryModel → SqlQuery (lowering table); scalar nodes through the + registry (user plugins → built-in packs → evaluation fallback) Quotations pkg + │ SqlQuery (spec model) +[5] Passes SqlQuery.validate · simplify · alias compaction · partition-key lift · capability lint Sql pkg +[6] Printer deterministic text (SDK rendering rules); Parameterize or Inline mode Sql pkg + │ CompiledPlan { Sql; Slots; Hints; Warnings; Ast } +[7] Cache/binder ConcurrentDictionary per Translator; slot values → encoders Quotations pkg + │ QueryDefinition (+ QueryRequestOptions hints) +[8] Execution Container.GetItemQueryIterator<'T> → FeedIterator<'T>.AsAsyncEnumerable / CancellableTaskSeq +``` + +Stages 2–6 are pure functions over immutable data; the only mutable state is the plan cache owned by a `Translator` instance; stage 8 is the only place touching the SDK `Container`. Plugins never throw: they return an `Outcome`, and the engine converts failures into positioned diagnostics. + +**Why an intermediate `QueryModel` between `Expr` and `SqlQuery`:** Cosmos clause order is not free (`take` then `skip`, `select` after `distinct`, aggregates after paging, `TOP` illegal in subqueries, `OFFSET` requires `LIMIT`, `groupBy` last). One immutable record lowered in one place with a unit-tested "re-nest or reject" table avoids the SDK's `Take().Skip()` bug and Marten's outer-to-inner rewrite hacks. All front ends (CE, `Query.*`, compiled queries, later `Seq` pipelines) share this pivot so semantics cannot drift. + +**Why a separate SQL AST instead of printing while walking the quotation:** structural rewrites (partition-key lifting, simplification, parameter inlining) are trivial on a DU; the printer is one place with SDK-compatible rules; `parse (print ast) = ast` properties become possible; plugins build SQL through smart constructors instead of strings. + +**Collections and allocation rules (maintainer decision).** + +- Ordered sequences in data types and public contracts are `ImmutableArray<'T>` (AST children, plugin and translator lists, parameter slots, validation errors, warnings), built with `ImmutableArray.CreateBuilder` or collection expressions, never by repeated `Add`. Precedent in this repo: `ReadManyOperation.Items` (`src/Cosmos/ReadMany.fs:10`). +- Read-mostly lookup tables built once are `FrozenDictionary<'K,'V>`/`FrozenSet<'T>`: the registry index by `MemberKey`, the catalog by function name, keyword and reserved-word sets, type-mapping and member-name tables. String keys always get an explicit comparer (`StringComparer.Ordinal`, or `OrdinalIgnoreCase` for SQL keywords and function names). +- `ImmutableDictionary`/`ImmutableList` only where persistent updates are really needed; the plan cache stays a `ConcurrentDictionary`. +- **`ImmutableArray` is equal by content only under some comparers**: it is a struct whose own `Equals`, the one `EqualityComparer<'T>.Default` calls, compares the reference of the array it wraps. The equality the F# compiler generates for records and unions goes through `IStructuralEquatable` and does compare the elements, so a record or union holding one is equal by content under `=`. That path has its price and its gaps: it boxes every element (1584 bytes for one comparison of two arrays of 32 integers), its hash code covers only the last eight elements, a struct tuple, a `HashSet` with the default comparer and a C# caller compare references, `compare` throws for arrays of different lengths, and a default array never equals an empty one *(verified in fsi, F# 10 on .NET 10)*. AST nodes therefore hold `EquatableArray<'T>` – a small struct wrapper over `ImmutableArray<'T>` with sequence equality and a combined hash code (the pattern Roslyn incremental generators use) – which makes their equality structural under every comparer, and free of allocations for elements that implement `IEquatable<'T>` as the records, unions and struct tuples of the tree do, for round-trip tests, cache keys and pass idempotence. A unit test asserts `parse (print ast) = ast` on nodes with children so a regression cannot hide. +- Pattern matching stays readable through struct partial active patterns over `ImmutableArray` whose payloads are struct tuples, not by converting to F# lists: `[] let (|Arr2|_|) (xs : ImmutableArray<'T>) = if xs.Length = 2 then ValueSome (struct (xs[0], xs[1])) else ValueNone`, matched as `Arr2 (a, b)` or `Arr2 (struct (a, b))`; `Arr3` alike, `Arr1 a` with the bare element; `Arr0` is a bool pattern `let (|Arr0|_|) (xs : ImmutableArray<'T>) = xs.IsEmpty`; other lengths fall through to the next case. `[]` makes only the `voption` a struct: a payload written `ValueSome (xs[0], xs[1])` gives `ValueOption>`, one heap tuple per match, and a multi-case pattern `(|Arr0|Arr1|ArrN|)` returns a heap `Choice` *(verified in fsi)*. F# lists remain where FSharp.Core itself hands them out – `Patterns.Call (target, method, args)` yields `Expr list` – and in the few places where a list pattern is clearly more readable than the alternative. +- User-facing marker functions take `'T seq`, so callers may pass arrays, lists or immutable collections inside quotations. +- **Shared collection functions** (maintainer request in the review of #43, 2026-10-10; #47). FSharp.Core's `tryHead`, `tryLast`, `tryItem`, `tryExactlyOne`, `tryFind`, `tryFindBack`, `tryFindIndex`, `tryFindIndexBack` and `tryPick` of `Seq`, `List` and `Array` return an `option`, and `zip` returns reference tuples. `src/Shared/ValueCollections.fs` shadows them with functions of the same names that return a `voption` or struct tuples (`Seq.zip` is `Enumerable.Zip`); the root `Directory.Build.props` compiles the file first into every F# project but the build project, and the module opens itself in the namespace `FSharp.Azure.Cosmos` (maintainer request in the review of #47, 2026-10-10; it was in the global namespace before): the shadows are in effect in the code of that namespace and in every file that opens it, and a file in another namespace, a child namespace such as `FSharp.Azure.Cosmos.Tests` included, opens `FSharp.Azure.Cosmos` to get them, because a child namespace does not open the modules of its parent *(verified)*. A shadowed function stays reachable by its full name, and a function that is missing there is added there, not converted at its call site. Their tests are properties (#50, which follows #43): each shadow is compared with the FSharp.Core function it hides on generated arrays, indexes and predicates. +- **One module per pipeline** (maintainer request in the review of #44, 2026-10-10; #49). Consecutive steps of a pipeline come from one module where they can, the conversion at the end included: `xs |> Seq.filter isValid |> Seq.sort |> Seq.toArray`, not `… |> Seq.sort |> Array.ofSeq`. The module changes only where the steps that follow need the other collection. The rule is part of the agent instructions. +- Tests and generators may use F# collections freely. + +**Multi-value data (maintainer decision 2026-09-27: prefer struct tuples).** + +- Values grouped inside the implementation – pass results, registry and cache keys, collection elements, active-pattern payloads, bind-time slot values – are struct tuples `struct ('a * 'b)` (for example the parameterizer's result: rewritten `Expr`, `ShapeKey`, slot values), or `[]` records when the fields have meanings and travel through several functions: the symbolic optional pair `OptionalPair` (§9), the `MemberKey` identity (§8), `SourceRange` (§12). Never a reference tuple. +- Public functions and members that return several values return anonymous struct records `struct {| … |}` (`QueryPageAsync`, §15) or named records (`Page`, §9a), never tuples. Collection elements in public contracts are `ImmutableArray` (`NativeOperations`, `TypeMapping.Members`), in AST nodes `EquatableArray` (`ObjectCreate`). Plugin callbacks receive the parts of a node as one struct tuple after the context (`Translator.forMethod`, §8). Contracts declare no `out` parameters, which F# callers see as reference tuples (`Dictionary.TryGetValue "k"` is a `Tuple` *(verified in fsi)*). +- **Not tuples, unchanged:** union case fields (`Forward of first : int * after : …`); tupled parameters of methods and abstract members (`abstract Translate : TranslationContext * Expr -> Outcome` compiles to a two-parameter method *(verified in fsi)*); CE builder members, which the desugaring calls as `For (source, body)`. Parentheses around an abstract member's parameter list, `abstract M : (A * B) -> C`, compile to one `Tuple` parameter *(verified in fsi)* and are never written. +- **Forced reference tuples** are accepted at the boundary, destructured at once and never stored: the payloads of FSharp.Core `Patterns.*` (`Call`, `Value`, `ValueWithName`, `WithValue`, `PropertyGet`, … return `(… * …) option`); `FSharpType.GetFunctionElements` and `FSharpValue.GetUnionFields`; the CE variable space (§7); the bodies of `[]` functions and static members with tupled parameters; `fst`/`snd`, `||>`/`|||>`/`<||`/`<|||`, `.Item1` (compiles only on reference tuples, with warning FS3220) and the `KeyValue` pattern (`Call (KeyValuePattern)` returning `Tuple<'K, 'V>`); the FSharp.Core collection functions `zip`/`zip3`/`unzip`/`indexed`/`pairwise`/`countBy`/`groupBy`/`partition`/`allPairs`/`mapFold`/`splitAt`/`unfold`, `Map.toSeq`/`toList`/`toArray`/`ofSeq`/`ofList` and `dict`; `out`-parameter results; the `DebugRange` quotation attribute (`Tuple>`, converted once into `SourceRange`) *(all verified in fsi)*. A reflection scan of the public methods of FSharp.Core 10.0.100 finds `ValueTuple` only in the `MergeSources` overloads of `TaskBuilder` and `BackgroundTaskBuilder` (`and!`) *(verified)*. F# never converts a tuple value from one kind to the other (FS0001); a syntactic `(a, b)` is compiled as a struct tuple only where a struct tuple is already the expected type *(verified in fsi)*. +- **Rewrites keep the tuple kind.** `Patterns.NewTuple` also matches struct tuples, but `Expr.NewTuple` always builds `System.Tuple`: rebuilding a struct node with it changes `Expr.Type`; a `Let` or `Call` that receives the rebuilt node as its bound value or argument then throws `ArgumentException`, while as a `Let` body the change passes silently *(verified in fsi)*. Rebuilds go through `ExprShape.RebuildShapeCombination` or, when `Type.IsValueType`, `Expr.NewStructTuple`; both keep `ValueTuple` *(verified in fsi)*. The pinned FSharp.Core 10.0.100 already exposes `NewStructTuple` and `Expr.NewStructTuple` without an assembly argument *(verified by reflection)*. +- User-facing forms – projections, group keys, sort keys, keyset keys – are in §20.11; compiled-query arguments in §7. + +## 5. Projects and packages (this repository) + +| Project | Package | Depends on | Purpose | +|---|---|---|---| +| `src/Cosmos.Sql` | `FSharp.Azure.Cosmos.Sql` | FSharp.Core, in-box System.Text.Json | Spec model: AST mirroring `sql.g4`/`SqlObjects`, `SqlQuery.validate`, deterministic printer, function catalog as data, hand-written scalar-expression parser. No SDK dependency. | +| `src/Cosmos.Quotations` | `FSharp.Azure.Cosmos.Quotations` | Sql, `FSharp.Azure.Cosmos` (project ref), FSharp.SystemTextJson (MIT), IcedTasks (MIT) | Normalizer, parameterizer, translator, registry, built-in packs, the STJ + FSharp.SystemTextJson layout (names, union encodings incl. per-type overrides, `SkippableOptionFields`, read from the converter's public `Options` member), CE builder, `Query.*`, compiled queries, execution extensions, Patch/Exists quotation overloads. | +| `src/Cosmos.Quotations.Spatial` | `…Quotations.Spatial` | Quotations (SDK `Spatial` types) | `IGeometryProvider` for `Microsoft.Azure.Cosmos.Spatial` + native-member recognition. Kept separate on purpose: the first-party adapter must use the same contract as third parties. | +| `src/Cosmos.Quotations.NetTopologySuite` | `…Quotations.NetTopologySuite` | Quotations, NetTopologySuite (BSD-3) | `IGeometryProvider` for NTS. No `NetTopologySuite.IO.GeoJSON` (drags Newtonsoft.Json); the core writes GeoJSON from a neutral `GeoShape`. | +| `tests/Cosmos.Tests.Infrastructure` | – | MSTest.TestFramework, SDK, CompareNETObjects | Base classes for both database lifetimes, emulator factory with partition budget, database identifier, container definitions, capability probe (scenario factories derive from the emulator factory but live next to their tests, §22.6), assertion helpers, `FakeFeedIterator`/`FakeFeedResponse` – extracted from `tests/Cosmos.Tests` so all test projects share it (§22). | +| `tests/Cosmos.Sql.Grammar` (C#, `IsTestProject=false`) | – | Antlr4.Runtime.Standard (BSD-3) | Parser generated from the vendored SDK `sql.g4`; generated sources checked in (regeneration script), so CI needs no Java. **Approved by the maintainer (2026-09-26).** | +| `tests/Cosmos.Quotations.Testing` | – | Sql, Quotations, Unquote | Interpreter (ported OfflineEngine), plugin conformance kit; promotable to a shipped `.Testing` package later. The Hedgehog generators live in the two `.Hedgehog` projects below (§22.6). | +| `tests/Cosmos.Sql.Hedgehog` *(name proposed)* | – | Sql, Hedgehog, FSharp.SystemTextJson, MSTest.TestFramework | Generator helper modules shared by every generator project (`Sample`, `Gen`, `CollectionGen`, `TextGen`, date modules, `NumberGen`) and the generators of the spec model (`Gen`, scalar trees) – the counterpart of the shared helper-generator library of the maintainer's application. FSharp.SystemTextJson only for `Gen.skippable`/`Gen.skippableBalanced`; the shipped `Sql` package stays free of it. | +| `tests/Cosmos.Quotations.Tests.Models` *(name proposed)* | – | Quotations | The translator test model (§22.6), namespace `FSharp.Azure.Cosmos.Quotations.Tests.Models`: records and the class hierarchy per test container, `PartitionKeys`/`Discriminators` constants, `JsonDerivedType` attribute classes, partition-key builders, the Marten corpus stand-ins (§20.14). A library, because the generator project and several test projects reference it. | +| `tests/Cosmos.Quotations.Tests.Models.Hedgehog` *(name proposed)* | – | Quotations.Tests.Models, Sql.Hedgehog, Hedgehog, MSTest.TestFramework | Leaf generators, GenConfig records and top-level generators of the test model, quotation generators and the heterogeneous-document generator – the counterpart of the persistence-model generator library of the maintainer's application. | +| `tests/Hedgehog.MSTest` *(location: maintainer decision 2026-09-28 – here and as a pull request upstream; name proposed)* | – | Hedgehog 2.0.4, MSTest.TestFramework | Hedgehog MSTest adapter, ported from `hedgehogqa/fsharp-hedgehog` release 2.0.4 (commit `a469772`, no tag) `src/Hedgehog.NUnit` and `src/Hedgehog.Xunit` (Apache-2.0, §14): `[]` over `TestMethodAttribute.ExecuteAsync`, class-level `[]`, `[]`, `GenAttribute<'T>` and `AutoGenConfig` types (§22.6). Namespace `Hedgehog.MSTest`, upstream's file layout and no reference to any project of this repository, because the same code is proposed to `hedgehogqa/fsharp-hedgehog` as `src/Hedgehog.MSTest` next to `Hedgehog.NUnit` and `Hedgehog.Xunit` (the NuGet id `Hedgehog.MSTest` is free *(verified)*); once upstream publishes the package, this project is replaced by a package reference. | +| `tests/Hedgehog.MSTest.Tests` *(name proposed)* | – | Hedgehog.MSTest, MSTest | The adapter's own suite, ported from upstream's xUnit and NUnit tests into `[]` types (§22.6). | +| `tests/Cosmos.Sql.Tests`, `tests/Cosmos.Quotations.Tests` | – | above | Test suites (§13); their Hedgehog properties run through `Hedgehog.MSTest`. The emulator client of these suites is created with `UseSystemTextJsonSerializerWithOptions` + `JsonFSharpConverter`; the existing operation tests keep their current client. | +| `benchmarks/Cosmos.Quotations.Benchmarks` | – | BenchmarkDotNet (MIT) | Cost model; outside `tests/**` by construction. | + +**.NET 10 upgrade (first change of Phase 0)** *(file list verified by grep)*: `TargetFramework` `net8.0` → `net10.0` in `src/Cosmos/FSharp.Azure.Cosmos.fsproj`, `tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj` and `build/build.fsproj`; the commented `Condition="'$(TargetFramework)' == 'net8.0'"` block in `Directory.Build.props:46` updated or removed; `README.md` ("dotnet SDK 9.0 or higher (uses F# 9)") corrected; `global.json` (10.0.401) and `.devcontainer/devcontainer.json` (10.0) already fit. The five workflows under `.github/workflows` install 10.x and 8.x: keep the 8.x runtime only while the pinned dotnet tools need it (`rollForward: false` for reportgenerator 5.4.7, fsharp-analyzers 0.31.0, fslangmcp 0.18.0, fantomas 8.0.5, fsdocs-tool 20.0.1 in `.config/dotnet-tools.json`) – check each tool on a 10-only machine, then drop 8.x or enable roll-forward. The `System.Linq.Async` 6.0.0 pin in `Directory.Packages.props` has no direct `PackageReference`; check whether anything brings it transitively, then remove it or bump it, because on net10.0 `System.Linq.AsyncEnumerable` is in-box and the old package causes ambiguous-call errors. CHANGELOG entry and major version bump. + +Build integration *(verified against `build/build.fs`, `Directory.Build.targets`)*: add every project to **both** `FSharp.Azure.Cosmos.slnx` and `FSharp.Azure.Cosmos.slnf` (FAKE builds/packs the filter); narrow `testsGlob` (`build.fs:45`) from `tests/**/*.??proj` to `tests/**/*.Tests.??proj` so the infrastructure, grammar, testing, test-model and generator libraries and the Hedgehog adapter are not run with `dotnet test --no-build` (`build.fs:376`); `tests/Directory.Build.props` marks every project under `tests/` as a test project *(verified)*, so the seven non-test projects there (infrastructure, grammar, testing, the test model, the two `.Hedgehog` generator projects and the adapter `Hedgehog.MSTest`; none of their project names ends in `.Tests`, so the narrowed glob skips them, while the adapter's suite `Hedgehog.MSTest.Tests` matches it and runs) set `IsTestProject=false` and `IsPackable=false` explicitly (otherwise `dotnet test FSharp.Azure.Cosmos.slnx` under the Microsoft.Testing.Platform runner tries to execute them); the C# grammar csproj overrides `Nullable=disable` and removes the injected FSharp.Core reference (``); the coverage assembly filter excludes the helper assemblies and `Hedgehog.MSTest`; `AssemblyInfo.fs` first in every new `src` fsproj (`generateAssemblyInfo` walks `src/**` only, `build.fs:441`); Fantomas only runs for `.fsproj`; CPM entries for Hedgehog 2.0.4 (Apache-2.0; it brings TypeShape 9.0.0 and FSharp.Core ≥ 8.0.403 transitively and contains the former Hedgehog.Experimental auto-generation, so Hedgehog.Experimental, which NuGet marks deprecated with 'The functionality has been merged into Hedgehog package', is not added *(verified in the Hedgehog 2.0.4 nuspec and the NuGet registration of Hedgehog.Experimental 0.10.0)*), `MSTest.TestFramework` at the `MSTest` version (today `Directory.Packages.props` pins only the `MSTest` metapackage, 4.2.3 *(verified)*, while the helper libraries and the adapter reference the framework package alone, §22.1), Antlr4.Runtime.Standard (BSD-3), FSharp.SystemTextJson 1.4.36 (MIT), CompareNETObjects 4.84.0 (Ms-PL, test-only), IcedTasks 0.11.9 (MIT), NetTopologySuite (BSD-3), BenchmarkDotNet (MIT) – the Hedgehog, FSharp.SystemTextJson, CompareNETObjects and IcedTasks versions are the application's (§21.7); confirm each nuspec on add; `THIRD-PARTY-NOTICES.md` for vendored MIT code, the Apache-2.0 sources of the Hedgehog adapter and the Microsoft Learn example corpus (CC BY 4.0 prose, MIT samples), packed into the `Sql`/`Quotations` nupkgs via `` (the root props pack only README/LICENSE/logo); a `catalog-diff` test that fails when the SDK's `SqlFunctionCallScalarExpression.Names` gains entries on an SDK bump; the SDK bump to ≥ 3.62 recorded as a core CHANGELOG entry because CPM transitive pinning raises every package's minimum; lockstep versioning through the single `CHANGELOG.md` for 1.0. `fsdocs --strict` builds API docs for all `src` projects, so every public member needs valid XML docs. New docs: `How_Tos/Querying_With_Quotations.md`, `How_Tos/Extending_The_Translator.md`, `How_Tos/Geospatial_Vector_FullText.md`, `Explanations/Null_And_Undefined.md`, `Reference/Construct_Mapping.md` (F# construct → SQL), `Reference/Function_Coverage.md` (generated from the catalog). + +**`dotnet test` through FAKE** *(verified)*. FAKE's `DotNet.test` cannot run the test applications of this repository: it always adds MSBuild switches such as `/nodeReuse:False`, the Microsoft.Testing.Platform runner that `global.json` selects hands them to the test application, and the application exits with code 5. `build/build.fs` therefore started `dotnet test` itself, in one function that the test run and the listing of tests (§22.1) shared. fsprojects/FAKE#2903 (opened 2026-10-10 from `xperiandri:feature/dotnet-test-mtp`) added `DotNet.testMTP` with typed options for that runner; it fixed fsprojects/FAKE#2896 and superseded the draft fsprojects/FAKE#2897. FAKE 6.2.0 has the function, and #64 replaces the hand-written call: `dotnetTest` runs `DotNet.testMTP` once for every test project, with no build, the configuration of the target and, with coverage, the Cobertura format. + +## 6. The spec model: `FSharp.Azure.Cosmos.Sql` + +**AST.** One DU case per `sql.g4` non-terminal, named after the SDK `SqlObjects` classes so SDK baselines, parser baselines and the OfflineEngine port line up 1:1: `SqlQuery` (Select, Distinct, Top, From, Where, GroupBy, OrderBy/OrderByRank, OffsetLimit), `SelectSpec` (Star/Value/List), `CollectionExpression` (Aliased/ArrayIterator/Join), `Collection` (InputPath/Subquery), `PathExpression`, `ScalarExpression` (Literal, ParameterRef, PropertyRef, MemberIndexer, Binary, Unary, Conditional, Coalesce, In, Between, Like, FunctionCall, ArrayCreate, ObjectCreate, Exists, Array, All, First, Last, Subquery), `Literal` (Boolean, Null, Undefined, Int of int64, Float, String), `BinaryOperator` (17 kinds; `Coalesce` is its own scalar case), `UnaryOperator` (4). Rules: immutable records/DUs whose child collections are `EquatableArray<'T>` (§4), so structural equality holds for round-trip tests, cache keys and pass idempotence; **no raw-text case** – the escape hatch is parsed (below); **no shift operators** (`sql.g4` lacks `<< >> >>>`; F# shifts map to `INTBITLEFTSHIFT`/`INTBITRIGHTSHIFT`); inline `Int` literals outside ±2^53 are errors unless `Semantics.AllowLossyInt64` (parameters are never range-checked; they only warn `CQ1005`); decimals are always parameters. + +**Validator.** `SqlQuery.validate : SqlQuery -> ImmutableArray` encodes rules the grammar cannot: `SELECT *` only with one alias; `OFFSET`+`LIMIT` together, literal or parameter; `TOP` never in a subquery; parameters only in scalar/TOP/OFFSET/LIMIT positions; `ORDER BY` items only property paths or catalog functions flagged `AllowedInOrderBy` (`VectorDistance`, scoring functions) and parameter-free; `GROUP BY` keys parameter-free; no `OFFSET`/`LIMIT`/`ORDER BY` inside subqueries; `ORDER BY RANK` items only scoring functions and never mixed with plain items; aliases `[A-Za-z_][A-Za-z_0-9]*` and not keywords; `IN` non-empty; object-literal keys string literals; GROUP BY projections grouped or aggregated. Nothing unvalidated reaches the printer. `ValidationError` is a struct record, and `SqlQuery.validateWith` takes the `ValidationOptions` whose shared default `validate` uses (review of #42). + +**Printer.** Copies `SqlObjectTextSerializer` rules *(verified)*: bare alias identifiers; every property access as `alias["name"]` with JSON escaping (safe for reserved words such as `value` – the 1.1.0 changelog fix – and arbitrary keys; `PropertyStyle.DotWhenSafe` optional); every binary/unary/conditional/coalesce/IN/BETWEEN/LIKE node parenthesised; strings double-quoted with `\\ \" \/ \b \f \n \r \t` and `\uXXXX` for control characters; integers via `long`, doubles via `R`/`InvariantCulture`; lowercase `undefined/null/true/false`; `udf.` prefix; clause order `SELECT [DISTINCT] [TOP n] spec FROM … WHERE … GROUP BY … ORDER BY [RANK] … OFFSET n LIMIT m`. Output is byte-comparable with SDK and EF baselines; total and culture-independent (tested under `fr-FR`). Two modes: `Parameterize` (default) and `Inline` (a `substituteParameters` pass replaces `ParameterRef`s with literal ASTs from each slot's `ToLiteral` encoder; used for `Patch.filterPredicate` – `src/Cosmos/Patch.fs:98/209` takes a plain string *(verified)* – and diagnostics). + +**Catalog as data.** `FunctionSpec = { Name; Category; Arity; ReturnKind; UndefinedRule; IndexUsage; Availability : Service|WindowsEmulator|VNextEmulator; Deterministic; OnlyInOrderByRank; DocUrl }` for the ~135 documented functions (seeded from the SDK `SqlFunctionCallScalarExpression.Names` minus internal names, and the Learn pages – note the docs index omits `NOW, AGO, DATETIMEFORMAT, YEAR, MONTH, DAY, LASTINDEXOF, SETDIFFERENCE, SETEQUAL`), plus `OperatorSpec` (precedence, JS-int32 flag) and `KeywordSpec`. `Catalog.call name args` validates arity and placement at translation time. The same table drives translators, the interpreter, emulator capability gating, index-usage advice and the generated coverage page. The coverage test is scoped: from Phase 1 it asserts every registered rule references a catalog entry; from Phase 4 it flips to "every entry is `Supported` (rule + golden test) or `Unsupported of reason`". + +**Scalar parser (shipped, hand-written).** `Sql.Parser.parseScalar : string -> Result` – small recursive descent over the `sql.g4` scalar grammar (no FParsec, no runtime dependency). Backs `Cosmos.Functions.raw` so raw text is parsed, hole-substituted and validated instead of spliced, and gives `parse (print ast) = ast` properties. The ANTLR-generated parser in tests remains the *independent* oracle. + +## 7. Front end and normalisation + +**Four entry points, one pivot, one normalizer.** + +1. `cosmosQuery { for p in items do where (…); sortBy p.Name; thenByDescending p.Age; skip 20; take 10; select {| Name = p.Name |} }` – standalone builder (§3). `Run` returns a **pure** `CosmosQuery<'T>` (quotation + result cardinality), not results. `items<'T>` is a phantom source; the container is supplied at execution, so queries are container-independent, cacheable module-level values, testable without a client. Operations: `where`, `select`, `sortBy`, `sortByDescending`, `thenBy`, `thenByDescending`, `skip`, `take`, `top`, `distinct`, `count`, `exists`, `head`, `headOrDefault`, `sumBy`/`averageBy`/`minBy`/`maxBy`, `groupBy … into`, `orderByRank`, `withPartitionKey`; nested `for l in p.Lines do` lowers to `JOIN l IN p["lines"]`. *(verified)* F# 10 accepts `[]` on extension members of a builder type, so plugin packages may add keywords, but the primary plugin surface is marker functions inside `where`/`select`/`orderByRank`. +2. Functional API: `Query.where <@ fun p -> … @>`, `Query.select`, `Query.sortBy`, `Query.take`, `Query.skip`, … composing `CosmosQuery<'T>` values – for pipeline-style users and for `Patch.filterPredicate`. +3. Compiled queries: `CosmosQuery.compile translator <@ fun (args : {| MinAge : int; City : string |}) -> cosmosQuery { … } @>` and `CosmosQuery.ofReflected <@ Queries.adults @>` (body pickled by fsc at build time via `[]`, cached by FSharp.Core per method token). `compile` also accepts curried, struct-tupled and reference-tupled argument lambdas; the documented tupled form is `fun (struct (minAge, city)) -> …`, so `Definition` takes its arguments without a heap tuple. Both tupled forms arrive in the shapes of the `betaReduce` row below *(verified in fsi)*: each component becomes the slot named after its `Let` variable, found by `Var` identity and tuple index, never by the order of the `Let`s (a struct pattern binds in descending order of the variable names) or by the number in `_argN`. A lambda over two documents is `InvalidQueryShape` (no cross-document join). +4. Auto-quoted method arguments *(verified in fsi)*: a member whose parameter is declared `[] predicate : Expr<'T -> bool>` receives the quotation of an ordinary lambda, so callers write `container.ExistsWhereAsync (fun d -> d.Email = email)` or `query.Where(fun d -> …).SortBy(_.Name)` without `<@ @>`. Plain lambdas, the `_.Member` shorthand, function references and point-free compositions all arrive as quotations with captured values as `ValueWithName`; `[]` yields `WithValue (compiledFunction, quotation)`. Limits: the same member does not accept a quotation value (type error), so quotation values go through `Query.*`; a function value passed by name is quoted as a reference to the value → `CapturedFunctionValue` unless the function is `[]`. The fluent members (`Where`, `Select`, `SortBy`, `SortByDescending`, `ThenBy`, `ThenByDescending`, `GroupBy`) are intrinsic members of `CosmosQuery<'T>`/`OrderedCosmosQuery<'T>`, never `[]` extensions on shared types such as `IQueryable<'T>` (the Marten layer's extensions shadow `System.Linq.Queryable`). + +A `Seq`/`List` pipeline front end (`items |> Seq.filter … |> Seq.sortBy … |> Seq.truncate n`) lowers to the same `QueryModel` in Phase 2. + +**Composing quotations.** Quotation splicing is the composition mechanism for reusable fragments: `let adult = <@ fun (p : Person) -> p.Age >= 18 @>` then `where ((%adult) p && p.City = city)`. Typed `%` and untyped `%%` splices arrive as `Application (Lambda (p, body), arg)` *(verified)* and are beta-reduced by the normalizer; captured values inside the spliced quotation stay `ValueWithName` slots, so the shape key and parameterisation are unaffected. On top of splicing the functional API ships combinators that build quotations: `Predicate.and'`, `Predicate.or'`, `Predicate.not'`, `Predicate.all`/`Predicate.any` over a list of predicates, and conditional filters `Query.whereIf condition predicate` / `Query.whereSome (value : 'V voption) (fun v -> <@ … @>)`, so dynamic predicate building never needs string concatenation. Point-free predicates (`Query.where <@ isAdult @>`, `<@ (=) userId @>`, `<@ not << isAdult @>`) are accepted through `etaExpand`. + +**SQL alias** = the F# lambda variable name when it is a safe identifier and not a keyword (`value`, `order`, `left`, `right`, `first`, `last`, `all`, `in`, `top`, `rank`, `group`, `by`, `escape`, …), otherwise `c`; inner aliases from element lambda names, uniquified. Generated SQL that reads like the source helps logs, index metrics and support. A variable name is *generated* when it does not match the identifier pattern, starts with `_arg` (the `_.Member` shorthand and the struct-tuple pattern `fun (struct (a, b)) -> …` yield `Lambda (_arg1, …)` *(verified)*) or with `activePatternResult` (the `KeyValue` pattern), or is one of `copyOfStruct`, `matchValue`, `tupledArg`, `patternInput` (`let (a, b) = …`, `let struct (a, b) = …`) *(verified in fsi)*; generated names and parameters synthesised by `etaExpand` map to alias `c` (element aliases `t`, `t1`, …). Several `Query.where`/`select`/`sortBy` calls with different variable names are unified to the alias of the first operation. `TranslatorOptions.RootAlias` fixes the root alias for all queries (recommended for golden files and for the service-side plan cache). Variable names never enter the shape key, so point-free, shorthand and explicit-lambda forms of one predicate share one cached plan. + +**Normalizer.** Named `Expr -> Expr` passes on `ExprShape`, run to a bounded fixpoint (32 iterations, then `NormalizationDiverged`). The implementation (#58) applies the same rewrites node by node in one walk and has other limits; the Phase 1 result in §16 says where it departs from this section. Shapes *(verified in fsi)*: + +| Pass | Handles | +|---|---| +| `eraseStructReceivers` | `AddressOf e` as the receiver of `PropertyGet`/`FieldGet`/`Call` on a value type → `e`, where `e` is a variable or a module value (`PropertyGet (None, v)`). Only struct anonymous records produce it *(verified in fsi)*: `PropertyGet (Some (AddressOf args), MinAge, [])` for a parameter or a quotation-local `let`, `Let (copyOfStruct, NewRecord …, PropertyGet (Some (AddressOf copyOfStruct), Age, []))` for a constructed value, `Let (copyOfStruct, PropertyGet (Some p, A, []), PropertyGet (Some (AddressOf copyOfStruct), X, []))` for a class property of that type (§21.3), `PropertyGet (Some (AddressOf (PropertyGet (None, v))), MinAge, [])` for a module-level value; receivers of type `[]` record, `voption`, `DateTime`, `KeyValuePair` or `ImmutableArray` (`PropertyGet (Some p, X, [])`, or a `copyOfStruct` copy when the receiver is a class property) and method calls on struct tuple values (`Let (copyOfStruct, v, Call (Some copyOfStruct, …))`) carry no `AddressOf`. Member access on a struct anonymous record held in a record field (`p.Anon.A`) is compile error FS0450 and on a captured local struct value FS3155 (§20.11), so those shapes never arrive. Runs before every other pass and on every body that `inlineReflectedDefinitions` inlines: `ExprShape.RebuildShapeCombination` throws `ArgumentException` ("Incorrect instance type") on a `PropertyGet` whose receiver is `AddressOf`, even with unchanged arguments, and `inlineLets` would substitute the copied value into the `AddressOf` (`AddressOf (PropertyGet (Some p, A, []))`) *(verified in fsi)*; the pass rebuilds such nodes with `Expr.PropertyGet`/`Expr.FieldGet`/`Expr.Call` over `e`. Without this pass struct anonymous records cannot be normalised; any other `AddressOf` stays rejected by the residual check | +| `etaExpand` | function-typed arguments of registered higher-order members (§20.2) and point-free roots *(verified)*: `<@ (=) 5 @>` is `Let (x, Value 5, Lambda (y, …))`, `<@ isAdult >> not @>`/`<@ not << isAdult @>` are `Call (op_ComposeRight/Left, [Lambda; Lambda])`, `<@ isAdult @>` is already `Lambda (c, Call (isAdult, [c]))`; a function-typed root that is not a `Lambda` gets a synthesised parameter typed from the function type's domain and is applied to it; curried multi-parameter roots (`fun a b -> …` = `Lambda (a, Lambda (b, …))`) are flattened into a parameter list | +| `normalizeDelegates` | `NewDelegate (Func<…>, vars, body)` → `Lambda` (F# lambdas passed to `System.Linq`/BCL methods *(verified)*); `FSharpFunc<_,_>.Invoke`/`InvokeFast`, `FuncConvert.ToFSharpFunc`/`FromFunc` wrappers → `Application` | +| `stripWitnesses` | `CallWithWitnesses` → `Call` (F# 5 witness passing for `+`, `*` …). A user `inline` helper with a member constraint arrives the same way: with `[]` the compiler keeps the definition under the witness-taking twin of the method (`twice$W`: `Lambda (op_Addition, Lambda (x, …))`), so the helper is inlined through that definition applied to the witnesses and the arguments; without the attribute it stays a call, which the registry reports as an unknown member with the hint to add the attribute *(verified, #58)* | +| `inlineReflectedDefinitions` | `MethodWithReflectedDefinition`/`PropertyGetterWithReflectedDefinition`, also closed generic instantiations; registry-known members never inlined; a call of a definition that calls itself, directly or through others, stays a call instead of being followed to a depth limit, and is an error when it is left in the normal form and reads the document (#58, §16); a module-level value and a member that a derived class can override stay as they are (§16). A function with tupled parameters is called as `Call (f, [a; b])` while its body is `Lambda (tupledArg : Tuple<…>, …)`, so the arguments replace `TupleGet (tupledArg, i)` and no tuple is built; a struct-tupled function is called with one `NewTuple` of `ValueTuple` and its body is `Lambda (_arg1 : ValueTuple<…>, …)`, left to `betaReduce` *(verified in fsi)* | +| `eliminatePipesAndComposition` | `op_PipeRight/Left`, `op_PipeRight2/3`, `op_ComposeRight/Left` (`Call (op_ComposeRight, [Lambda; Lambda])`) | +| `betaReduce` | `Application (Lambda …)`, tupled lambdas applied to `NewTuple`, struct and reference alike *(verified in fsi)*: `fun (a, b) -> …` is `Lambda (tupledArg : Tuple<…>, Let (a, TupleGet (tupledArg, 0), Let (b, TupleGet (tupledArg, 1), …)))`, `fun (struct (a, b)) -> …` is `Lambda (_arg1 : ValueTuple<…>, Let (b, TupleGet (_arg1, 1), Let (a, TupleGet (_arg1, 0), …)))`. A reference tupled lambda binds in positional order; a struct pattern, like `let`/`match` destructuring of either kind, binds in descending order of the variable names (`fun (struct (minAge, city))` binds `minAge` first) *(verified in fsi)*, so no rule depends on the variable name or on the binding order | +| `inlineLets` | capture-avoiding `Expr.Substitute`, with care where one variable is bound at two nested places (§16); covers `copyOfStruct` receiver copies – a **mutable** variable that nothing assigns to when the member is not known to leave the struct alone, as for `IsSome` of a `voption` property of a class or a member of a `[]` record, so a mutable variable without an assignment is inlined too *(verified, #58)* –, partial-application `Let (x, Value 1, Lambda (y, …))` shapes, CE variable-space `Let (p, TupleGet (tupledArg, 0), …)`, and `Let (patternInput, e, Let (b, TupleGet (patternInput, 1), …))`/`Let (matchValue, …)` from `let struct (a, b) = e`/`match e with struct (a, b)` and their reference forms *(verified in fsi)*. The CE variable space is **always a reference tuple**: with more than one variable in scope (nested `for`, `for` plus `let`) the compiler emits `Yield (NewTuple (p, t))` of `System.Tuple` and `Lambda (tupledArg : Tuple<…>, …)` for every custom operation, for custom builders and FSharp.Core `query` alike, also after `for struct (a, b) in xs`, with flat `TupleGet` indices beyond 7 variables *(verified in fsi)*; users cannot choose struct there, and the tuple never reaches SQL | +| `eliminateProjections` | `TupleGet (NewTuple)` for both tuple kinds (`Patterns.NewTuple` also matches `NewStructTuple`; `%A` prints both as `NewTuple`, only `Expr.Type` tells `ValueTuple` from `Tuple` *(verified in fsi)*), with flat indices for 8+ elements (quotations never walk `Rest`); `Call (Fst, [e])`/`Call (Snd, [e])` and `.Item1`/`.Item2` (`PropertyGet (Some e, Item1, [])`) → `TupleGet`, reference tuples only, since neither compiles on a struct tuple (FS0001, FS0039 *(verified in fsi)*); `PropertyGet (NewRecord)` incl. anonymous records of either kind (`NewRecord (<>f__AnonymousType…, args)` in canonical field order), `UnionCaseTest (NewUnionCase)`; rebuilt nodes keep the tuple kind (§4) | +| `desugarLiterals` | `MakeDecimal` → `Value decimal`; `PrintFormatToString` with `%P()` holes → `Concat` marker; `Box`/`Unbox`/`Coerce` to `obj` stripped; numeric conversions kept as `Convert` markers | +| `canonicalBooleans` | `IfThenElse (a, b, false)` → `AndAlso`, `(a, true, b)` → `OrElse`, double negation | + +A residual check rejects surviving `Lambda`/`NewDelegate` that are not arguments of a registered member, `Application`, `LetRecursive`, `AddressOf` not erased by `eraseStructReceivers`, loops and `TryWith` with a positioned error. An `Application` whose function is a captured compiled closure (`Application (ValueWithName (, pred), arg)` *(verified)*) gets the dedicated code `CapturedFunctionValue` with the hint to pass the predicate as a quotation and splice it (`%pred`) or to mark the function `[]`; when the argument does not depend on the document the whole sub-tree is client-evaluated into a parameter instead. Laws (§13): idempotence; `eval e = eval (nf e)` on closed expressions; `x |> f` = `f x` SQL; `(f >> g) x` = `g (f x)` SQL; `Option.isSome x` = `x.IsSome` = `match` SQL; **tuple kind**: a query and its twin written with the other kind (`struct (a, b)` ↔ `(a, b)`, `fun (struct (a, b))` ↔ `fun (a, b)`, `let struct` ↔ `let`, `struct {| … |}` ↔ `{| … |}`) give byte-equal SQL and equal slot values whenever both compile (the forced-reference forms of §4 have no struct twin) and every tuple that is materialised into JSON (a projected tuple or a projected tuple-typed group key) gets the same `TupleEncoding` answer for both CLR types – under `fSharpSystemTextJson` with the `Tuples` flag on and no converter for either type, under `systemTextJson`, or with the `Tuples` flag off, only with `IncludeFields = true`; otherwise one twin is refused – `MemberNotSerialized` for a struct tuple the layout does not serialise, `LayoutRequired` for an `Opaque` tuple type (§20.12) – and the law asserts the refusal *(encodings verified in fsi, STJ 10.0 and FSharp.SystemTextJson 1.4.36)*. Destructuring, sort keys, group-by items and comparisons never materialise a tuple, so for them the law holds under every layout. `eval` is never `LeafExpressionConverter.EvaluateQuotation`/`QuotationToLambdaExpression`: both throw `ArgumentException` on every `TupleGet` over a struct tuple, index 0 included, and `NotSupportedException` on member access to a struct anonymous record *(verified in fsi, FSharp.Core 10.1.401)*; the Unquote oracle evaluates the tree after `eraseStructReceivers`, because Unquote 7.0.1 rejects `AddressOf` *(verified)*. + +## 8. Translator, registry and plugin model + +**Operator level.** A table maps builder methods (and later `Seq/List/Array` functions on the root source) to `QueryModel = { Alias; Joins; Wheres; Orderings; RankOrdering; Skip; Take; Distinct; Projection; Terminal; PartitionKey }` with explicit variable substitution. Lowering applies an explicit re-nesting table written from the Phase 0 results (§16), which both emulators give alike *(verified, #44)*. A `FROM (SELECT VALUE …) AS x` subquery may hold a filter, a projection, `DISTINCT` or `GROUP BY` and may be nested, and the query around it may filter, project, aggregate, use `DISTINCT`, and sort and page with `ORDER BY`, `TOP` and `OFFSET LIMIT`; so `select` then `where`, `distinct` then an aggregate, and a filter over the groups of a `GROUP BY` (§20.10) re-nest. `TOP`, `ORDER BY` and `OFFSET LIMIT` are rejected inside every subquery, in `FROM`, `EXISTS`, `ARRAY` and scalar position alike (errors SC2203, SC2202, SC2204), and an `ORDER BY` outside a subquery accepts only a path of the documents, not a value the subquery computes (error 2206). So `take`, `skip` or `sortBy` followed by `where`, `distinct` or an aggregate cannot re-nest and is `InvalidQueryShape` with a documented workaround (filter before paging, or page on the client), and a sort by a value a subquery computes is `OrderByNotPath`. Shapes outside the table stay `InvalidQueryShape` until a probe promotes them; the native planner on the Windows lane remains a later oracle; `head`/`headOrDefault` → `TOP 1` + `Cardinality.Single`; `exactlyOne` → `TOP 2` + client check; `skip` without `take` → `OFFSET @m LIMIT 2147483647` with warning `CQ1020 SkipWithoutTake`; `groupBy` is followed only by its projection, which may only use the group key and aggregates, and by a filter over the projected groups (§9, §20.10). `OperatorRule`s are registrable so plugin keywords can participate. + +**Scalar level.** The engine handles structural nodes itself (`Var`, `Let` residue, `IfThenElse`, `NewTuple/NewRecord/NewArray/NewUnionCase` → literals through type mappings, `NewTuple` of either tuple kind in the form that `TupleEncoding` reports for its type and target member (§10), `Coerce` erase, `TypeTest` → `IS_*`, `UnionCaseTest` → layout-driven tag tests, `TupleGet` over a tuple-typed document path → the component path that `TupleEncoding` gives: `path[i]` under `Array`, `path["Item"]` through the naming policy with a nested `Rest` from the eighth component under `ItemObject`, `MemberNotSerialized` under `ValueNone` and `LayoutRequired` under `Opaque` (§10, §20.12)). For `Call`, `PropertyGet`, `FieldGet`, `NewObject`, `NewUnionCase`, `UnionCaseTest` it computes a `MemberKey` (`Method` of generic definition, `Property`, `Field`, `Constructor`, `UnionCase`, `OfType`; compared by module, metadata token and generic arity, held in one `[]` key) and dispatches: exact-key index (user plugins first, then built-in packs) → pattern translators (`Keys = []`, e.g. "any member whose declaring type is assignable to a registered geometry type") → evaluation fallback per `EvaluationPolicy` → `Unsupported` error listing the member, the translators consulted and the escape hatches. `Translator.Explain` returns the per-node trace. + +**`TranslationContext`** (given to every translator; linq2db's `ITranslationContext` collapsed into one interface with smart constructors): `Translate`/`TryTranslate`, `Parameter (value, clrType, nameHint)`, `Literal`, `Mapping : Type -> TypeMapping`, `Layout : IJsonLayout`, `Semantics`, `Call (name, args)` (catalog-validated), `Member (target, MemberInfo)` (layout-resolved path), `CanEvaluate`/`Evaluate`, `NewAlias`, `TranslateSubquery`, `Options`, `Warn`, `Fail`. + +**Plugin record and conflict rules.** + +```fsharp +type TranslatorPlugin = + { Id : string; After : ImmutableArray; Before : ImmutableArray + ScalarTranslators : ImmutableArray; OperatorTranslators : ImmutableArray + TypeMappings : ImmutableArray; GeometryProviders : ImmutableArray + NonEvaluatable : ImmutableArray; CatalogExtensions : ImmutableArray } +and IScalarTranslator = + abstract Id : string + abstract Keys : ImmutableArray // empty => pattern translator + abstract Overrides : ImmutableArray + abstract Translate : TranslationContext * Expr -> Outcome +and [] Outcome = Translated of Translated | NotApplicable | Failed of TranslationError +``` + +Two tiers only: user plugins (registration order refined by `After`/`Before` via topological sort; cycles throw) and built-in packs (themselves plugins, so the coverage test and conformance kit treat them uniformly). Enforced at `Translator.Create`, never silently: two exact translators in the same tier sharing a key where neither lists the other in `Overrides` → `RegistryConflictException` naming both; a plugin translator on a key also owned by a built-in simply wins; two `GeometryProviders` claiming overlapping types → conflict unless one `Overrides` the other; unknown ids → exception. Type-mapping providers form an ordered chain (plugins first), first `ValueSome` wins. Translators are immutable singletons (linq2db lesson: per-call instances poison caches). No global mutable registry (the rejected branches had one). + +**Two registration routes.** Attribute route (itself a built-in pattern translator): `[]` templates with `{n}`/`{this}` holes (parsed by the shipped scalar parser), `[]`, `[]` on stubs whose body is `Cosmos.Functions.clientOnly ()` (throws outside a quotation, like `EF.Functions`). Programmatic route: `Translator.forMethod <@ f @> (fun ctx (struct (this, args)) -> …)` with `forMethod : Expr -> (TranslationContext -> struct (Expr voption * ImmutableArray) -> Outcome) -> IScalarTranslator`; the receiver is converted once from the `Expr option` of `Patterns.Call` and the arguments from its `Expr list` *(verified in fsi: the lambda compiles against this signature, while the reference pattern `(this, args)` makes the callback take a `Tuple, FSharpList>`, one heap tuple per translated node)*. `forProperty` (`struct (Expr voption * PropertyInfo)`), `forUnionCase` (`struct (UnionCaseInfo * ImmutableArray)`) and the `TypeMapping.Equality`/`Containment` hooks of §20.8 (`struct (Expr * Expr)`) follow the same convention: context first, then one struct tuple of the node parts; `forType`, `pattern` and `TypeMapping.provider` complete the route. + +**Geometry without binding to a library.** Core defines abstract operations and generic markers: `Cosmos.Geo.distance : 'G -> 'G -> float`, `within`, `intersects`, `isValid`, `isValidDetailed`, `area` (→ `ST_*`), a neutral `GeoShape = Point | LineString | Polygon of rings | MultiPolygon` DU with a core GeoJSON writer (`[lon, lat]`, ring-orientation warning), and + +```fsharp +type IGeometryProvider = + abstract Id : string + abstract GeometryType : Type + abstract Overrides : ImmutableArray + abstract ToShape : obj | null -> GeoShape voption + abstract NativeOperations : ImmutableArray // opt-in: `point.Distance other` in library style +and NativeGeoRule = { Operation : GeoOperation; Arguments : ArgumentOrder } // ReceiverFirst | ReceiverLast | Swap | Custom of (Expr list -> Expr list) +``` + +A document field typed as geometry is a plain path; a captured geometry value becomes a `RawJson` parameter (`WithParameterStream`) written by the core from the provider's `GeoShape`, which every spatial function accepts *(verified on the Windows emulator, #44)*; comparisons between two members need no provider at all. `NativeOperations` carries an argument-order rule (NTS `polygon.Contains point` is `ST_WITHIN(point, polygon)`; `Covers`/`CoveredBy` swap arguments) and warning `CQ1008 NativeGeometrySemantics` (NTS distance is planar, `ST_DISTANCE` is metres on the WGS-84 ellipsoid – 110 574 m for one degree of latitude at the equator *(verified on the Windows emulator, #44)*). Storage side, documented and tested by the conformance kit: NTS-typed document fields require `NetTopologySuite.IO.GeoJSON4STJ` converters on the client's STJ options (STJ-only, no Newtonsoft dependency); they cannot be stored with the SDK's Newtonsoft default. A third-party library plugs in with ~20 lines (§15). This is EF's NTS plugin contract (type-mapping + method translators + evaluatable filter) re-keyed on abstract operations instead of one library's `MethodInfo`s. + +**Vector, full text, UDF, raw** are part of the language, so their markers live in core `Cosmos.Functions`: `isDefined`, `isNull`, `coalesceUndefined` (`??`), `iif`, `regexMatch`, `arrayContains`/`arrayContainsPartial`/`arrayContainsAll/Any`, `documentId`, `now ()` (`GetCurrentDateTime()`), `vectorDistance a b bruteForce options` – generic over the vector element type with type mappings for `float32[]`, `byte[]`, `sbyte[]`, `Half[]` and `ReadOnlyMemory<_>` (the SDK offers `byte[]`/`sbyte[]`/`float32[]` overloads; the service also supports float16/quantized types); `options` is a core `VectorDistanceOptions` record (`DistanceFunction`, `DataType`, `SearchListSizeMultiplier`, `QuantizedVectorListMultiplier`, `FilterPriority`) rendered inline as the service requires – the core never references `Microsoft.Azure.Cosmos.Linq` (the SDK's options type is the nested `CosmosLinqExtensions.VectorDistanceOptions`); per-field metadata via `[]` (SDK enums from the root namespace) or `Options.VectorFields`, `fullTextContains/All/Any`, `fullTextScore`, `rrf`, `udf<'R> name args`, `raw<'R> sql args` (Marten `MatchesSql` style with `{n}` holes; parsed, substituted, validated). Scoring functions are catalog-flagged `OnlyInOrderByRank`; `orderByRank` validates this and inlines OFFSET/LIMIT constants there (EF `ParameterInliner` lesson). All markers are deny-listed from client evaluation. + +**Custom scalar types.** `TypeMapping = { ClrType; JsonType; ToParameterValue : obj -> ParameterValue (Object | RawJson); ToLiteral : obj -> ScalarExpression voption; IsOrderable; Element }`. Units of measure are erased by the compiler (nothing to do); single-case DUs, enums, `DateTimeOffset`/`DateOnly`/`TimeOnly`/`Guid`, `decimal`, `int64` get built-in mappings with encodings from the layout; user value types register a provider. F# nullness is enabled repo-wide (`enable`), so value-carrying members are typed `obj | null` (`TypeMapping.ToParameterValue`, `IGeometryProvider.ToShape`, `TranslationContext.Evaluate`/`Parameter`); a `null` slot value becomes `ParameterValue.Object null` → JSON `null`, and null-valued slots are covered by the conformance kit and the differential generators. + +## 9. Semantics decisions (documented truth tables, all tested) + +`Semantics = { Null : Typed | Strict | Native; Division : IntegerAware | Native; StringConcat : ConcatFunction | Operator; CultureComparison : Reject | AsOrdinal; AllowLossyInt64 : bool }` is explicit, part of the plan-cache key, `Typed` by default. + +- **Null/undefined (`Typed`).** Guards are inserted exactly where the CLR type says a value may be absent (`option`, `voption`, `Skippable`, `Nullable`, nullable reference members); non-optional record fields are assumed present, so their comparisons carry no guard; a guard costs little anyway and keeps the index seek of the comparison it protects *(verified on the Windows emulator, §16)*. `Strict` guards every comparison; `Native` emits bare operators (documented: `undefined` excludes the item). Guard forms for an optional member `p` (robust; narrowed only with `TranslatorOptions.NarrowOptionalGuards`, §20.12): has value → `(IS_DEFINED(p) AND NOT IS_NULL(p))`; no value → `(NOT IS_DEFINED(p) OR IS_NULL(p))`; `Skip` → `NOT IS_DEFINED(p)`; `Include None` → `IS_NULL(p)`; `inner(p)` = `p` for unwrapped encodings or the field path for `CaseFields`. Everything else – equality, ordering, unwrapping and every `Option`/`ValueOption`/`Skippable` function – follows from the pair rules of the next bullet and of §20.3–§20.5. `??` is emitted only through `Cosmos.Functions.coalesceUndefined`: it replaces only `undefined` and keeps an explicit `null` *(verified on both emulators, #44)*, so it never translates `Option.defaultValue`, whose `None` may be stored as `null`. +- **Optional values are translated symbolically** as a pair of scalar expressions (internally `[] OptionalPair = { HasValue : ScalarExpression; Value : ScalarExpression }`, §4; `(h, v)` here, in §9a and in §20 is notation, not a type) rather than by per-function rules – for `option`, `voption`, `Skippable`, `Nullable` and nullable references alike. A member gives `(isSome(p), inner(p))`; `Some e`/`ValueSome e`/`Include e` give `(true, e)`; `None`/`ValueNone`/`Skip` give `(false, undefined)`; `map f` → `(h, f v)`; `bind f` → `(h AND h', v')`; `filter q` → `(h AND q v, v)`; `orElse y` → `(h OR hy, (h ? v : vy))`; `exists q` → `h AND q v`; `forall q` → `NOT h OR q v`; `contains x` → `h AND v = x`; `defaultValue d`/`defaultWith` → `(h ? v : d)`; `get`/`.Value` → `v`; `isSome`/`isNone`/`.IsSome`/`.IsNone`/`.HasValue` → `h`/`NOT h`; `count` → `(h ? 1 : 0)`; `toList`/`toArray` → `(h ? [v] : [])`; `flatten`; `toObj`/`ofObj`/`toNullable`/`ofNullable`/`toValueOption`/`ofValueOption`/`Skippable.toOption`/`ofOption`/… as pair conversions; `match` arms read the pair. Any composition (`map >> bind >> filter >> defaultValue`, pipelines, point-free forms) therefore works uniformly in `where`, `select`, sort keys and group keys – the maintainer's Marten layer supports these in WHERE only and documents the SELECT failure, an `ArgumentNullException`, in its limitations notes and in a root-cause analysis, which this design must not reproduce: every option test case runs in predicate **and** projection position. The pair is materialised into JSON only when the optional value itself is projected or compared as a whole, using the layout's encoding (`null`, omitted member, or `{"Case": "Some", "Fields": [v]}` when `UnwrapOption` is off), so a projected `option` deserialises back through the same serializer. +- **Numbers.** `int/int` → `INTDIV(a, b)`, `int % int` → `INTMOD`: both truncate toward zero like F# `/` and `%` on integers, and a zero divisor or a fractional operand gives `undefined`, which drops the row (or omits the member) where F# throws – documented divergence *(verified on both emulators, #44)*; floats → `/`, `%`, where a zero divisor gives an infinity that compares as such in a filter but fails the whole query with HTTP 400 once a returned value holds it, because JSON has no infinity or NaN *(verified, #44)* – documented, since .NET returns the infinity; `int32` bitwise → `& | ^ ~` (JS-int32 semantics coincide); `int64` bitwise → `INTBITAND/OR/XOR/NOT`; all shifts → `INTBITLEFTSHIFT/RIGHTSHIFT`; `decimal` always a parameter with warning `CQ1004 DecimalPrecision`; `int64` beyond 2^53 → `CQ1005 Int64Precision`. +- **Strings.** `+` and `$"…"` → `CONCAT(…)` (`||` has the lowest binary precedence in `sql.g4`; `+` is undocumented); `ToString(x)` inserted for non-string holes; ordinal, case-sensitive comparisons; the `StringComparison` argument of `Equals`/`Contains`/`StartsWith`/`EndsWith` follows §20.6 (`Ordinal` equality is `=`, `OrdinalIgnoreCase` is the third argument `true`, culture-sensitive values are rejected unless `Semantics.CultureComparison = AsOrdinal`); `ToLower()/ToUpper()` in predicates warn `CQ1001 FullScanFunction`. +- **Conditionals.** `if/then/else` in value position → `(c ? a : b)` (under `Typed`, conditions on optional members are guarded, so the ternary's undefined trap cannot fire); in predicate position → `((c AND a) OR (NOT c AND b))`; `match` on unions → nested conditionals over layout-provided tag paths; `IIF` only via `Functions.iif`. +- **Collections.** `List/Array/Seq.contains v p.Tags` → `ARRAY_CONTAINS(p["tags"], @v)`; `values |> List.contains p.X` with a captured list → `ARRAY_CONTAINS(@values, p["x"])` (stable SQL text, served from the index at the cost of the equivalent `IN` *(verified on the Windows emulator, §16)*); a source-literal list → `p["x"] IN (@p0, @p1, …)` up to `InlineInThreshold` (32); `exists` → `EXISTS(SELECT VALUE t FROM t IN p["tags"] WHERE …)`; `forall` → `NOT EXISTS(… WHERE NOT (…))`; `length`/`.Length`/`.Count` → `ARRAY_LENGTH`; `isEmpty` → `(ARRAY_LENGTH(x) = 0)`; `xs[i]` → `x[i]`; `List.map/filter/sumBy` in projections → `ARRAY(SELECT VALUE … FROM t IN …)`/`(SELECT VALUE SUM(…) FROM t IN …)`; `List.exists (fun t -> t.Name = "x")` over object arrays stays `EXISTS`, which uses the index exactly like `arrayContainsPartial` *(verified on the Windows emulator, §16)*. The same rules apply to `System.Linq.Enumerable` extension methods (`Any`, `All`, `Contains`, `Count`, `Where`, `Select`, `First`…; their F# lambdas arrive as `NewDelegate` *(verified)*) and to BCL collection instance members (`List.Contains/Exists/TrueForAll/Count`, `HashSet`/`ISet`/`ImmutableArray`/`ImmutableList`/`ImmutableHashSet`/`ImmutableSortedSet`/`FrozenSet.Contains`, `Array.Length`, `ICollection.Count`) and to the module functions of FSharp.Collections.Immutable (recognised by name, no package dependency), for document members and captured collections alike. +- **Projections.** Always `SELECT VALUE`: identity → `SELECT VALUE p`; scalar → `SELECT VALUE expr`; records/anonymous records/`{ p with … }` → `SELECT VALUE {"key": expr, …}` with keys from the *target* type's layout names so the same serializer reads them back (fixes SDK #3728); tuples of either kind → the form that `TupleEncoding` reports for the tuple type and target member (§10), a positional array or an `Item1`… object; a tuple type reported as not serialised is refused with `MemberNotSerialized` instead of round-tripping to default values (a struct tuple under the plain-STJ layout without `IncludeFields`), an `Opaque` one with `LayoutRequired` (§20.12); the documented positional form is `select struct (a, b)` (an anonymous struct record `select struct {| … |}` instead of a tuple where names are wanted), and its reference twin gives byte-equal SQL wherever `TupleEncoding` answers alike for both tuple types (§7 tuple-kind law, §20.11); warning `CQ1006 UndefinedInProjection` when a projected member is optional (objects drop undefined members, arrays shift; a tuple array of the wrong length or with `null` in a non-nullable element fails deserialisation under FSharp.SystemTextJson *(verified)*). +- **Paging and ordering.** `take` alone → `TOP @n`; `skip m; take n` → `OFFSET @m LIMIT @n`; `skip` alone → `OFFSET @m LIMIT 2147483647` with warning `CQ1020`; keyset paging in §9a; `take` without `sortBy` warns `CQ1007 NondeterministicTop`; multi-key `sortBy` produces advice `CompositeIndexRequired`; `sortBy` on an optional member needs none, because `ORDER BY` keeps the documents without the value and sorts them where F# sorts `None` – first in ascending, last in descending order (§9a); a tuple sort key – `sortBy (struct (p.Name, p.Age))`, the documented form, or its reference twin with byte-equal SQL – is split into one `ORDER BY` item per component in the operation's direction (`ORDER BY p["name"] ASC, p["age"] ASC`), each component subject to the path rules below; mixed directions need `thenBy`/`thenByDescending` or `SortKey` values; `distinct`/`groupBy` disable continuation (documented). A sort key that unwraps an optional member (`p.Count.Value`, `Option.get`, `Option.defaultValue d`) is lowered to the member path with warning `CQ1011 DefaultIgnoredInOrderBy` – parity with the maintainer's Marten layer, which strips the wrapper for ORDER BY; documents lacking the value sort as `undefined`/`null`, not as `d` – and any other computed sort key is `OrderByNotPath` unless `Options.ComputedProperties` maps the expression to a container computed property. +- **Dates.** Always parameters; contract: stored values and parameters go through the same serializer, so ISO-8601 UTC strings compare correctly; `DateTimeOffset` with non-zero offset warns `CQ1003`; `DateTime.UtcNow` inline is evaluated client-side into a parameter (deterministic, index-friendly); `now ()` is the server-side form. `.Year/.Month/…` → `DateTimePart`, `.AddDays` → `DateTimeAdd`. Cosmos ticks are 100 ns since the Unix epoch, not .NET ticks – rebase in `DateTimeToTicks` mappings. + +## 9a. Keyset (seek) paging + +The maintainer pages with Relay-style cursors in both projects: the Marten layer has only keyset paging; the Cosmos application is a hybrid, a continuation token inside an opaque cursor while the direction is unchanged and keyset predicates when it flips. A continuation token is forward-only, opaque, bound to the query text and unavailable with `distinct`/`groupBy`; keyset cursors are stable, bidirectional and shareable at the price of a composite index. Both are offered; the docs say when to prefer which. Implementation in Phase 2, emulator probes in Phase 0. + +```fsharp +type Cursor<'Id, 'Key> = { Id : 'Id; Key : 'Key } // 'Key = struct tuple of the sort-key values, or the bare value for one key (item 4) +type PageRequest<'Id, 'Key> = + | Forward of first : int * after : Cursor<'Id, 'Key> voption + | Backward of last : int * before : Cursor<'Id, 'Key> voption +type Page<'T, 'Id, 'Key> = + { Items : ImmutableArray<'T>; StartCursor : Cursor<'Id, 'Key> voption; EndCursor : Cursor<'Id, 'Key> voption + HasNext : bool; HasPrevious : bool } +``` + +1. **One N-ary implementation** over the `SortKey` list, no arity overloads (the Marten layer has hand-written overloads for one, two and three keys). With the id as key N+1 the predicate is the lexicographic `OR over i of ((AND over j < i of (k_j = @v_j)) AND (k_i op_i @v_i))`. One ascending key, forward: `SELECT TOP @first VALUE c FROM c WHERE ((c["date"] > @k1) OR ((c["date"] = @k1) AND (c["id"] > @id))) ORDER BY c["date"] ASC, c["id"] ASC`. +2. **Direction matrix.** Forward + Asc → `>` and `ASC`; Forward + Desc → `<` and `DESC`; Backward + Asc → `<` and `DESC`; Backward + Desc → `>` and `ASC`. Backward pages are fetched in inverted order and reversed by the execution extension; `n + 1` rows are fetched to compute `HasNext`/`HasPrevious`. +3. **Tie-breaker.** `FollowFirstKey` (default): the id direction follows the first key, so one composite index serves both sort directions and both page directions. `Ascending` is parity with the Marten layer and needs a second, mixed-direction composite index. `id` is unique only inside a logical partition: a cross-partition keyset needs the partition key among the sort keys or a partition-scoped query, otherwise `InvalidQueryShape`. +4. **Cursor components are separate named slots** (`@k1`, `@k2`, `@id`) encoded by the user's serializer. `'Key` is a struct tuple with one component per `SortKey` (the documented form, as in both of the maintainer's code bases), a reference tuple, or the bare value for a single key (F# has no one-element tuple syntax). It is decomposed with `FSharpType.GetTupleElements`/`FSharpValue.GetTupleFields`, which handle both kinds and flatten 8+ components, never with `FSharpValue.PreComputeTuplePropertyInfo`, which throws on `ValueTuple` *(verified in fsi)*; `Query.page` checks the component types against the key types of `keys` and reports `InvalidQueryShape` on a mismatch. The whole `'Key` is never one value: plain STJ writes a struct tuple as `{}` *(verified)*, so an application that serialises `Cursor<'Id, 'Key>` into an opaque token needs FSharp.SystemTextJson, which writes it as a JSON array, or `IncludeFields = true`. The predicate is built as a scalar tree and goes through the normal translator, so scalar wrappers, optional keys and layout paths follow §8–§10. Ordering operators must therefore work on wrapper keys (string wrappers such as `TypedString<_>`, id wrappers). +5. **Optional keys** follow the Cosmos type order `undefined < null < boolean < number < string`, not PostgreSQL `NULLS LAST`: in `ASC` absent values come first. Each lexicographic branch is built with the pair `(h, v)` and the captured cursor pair; `simplify` removes constant-false branches; the predicate is never short-circuited as a whole (a known defect of the Marten code: paging stops inside the NULL bucket). A keyset over an optional key requires a single absence encoding for that member; under `NullOrUndefined` it is `InvalidQueryShape` with that hint. +6. **Index caveats**: a single-property `ORDER BY` keeps the items whose sort path is undefined and returns them first in ascending order, then sorts by type – undefined, `null`, booleans, numbers, strings, arrays, objects *(verified on both emulators, #44)*. A multi-property `ORDER BY` over a composite index keeps them too, because the index holds a value that marks an undefined path, and sorts every key that way: the documents without the first key come first, and among equal first keys those without the second; the exact inverse of the index returns the exact reverse *(verified on both emulators, #44)*. Such an `ORDER BY` is served only by a composite index whose path sequence and ASC/DESC pattern match exactly or are the exact inverse: the Windows emulator refuses any other mix of directions with HTTP 400 ("does not have a corresponding composite index"), as the documentation of indexing policies says, while the vNext emulator, which has no composite indexes yet, sorts without one *(verified, #44)* – only the Windows lane catches a missing index. `CompositeIndexRequired` prints the required index definition. +7. **Tests.** Property: concatenating all pages, forward and backward, equals the unpaged ordered result, including documents whose keys are absent or null. One golden baseline per row of the direction matrix. + +## 10. Serializer and member-naming integration: `IJsonLayout` + +Everything that depends on how documents were serialised goes through one service: + +```fsharp +type IJsonLayout = + abstract Identity : string + abstract MemberName : MemberInfo -> string voption // ValueNone = not serialised + abstract OptionalEncoding : MemberInfo voption * Type -> OptionalEncoding // option / voption / Nullable / Skippable + abstract UnionLayout : Type -> ImmutableArray // head = write encoding; tail = accepted legacy read encodings + abstract CollectionEncoding : MemberInfo voption * Type -> CollectionEncoding voption // ValueNone = not a JSON array + abstract MapEncoding : Type -> MapEncoding // Object | ArrayOfPairs (JsonFSharpOptions.MapFormat) + abstract Polymorphism : Type -> PolymorphismInfo voption // discriminator property and values (JsonPolymorphic / JsonDerivedType) + abstract EnumEncoding : Type -> EnumEncoding // String when a JsonStringEnumConverter applies + abstract TupleEncoding : MemberInfo voption * Type -> TupleEncoding voption // per tuple type and member ([] on a class property is honoured); ValueNone = not serialised ({} under plain STJ) +// OptionalEncoding = NullOrUndefined | AlwaysNull | AlwaysUndefined | CaseFields of tag * FieldLayout (JsonFSharpOptions without UnwrapOption) +// UnionLayout = ScalarWrapper | Cases of tag : TagPlacement * fields : FieldLayout +// TagPlacement = Adjacent of tagName | Internal of tagName | External | Untagged | BareString +// FieldLayout = Positional of fieldsName | Named of fieldsName | UnwrappedSingleField of fieldsName | UnwrappedRecord | NoFields +// CollectionEncoding = { Element : Type; Empty : AlwaysArray | NullWhenEmpty | OmittedWhenEmpty | NullOrOmittedWhenEmpty } +// TupleEncoding = Array (positional, arity 8+ flattened, exact length) | ItemObject (Item1…Item7 + nested Rest, through the naming policy) | Opaque (another converter, §20.12) +``` + +**Narrowed on 2026-10-12** (§2). The first layout supports the set-ups that it has been compared with the serializer for and refuses the others: `ofSerializerOptions` gives the layout or the reasons, `OptionalEncoding` and `Identity` are not built, and a union is described per case. §16, *Phase 1 result: the layout*, says where the implementation departs from the text below; the layout for every setting of the library, which this section describes, is shelved there. + +Keyed by member because FSharp.SystemTextJson's `SkippableOptionFields`/`WhenWritingNull` apply to record fields and per-type overrides exist *(verified in the FSharp.SystemTextJson sources)*. + +**Tuples are resolved per type and member** because the two kinds serialise differently outside FSharp.SystemTextJson *(verified: STJ 10.0 and FSharp.SystemTextJson 1.4.36, round trips run)*. `JsonFSharpConverter` – one flag, `JsonFSharpTypes.Tuples`, on by default, covers both kinds – writes `(1, "a")` and `struct (1, "a")` as `[1,"a"]`, also under the production profile (§21.3), flattens arity 8+ into one array and reads back only an array of exactly that length. Plain STJ writes a reference tuple as `{"Item1":1,"Item2":"a"}` and a struct tuple as `{}` (`ValueTuple` has fields, no properties), and reads any object into a struct tuple as default values without an error; `IncludeFields = true` makes both kinds `{"Item1":…}` with a nested `"Rest"` from arity 8. The naming policy applies to these names (`SnakeCaseLower`: `item1`, `rest`), and they are matched case-sensitively. A user converter for one tuple type, placed before `JsonFSharpConverter`, changes that type alone. `JsonFSharpOptions` per-type overrides never reach tuples, and FSharp.SystemTextJson 1.4.36 ignores a member-level `[]` on an F# record field, but STJ honours it on class properties under both layouts and on record fields under plain STJ *(verified)*. So `fSharpSystemTextJson` answers `Array` for both kinds, `Opaque` (§20.12) when `options.GetConverter` returns a converter that does not come from the FSharp.SystemTextJson assembly (its tuple converters are `JsonTupleConverter`/`JsonStructTupleConverter`) or when a class property carries `[]`, and the plain-STJ answer when the `Tuples` flag is off; `systemTextJson` answers `Opaque` for a type-level or member-level converter, `ItemObject` for reference tuples, and for struct tuples only when `IncludeFields` is set, otherwise `ValueNone`. Under `MapFormat.Object` a `Map` with a tuple key of either kind cannot be serialised at all (`NotSupportedException`), so such members are refused; under the default `ObjectOrArrayOfPairs` the key follows `TupleEncoding` (`[[[1,"a"],10]]`). `KeyValuePair` is not a tuple for either serializer: it is the object `{"Key": …, "Value": …}` with policy names. + +Implementations: + +- `JsonLayout.fSharpSystemTextJson options` – **the supported layout.** Finds the `JsonFSharpConverter` in `options.Converters` and reads its public `Options : JsonFSharpOptions` member *(verified in the FSharp.SystemTextJson sources, `src/FSharp.SystemTextJson/All.fs`)*: `JsonUnionEncoding`, `SkippableOptionFields`, `JsonName`, `WithOverrides` per type; STJ attributes (`JsonPropertyName`, `JsonIgnore`, `JsonInclude`) and the naming policy; `Skippable<'T>` handled as the real type (cases `Skip`/`Include`, members `isSkip/isInclude/IsSkip/IsInclude`, module functions); narrows the guard forms when the encoding is known (`AlwaysNull` → `IS_NULL(p)` alone). +- `JsonLayout.systemTextJson options` – plain STJ without the F# converter: member names and naming policy only. Option, voption and union members are refused with a `MemberNotSerialized`-class diagnostic that names the missing `JsonFSharpConverter` (STJ 10 writes `Some v`/`ValueSome v` as the bare `v` and `None` as `null`, which this layout does not model, and throws `NotSupportedException` for unions *(verified)*). Struct tuples are refused the same way unless `IncludeFields = true`; reference tuples are `{"Item1": …}` objects (§10, tuples). +- `JsonLayout.ofCosmosLinqSerializer` – names only, over an explicitly supplied base layout, when the user's serializer derives from the public abstract `CosmosLinqSerializer`. +- `JsonLayout.ofClientOptions : CosmosClientOptions -> IJsonLayout` – **auto-detection**: when `UseSystemTextJsonSerializerWithOptions` is set, `fSharpSystemTextJson` if the converter is present and `systemTextJson` otherwise. `CosmosClientOptions` and `CosmosClient.ClientOptions` hand back the very options instance that was set, whose `Converters` hold the `JsonFSharpConverter` with its public `Options` *(verified with SDK 3.62.0 and FSharp.SystemTextJson 1.4.36)*; that property is read first, because with it set `Serializer` is not `null` but the SDK's own `CosmosSystemTextJsonSerializer`, and a client on the SDK default has `null` options and a wrapper of the Newtonsoft serializer *(verified)*; a custom `Serializer` that implements `IJsonSerializerOptionsProvider` (a one-member interface shipped by this package, `abstract Options : JsonSerializerOptions`) → the layout of those options, read from the final instance the serializer holds – this is how the maintainer's own application configures its client (a `CosmosLinqSerializer` subclass assigned to `CosmosClientOptions.Serializer`, never `UseSystemTextJsonSerializerWithOptions`); a `CosmosLinqSerializer` without that interface → names only plus advice `CQ1009`; any other custom serializer or the SDK default (Newtonsoft) serializer → `LayoutRequired` error asking for STJ options or an explicit layout. The explicit route `Translator.Create { … Layout = JsonLayout.fSharpSystemTextJson jsonOptions }` is the primary documented sample. Execution extensions that receive a `Container` and no explicit `Translator` use this (`Translator.OfClient`). + +**Defaults.** `Translator.Default` (used when no container is in scope, e.g. `patch { filterPredicate <@ … @> }`) assumes default `JsonFSharpOptions` with default STJ naming and emits advice `CQ1009 LayoutNotConfigured` the first time an optional or union member is translated. Captured values are always encoded by the user's own serializer (parameters), so value encoding is correct under every layout; only member names and null/undefined/tag *paths* depend on the layout. + +## 11. Parameterisation, shape key, cache, compiled queries – and the source-generator verdict + +The implementation (#60) departs from the rule table below in four rules – literals, values that the quotation builds, branches that are not taken, and captured values that are used at several places – and computes the shape key of the normal form; the Phase 1 result in §16 says where and why. + +**Rule table** (EF funcletizer simplified – F# closures are already leaves *(verified)*): `Value`/`ValueWithName`/`WithValue` → slot named after the captured local (`@minAge`) or `@p`; `PropertyGet (None, moduleValue)`/`FieldGet` → slot (constant only for `[]`), so module-level values do not fragment the cache; closed sub-trees with no free query variable (`DateTime.UtcNow.AddDays -1.0`, `Guid.Parse "…"`) → evaluated per `EvaluationPolicy` (`PropertyChains` default: `Value` leaves, static/instance property and field chains, `New*` nodes, `Call` with evaluated arguments, via reflection `GetValue`/`Invoke`; **never `Expression.Compile`**; `LeavesOnly` and `Never` available; markers deny-listed) → slot; `take`/`skip`/`top` operands → slots (`TOP @n` is legal); source-written primitive literals (`p.Age > 18`) → slots by default (`ParameterizeLiterals = true`; stable SQL text for the service plan cache; `InlinePrimitiveLiterals` for readable SQL and golden files); `Query.constant x` forces a literal, `Query.parameter x` forces a slot; identical captured values deduplicate. A `ParameterInliner` pass (EF pattern) turns slots back into literals wherever the backend rejects parameters – `ORDER BY` expressions, `GROUP BY` keys and the `OFFSET`/`LIMIT` of an `ORDER BY RANK` query – and such inlined values are hashed into the shape key. Union tags and object-literal keys are layout facts, always inlined. Never inlining captured values is a correctness decision: the SDK's `SqlQuerySpec` converter routes non-primitive parameter values through `UserSerializer.ToStream(value)` and writes primitives (string/number/bool) directly *(verified in `CosmosSqlQuerySpecJsonConverter`; harmless for the types that matter)*, so `DateTimeOffset`, enums with converters, GeoJSON, options and unions are encoded exactly as stored documents. + +**Captured tuples** of either kind are never one slot. A component read arrives as `TupleGet (ValueWithName (t), i)` (over `PropertyGet (None, t)` for a module value) and becomes one typed slot per component, read with `FSharpValue.GetTupleField`/`GetTupleFields` – never with `PreComputeTuplePropertyInfo` or `LeafExpressionConverter`, which throw on `ValueTuple` *(verified in fsi)*. `(a, b) = t` arrives as `Call (op_Equality, [NewTuple …; ValueWithName …])` for both kinds *(verified in fsi)* and is expanded component-wise into `AND`, one slot per component. Ordering between tuples (`<`, `compare`) is `OrderingNotSupported` in 1.0, with the hint to write the lexicographic form or use keyset paging (§9a). + +**Shape key.** `Expr.Equals`/`GetHashCode` are unusable *(verified: reference-equal `Var`s, freshly allocated by every `<@ @>` evaluation through `Expr.Deserialize40`)*. One visitor computes node kinds, member keys including generic type arguments (`List.contains` ≠ ``), the type of every tuple node (the two tuple kinds may encode differently, §10), de Bruijn indices for bound variables, typed slot placeholders and the hash of every value that is inlined into SQL text (`Query.constant`, `[]`s, `InlinePrimitiveLiterals`, the length of a source-literal `IN` list, `ParameterInliner` results) while extracting slot values; a hit only binds slots. `ConcurrentDictionary` bounded by `PlanCacheSize` with clear-on-full eviction (simple and tested; LRU later if benchmarks justify) per `Translator`; a `Translator` fixes layout and semantics, so plans never cross configurations. Per-slot `Bind : obj -> ParameterValue` (Marten `ICompiledQueryAwareFilter` idea) lets translators post-process bound values (LIKE escaping, `float32[]` vectors, `GeoShape` → GeoJSON). The cache holds SQL text and slot binders, never a `QueryDefinition`: `QueryDefinition.WithParameter` mutates the instance and returns it, so a shared definition lets concurrent calls overwrite each other's parameters – the defect fixed in 1.2.0 (#32, `getExistsQuery` in `src/Cosmos/Cosmos.fs:208-211`). `ToQueryDefinition` and `CompiledQuery.Definition` build a fresh `QueryDefinition` on every call, and a concurrency test binds different values from parallel tasks and asserts each result. + +**Compiled queries.** `CosmosQuery.compile translator <@ fun (args : {| … |}) -> cosmosQuery { … } @>`: the outer lambda's arguments, their record-field accesses and their tuple components (§7) become named slots symbolically (no Marten sentinel trick; booleans work); the quotation is evaluated once at module init, removing the per-call `Expr.Deserialize40` cost; `CompiledQuery<'Args,'T>` exposes `Sql`, `ParameterNames`, `Definition : 'Args -> QueryDefinition`, `Iterator`. `CosmosQuery.ofReflected` resolves a `[]` function whose body fsc pickled at build time. + +**C# source generator: no.** Roslyn generators and interceptors run only inside C#/VB compilations; fsc has no generator pipeline (fslang-suggestions #864 open since 2020, effort XXXXL; dotnet/fsharp #14300 backlog); a generator referenced from an F# project is silently ignored and can never observe quotations or `[]` bodies. EF Core's precompiled queries are C#-only (Roslyn syntax → interceptors) and do not support the Cosmos provider (#33909). Precompilation is a startup feature, not throughput: EF's compiled queries save ~107 µs on a ~670 µs round trip; Marten 9 measured cold 75 ms → 3 ms and steady 710 → 490 µs; our cold cost is a translation of tens–hundreds of µs. The F# strategy that captures the same benefits, in order of value: (1) the shape-key plan cache (translation leaves the hot path; the SDK LINQ provider caches nothing); (2) `CosmosQuery.compile` and `ofReflected` (no `Deserialize40` per call, no `Expression.Compile`); (3) an optional later `FSharp.Azure.Cosmos.Quotations.Build` dotnet tool / MSBuild target after `CoreCompile` that loads the built assembly in an isolated `AssemblyLoadContext`, pre-translates `[]`/`[]` members into a manifest keyed by content hash (not metadata token), pre-seeds the cache and fails the build on untranslatable queries – justified only by Phase 0 benchmark numbers; (4) an optional FSharp.Analyzers.SDK "unsupported member" analyzer fed by exported catalog/registry data (FCS version coupling acknowledged). Myriad (untyped AST) and type providers (cannot see the current compilation) do not apply. A C#-consumer companion (Roslyn `SemanticModel` → our SQL AST) is out of scope unless measured demand appears. AOT discipline is kept (no `Expression.Compile`, no LINQ provider, no evaluator libraries at runtime, reflection limited to metadata reads and whitelisted `GetValue`/`Invoke`) but not promised. + +## 12. Execution integration and diagnostics + +**Execution.** Extensions on `Container` in `FSharp.Azure.Cosmos.Quotations` (`[]` module, tupled members, `[]` request options and cancellation token last; names chosen not to collide with SDK intrinsics): `QueryIterator<'T> (query, ?translator, ?requestOptions, ?continuationToken) : FeedIterator<'T>` (so `AsAsyncEnumerable`, `TaskSeq.ofFeedIterator`, `CancellableTaskSeq.ofFeedIterator` and `FirstAsync` apply unchanged), `QueryAsync<'T>` through the existing hand-written enumerator (never `taskSeq {}` for `IAsyncEnumerable` implementations – Debug regression dotnet/fsharp#20466), `QueryCountAsync (query, …)`, `QueryExistsAsync (query, …)`, `ExistsWhereAsync (predicate, …)` – a `Query*`/`*Where*` family chosen so the members never join the existing `Container.CountAsync` (three overloads)/`LongCountAsync`/`ExistsAsync (id, …)` overload sets in `Cosmos.fs` and the C# surface stays unambiguous (a Phase 1 compile test covers F# and a C# reference project) – and `QueryTryFirstAsync (query, …) : Task>>` via `CosmosResponse.fromFeedResponse` and the `HandleException` active pattern; overloads without `translator` build one from `JsonLayout.ofClientOptions`. `CancellableTaskSeq.ofQuery ct container query` follows the token-first curried convention. Asynchronous members are written with the IcedTasks CEs the gate of §2 allows – every probe of the builder green in Debug and Release with none skipped (§16). With IcedTasks 0.11.10-beta001 that holds for `cancellableTask`/`cancellableValueTask`, used for token-first curried functions, and for `valueTask`/`valueTaskUnit`, used for `IAsyncEnumerator`/`IAsyncDisposable` members; any other builder keeps `task` until probes of its own pass. #41 converted the existing hand-rolled wrappers: the feed iterator's `MoveNextAsync` (`ValueTask (moveNext)` in `src/Cosmos/IterationExtensions.fs`) now uses `valueTask`, and the `DisposeAsync` of `tests/Cosmos.Tests.Infrastructure/IntegrationInfrastructure.fs` (`|> ValueTask`) uses `valueTaskUnit`. `RequestHints` (partition key from `withPartitionKey` or the Phase 3 lifting pass driven by `Options.PartitionKeyPaths : FrozenDictionary>`) merge into `QueryRequestOptions`. First internal consumers, added as extensions so `FSharp.Azure.Cosmos` never depends on the Quotations package *(verified in fsi: an extension `[]` member can overload the intrinsic keyword with a different argument type)*: `patch { filterPredicate <@ fun c -> c.Qty > 0 @> }` (`PatchBuilder` and `PatchConcurrentlyBuilder`; inline print mode, `FROM c WHERE …`) and quotation overloads of `ExistsAsync`/`IsNotDeletedAsync` (`src/Cosmos/Cosmos.fs:296-386`; since 1.2.0 the overload with a full partition key is a point read, so its quotation counterpart keeps that path through point-read lifting, §8). + +**Diagnostics.** `TranslationError = { Code; Message; Fragment (F#-syntax rendering by an in-package printer; Unquote stays test-only); Path (["where"; "&&"; "right"]); Location : SourceRange voption (from `DebugRange` custom attributes, which quotation literals, `[]` bodies and auto-quoted arguments carry in Debug and Release builds alike, whatever `--quotations-debug` says *(verified with SDK 10.0.401)*; a tree built through the `Expr` API carries none, so the location stays optional); Hints (the three escape hatches: `Query.parameter`/local binding, `Cosmos.Functions.raw`, a plugin or raw `QueryDefinition`); Trace }`. Codes: `Unsupported`, `UnknownMember`, `CapturedFunctionValue`, `NoTypeMapping`, `NoGeometryProvider`, `MemberNotSerialized`, `LayoutRequired`, `InvalidQueryShape`, `OrderByNotPath`, `OrderingNotSupported`, `CultureComparisonNotSupported`, `AmbiguousNestedOption`, `InvalidParameterValue`, `NormalizationDiverged`, `CatalogArity`, `GrammarViolation`, `RawSqlNotParsable`, `LiteralEncodingUnavailable`, `ClientEvaluationFailed` (carries the inner exception, the slot name and the evaluated F# fragment). The warning codes are listed in one table in §20.15. `Translator.TryTranslate` returns `Result`; `Translate` throws `CosmosQueryTranslationException` (derives from `InvalidOperationException` for C# consumers). Warnings/advice are values with stable codes (`CQ1001`…), never logs; `Options.OnWarning` integrates any logger; `Translator.Explain` returns plan, resolved names, JSON shapes and the per-node translator trace. + +**Capability lint (stage 5).** Coded warnings when a query approaches service/emulator limits – `maxJoinsPerSqlQuery` 5 (also the Windows emulator cap), `maxLogicalAndPerSqlQuery` 2000, `maxSqlQueryInputLength` 262144 – which let-inlining duplication, `List.exists` subqueries and nested JOINs can hit; a golden test covers each limit. + +## 13. Spec-conformance test architecture + +Ordered by value per effort; every rung reuses one helper that parses every emitted SQL string under the SDK grammar. + +1. **Grammar conformance.** `tests/Cosmos.Sql.Grammar` vendors `sql.g4` (fix the empty `LEX_IDENTIFIER` alternative), checks in the generated C# parser, exposes `Grammar.parse` + a `CstToAst` visitor (port of the SDK's) into our AST. `Assert.ParsesUnderSdkGrammar` runs on every query produced anywhere. +2. **Printer and parser laws (Hedgehog adapter properties, no emulator).** `[]` methods (§22.6) whose parameters come from `Gen` and the scalar-tree generators built from the catalog: `print` total and culture-independent; `Grammar.parse (print ast) = ast`; `Sql.Parser.parseScalar (print scalar) = scalar`; `validate ast = []`; SDK parser-baseline inputs as goldens. +3. **Golden baselines** (`Baselines//.sql` + `.params.json`, whitespace-normalised, `COSMOS_REWRITE_BASELINES=1` rewrites; hand-rolled for simplicity and full control of the rewrite switch; Verify.MSTest was not evaluated in depth – older versions use `VerifyBase` inheritance, which F# can consume – and can be adopted later). A literal type provider was evaluated on the maintainer's question in the review of #46 and rejected *(FSharp.Data.LiteralProviders 1.0.3, verified on SDK 10.0.401)*: it freezes the content of a file into the assembly at compile time, has no lookup by case name at run time, resolves paths against the compiling project and cannot create the baseline of a new case, so the rewrite switch would still need the run-time path; the baselines stay files read at run time. Seed corpus: the SDK's 70+ `Linq*.xml` baselines translated to quotations, MEVD vector/hybrid strings, EF Cosmos `AssertSql` strings, spring-data/Micronaut shapes; the construct mapping table (`Reference/Construct_Mapping.md`) indexes them. Shape assertions that must tell the tuple kinds apart check `Expr.Type.IsValueType` or match `NewStructTuple`, never printed text: `%A` prints `<@ struct (1, "a") @>` and `<@ (1, "a") @>` alike as `NewTuple (Value (1), Value ("a"))`, and Unquote 7.0.1 `Decompile` drops `struct` from patterns (`let struct (a, b) = p.Pair` decompiles as `let a, b = p.Pair`) *(verified in fsi)*. +4. **Coverage matrix** (build-breaking, scoped as in §6) generating `Reference/Function_Coverage.md`. +5. **Semantic oracle: `Interpreter`** – port of the SDK test-only `OfflineEngine` (MIT, ~12 files: `SqlInterpreter`, `ScalarExpressionEvaluator`, `BuiltinFunctionEvaluator`, `DataSourceEvaluator`, `Projector`, aggregates) to our AST over `System.Text.Json.Nodes`, extended with parameter binding, `LIKE`, `STRINGEQUALS`, `DateTime*`, `RegexMatch`, `IIF`, and the documented semantics (undefined propagation, three-valued logic, dropped undefined members/elements, type order `Undefined < Null < Boolean < Number < String < Array < Object`, `SUM/AVG` undefined on mixed types, `??` on undefined only). Validated by the ported SDK `OfflineEngineTests` and by every Microsoft Learn function-page example (query, input, documented output) run on both interpreter and emulator; disagreements go to a documented deviation list. +6. **Three-way differential property tests.** Typed Hedgehog generators of F# predicate/projection quotations over a `Target`-like record (ints, int64, floats, decimals, strings, bools, `DateTimeOffset`, enums, units of measure, single-case DUs, option/voption/Skippable incl. `Skippable<'T option>`, nested records, arrays/lists, struct and reference tuples (arities 2, 3 and 8+), struct and reference anonymous records, DUs with fields; a compiled quotation cannot read a struct anonymous record held in a field of the `Target` record (FS0450, §20.11), so generators build those trees through the `Expr` API) and heterogeneous documents (missing properties, explicit nulls, wrong-typed values, empty arrays). Two document generators: well-typed documents feed the three-way comparison; heterogeneous documents (which do not deserialise into the record) feed only (b)=(c) and the metamorphic suites. Compared: (a) F# meaning via Unquote `eval` on deserialised documents, (b) translate then interpret, (c) translate then run on the emulator over a class-scoped shared dataset (`[]`, fixed Hedgehog seed; database-per-test is kept for write tests). The dataset is a scenario of §22.6: its factory draws the well-typed documents from `DefaultGen` (the total-random variant, §22.6) and the heterogeneous ones from `heterogeneousDocumentGen` with the seeded `Sample` members in its constructor, `SeedDataAsync` only persists them, and the properties – `[]` methods of the dataset's read-only class (§22.2, §22.6) – take only quotations and captured arguments as generated parameters, never documents of the dataset. Under `Typed`/`Strict`, (a)=(b)=(c) on id sets; under `Native`, (b)=(c) and the divergence from (a) is asserted explicitly. Runs per `JsonFSharpOptions` variant (defaults; `SkippableOptionFields.Always`; options without `UnwrapOption`; a non-default union encoding; camelCase naming policy), as one derived `[]` per variant over the abstract suite base (§22.2) or as data rows ahead of the generated parameters (§22.6). Datasets use a fixed seed per suite through the seeded `Sample` members, properties the same seed through the adapter's `Seed` setting, and a failing property's result carries the recheck data and a paste-ready `[]` (§22.6). +7. **Metamorphic oracles on the emulator (SQLancer style).** TLP with the partitions `p`, `NOT p`, `NOT IS_BOOL(p)` (a defined non-boolean value falls in neither of the first two, so `NOT IS_DEFINED` alone is wrong): `|p| + |NOT p| + |NOT IS_BOOL(p)| = |all|`, verified first on heterogeneous documents; NoREC: `COUNT(1) WHERE p` = `SUM(p ? 1 : 0)`; PQS: pivot-document predicates. They catch translator and emulator bugs alike without a second engine. +8. **Normalizer, registry and cache laws** (§7 laws, including the tuple-kind law: every golden case with a tuple, an anonymous record or a destructuring pattern, except the forced-reference forms of §4 and the forms whose struct twin the compiler rejects in quotations (§20.11), has a twin in the other kind that asserts byte-equal SQL and equal slot values or, for a projected tuple whose type the layout does not serialise or reports as opaque, the refusal; duplicate keys throw, `Overrides` resolves, `After/Before` cycles throw; same shape with different values yields identical SQL and different bindings; determinism under parallel translation; unsupported programs never yield `Ok`). +9. **Emulator lanes.** Categories as `TestCategoryBaseAttribute` subclasses: `QuotationsUnit`, `SpecConformance`, `Differential`, `Metamorphic`, `EmulatorFeature(Spatial|Vector|FullText|Udf|IndexPolicy)`. A capability probe at `[]` issues small queries; `[]` tests call `Assert.Inconclusive` when the vNext Linux emulator (CI) lacks the feature (no UDFs – "not planned"; index policies are no-ops, so a missing composite index goes unnoticed *(verified, #44)*; no full text; no spatial functions – one in the projection fails with HTTP 500, "This query type isn't supported yet", and a spatial filter gets an answer without documents, which the SDK cannot read *(verified, #44)*; known `ORDER BY VectorDistance` crash shapes, emulator issue #350). `build.fs` gains an optional `TEST_FILTER` so Windows/macOS jobs can run `Unit|SpecConformance`; a weekly, non-blocking Windows-emulator lane (from Phase 0, maintainer decision; §2) covers spatial/UDF/index behaviour and, behind its own category, the native `QueryPartitionProvider` planner oracle via reflection (Windows x64, brittle across SDK versions, never on the default path). +10. **Plugin conformance kit** (`PluginConformance.run : TranslatorPlugin -> ConformanceReport`): rules resolve to catalog entries, encoders round-trip through `JsonNode` and the emulator accepts the parameter, rules are pure and deterministic, no partial trees, each rule has a golden case; run for every first-party adapter and a fake third-party geometry provider (proves the core has no coupling – Marten's untested `IMemberSource` lesson). +11. **Fakes and plumbing.** `FakeFeedIterator`/`FakeFeedResponse` (moved to shared infrastructure) unit-test execution and `CosmosResponse` mapping without the emulator. +12. **Benchmarks.** Quotation deserialisation, shape-key pass, translation, `QueryDefinition` construction, emulator round trip; numbers gate Phase 5. +13. **Layout conformance** (no emulator, §22.5): the facts the layout reports are asserted against JSON produced by the real serializer, per serializer profile. +14. **Polymorphism** (§21.1): accept and reject lists of discriminators per type, driven by reflection over the registrations. +15. **Corpora.** The ported Marten shapes (§20.14) and the query shapes of the maintainer's Cosmos application (§21.4) run through rungs 1, 3, 5, 6 and the emulator. +16. **No partial translation** (§20.1): mutation tests, conjunct coverage, packs loaded alone, rules never hit. + +The infrastructure, data generators, assertion helpers and style of all emulator-backed rungs follow §22. + +## 14. Reusable modules and code to copy (licences) + +- **Microsoft.Azure.Cosmos** (MIT): `QueryDefinition.WithParameter/WithParameterStream`, `Container.GetItemQueryIterator<'T>`, `QueryRequestOptions`, public `Spatial` types, `CosmosLinqSerializer.SerializeMemberName`, `CosmosClientOptions.UseSystemTextJsonSerializerWithOptions`. Bumped to 3.62.0 in Phase 0 (maintainer decision; STJ geometry converters fixed in #6025). +- **FSharp.Azure.Cosmos** (project reference): `FeedIterator<'T>.AsAsyncEnumerable`, `CancellableTaskSeq`, `CosmosResponse`, `HandleException`/`CosmosException` patterns, `Patch` builders, `CosmosName` bracket-notation precedent. +- **Copy from Azure/azure-cosmos-dotnet-v3** (MIT, attribution in `THIRD-PARTY-NOTICES.md`): `Microsoft.Azure.Cosmos/src/Query/Core/Parser/sql.g4` (grammar project), `src/SqlObjects/*` node inventory and `SqlObjectTextSerializer` rules (AST and printer), `src/Linq/BuiltinFunctions/*` mapping tables and `SqlFunctionCallScalarExpression.Names` (catalog and built-in packs), `tests/Microsoft.Azure.Cosmos.Tests/Query/OfflineEngine/*` (interpreter port), `Microsoft.Azure.Cosmos.EmulatorTests/BaselineTest/TestBaseline/Linq*.xml` and parser baselines (test data), `CosmosElementToTypeOrder` (ordering oracle), `CstToAstVisitor` (grammar-to-AST), `LinqTestsCommon` differential pattern. +- **dotnet/efcore** (MIT, algorithms only, no package): `ExpressionTreeFuncletizer` rule table, `CosmosReadItemAndPartitionKeysExtractor` (partition-key lift and point-read detection), `CosmosQuerySqlGenerator` switch tables, NTS plugin contract shape (`IMethodCallTranslatorPlugin`/`ITypeMappingSourcePlugin`/`IEvaluatableExpressionFilterPlugin`), `EF.Functions` stub pattern, `AssertSql`/`EF_TEST_REWRITE_BASELINES` workflow, `QueryTestBase` differential harness. +- **FSharp.Core `Query.fs`/`Query.fsi`** (MIT): `Quote/Run`, `CustomOperation`/`ProjectionParameter` conventions, `LowPriority/HighPriority` `Run` overload trick, `SpecificCallToMethod`/`RewriteExpr` patterns; **FSharp.Linq.ComposableQuery** (MIT, dormant): Cheney/Lindley/Wadler normalisation rules (beta, for-unnesting, if-splitting, projection elimination, fixpoint) – copy rules, not the package; **Brahma.FSharp** (design only): pass pipeline + translation-context shape; **linq2db** (MIT, design only): `IMemberTranslator`/`ITranslationContext`, `[Sql.Function]` templates, F# option translator precedent (PR #5704), singleton-translator lesson; **Marten/Weasel** (MIT, design only): `IMethodCallParser` registry, `MatchesSql` escape hatch, `LinqRefusals` uniform refusal message, `ICompiledQueryAwareFilter` bind-time value transforms, `LinqTestContext` differential harness; **WebSharper** (Apache-2.0, design only): `[]`/`[]` attribute-driven extensibility; **Micronaut/MEVD/spring-data** (Apache-2.0/MIT, golden strings only): `IS_DEFINED`/`IS_NULL` composites, `ARRAY_CONTAINS(x, v, true)`, `EXISTS(SELECT VALUE t FROM t IN …)`, `VectorDistance … AS score`, `ORDER BY RANK RRF(…)`; **Rezoom.SQL** (MIT, design only): annotated AST + spec-test record shape; **SQLancer** (MIT, design only): TLP/NoREC/PQS. +- **Copy from hedgehogqa/fsharp-hedgehog** (Apache-2.0, release commit `a469772` "Release v2.0.4" of 2026-06-29, the commit the Hedgehog 2.0.4 nuspec names – the repository has no `v2.0.4` tag, its newest tag is `v2.0.0` *(verified)*; attribution in `THIRD-PARTY-NOTICES.md` and in every copied file): the framework-neutral files that `src/Hedgehog.NUnit` and `src/Hedgehog.Xunit` share – `Prelude`, `ReflectionHelpers`, `AutoGenConfig`, `IPropertyAttribute`, `RecheckAttribute`, `GenAttribute`, `GenAttribute.Prelude`, `PropertyContext`, `InternalLogic` – into `tests/Hedgehog.MSTest` (§22.6). After normalising whitespace, comments and the namespace, `PropertyContext.fs`, `GenAttribute.Prelude.fs`, `RecheckAttribute.fs`, `AutoGenConfig.fs`, `IPropertyAttribute.fs` and `GenAttribute.fs` are identical in the two adapters, `InternalLogic.fs` differs only in one indexer spelling (`[0]`/`.[0]`), and `Prelude.fs` and `ReflectionHelpers.fs` carry extra xUnit helpers *(verified)*. NUnit's copies of the nine files compile against Hedgehog 2.0.4, MSTest.TestFramework 4.2.3 and net10.0 with only the namespace changed – without warnings when nullness checking is off, with 23 nullness warnings (FS3261, FS3262, FS3264) under the repository's `Nullable=enable` *(verified)*. The framework layers are not copied: NUnit's `ISimpleTestBuilder`/`IWrapTestMethod`/`HedgehogTestMethod` and xUnit v3's discoverer, test case and runners have no MSTest counterpart. +- **Packages**: Hedgehog 2.0.4 (Apache-2.0, test-only, the application's version; properties run through the `Hedgehog.MSTest` adapter project, never through `Property.check*` inside `[]`, §22.6; Hedgehog.Experimental is not used, because Hedgehog 2.x contains its auto-generation (`Gen.autoWith`, `IAutoGenConfig`) and NuGet lists it as deprecated, merged into Hedgehog *(verified)*. An earlier, unpublished Xunit → MSTest text rename of commit `fad70bd` (2023-07-10) of the old public `hedgehogqa/fsharp-hedgehog-xunit` repository is not the adapter's base *(verified by building copies of it)*: the rename is mechanical, pins SDK 6.0.411, targets netstandard2.0 with Hedgehog.Experimental 0.7.0 and no MSTest package, and uses types MSTest does not have (`MSTest.FactAttribute`, `MSTestTestCaseDiscoverer`, `MSTestTestInvoker`, …), so it fails with FS0039; the xUnit v2 discoverer and runner chain it renames has no MSTest counterpart. Reused from it: the attribute surface, which upstream kept, and its `PropertyTests.fs` as a behaviour checklist), CompareNETObjects 4.84.0 (Ms-PL, test-only, §22.7), Unquote 7.0.1 (Apache-2.0, already referenced; test-only `eval`/`decompile`), Antlr4.Runtime.Standard (BSD-3, test-only), FSharp.SystemTextJson 1.4.36 (MIT, core dependency), IcedTasks 0.11.9 (MIT, core dependency), NetTopologySuite 2.x (BSD-3, plugin only), BenchmarkDotNet (MIT, benchmarks only). **Parallelism:** FSharp.Collections.ParallelSeq 1.2.0 (`PSeq`, Apache-2.0, netstandard2.0, FSharp.Core ≥ 5.0.0 *(verified in the nuspec)*), the F# API over Parallel LINQ (`System.Linq.ParallelEnumerable`), may be used where many independent, CPU-bound items are processed: pre-translating every `[]`/`[]` member in the precompilation tool (§11), warming the plan cache for a set of compiled queries at start-up, and test tooling such as interpreting generated document sets, regenerating golden baselines and building the coverage matrix (§13). It is not used on the per-query hot path, where one translation takes microseconds and Parallel LINQ's partitioning would cost more than it saves. Translators are immutable and the plan cache is a `ConcurrentDictionary` (§4, §11), so parallel translation needs no extra locking; results are materialised into `ImmutableArray` at the end (§4). Each use is justified by a benchmark (§13 rung 12) before it ships, and a shipped package takes the dependency only if such a use lands in it. Not used: FParsec (unnecessary), NetTopologySuite.IO.GeoJSON (Newtonsoft dependency), FSharp.Quotations.Evaluator (2020, `Expression.Compile`), JasperFx.CodeGeneration (Roslyn at runtime; no benefit over F# closures), EF Core Cosmos provider types (internal API), FSharp.CosmosDb/Cosmonaut (raw SQL / archived). + +## 15. API sketch (condensed) + +```fsharp +// ---------- FSharp.Azure.Cosmos.Sql ---------- +// EquatableArray<'T> : struct wrapper over ImmutableArray<'T> with sequence equality and combined hash (§4) +type ScalarExpression = + | Literal of Literal | ParameterRef of ParameterName | PropertyRef of Identifier + | MemberIndexer of target : ScalarExpression * index : ScalarExpression // c["name"], arr[0] + | Binary of BinaryOperator * ScalarExpression * ScalarExpression + | Unary of UnaryOperator * ScalarExpression + | Conditional of ScalarExpression * ScalarExpression * ScalarExpression + | Coalesce of ScalarExpression * ScalarExpression // ?? (undefined only) + | In of ScalarExpression * negated : bool * EquatableArray + | Between of ScalarExpression * negated : bool * ScalarExpression * ScalarExpression + | Like of ScalarExpression * pattern : ScalarExpression * negated : bool * escape : string voption + | FunctionCall of FunctionRef * EquatableArray // via Catalog.call + | ArrayCreate of EquatableArray + | ObjectCreate of EquatableArray + | Exists of SqlQuery | Array of SqlQuery | All of SqlQuery | First of SqlQuery | Last of SqlQuery | Subquery of SqlQuery +and SqlQuery = + { Select : SelectSpec; Distinct : bool; Top : SpecValue voption; From : CollectionExpression voption + Where : ScalarExpression voption; GroupBy : EquatableArray; OrderBy : EquatableArray + OrderByRank : bool; OffsetLimit : struct (SpecValue * SpecValue) voption } +module SqlQuery = val validate : SqlQuery -> ImmutableArray; val simplify : SqlQuery -> SqlQuery +module Printer = val print : PropertyStyle -> SqlQuery -> string; val printPredicate : PropertyStyle -> Identifier -> ScalarExpression -> string +module Parser = val parseScalar : string -> Result +module Catalog = + val all : ImmutableArray; val byName : FrozenDictionary // OrdinalIgnoreCase + val call : string -> ImmutableArray -> Result + +// ---------- FSharp.Azure.Cosmos.Quotations ---------- +[] type QuerySource<'T> = class end // phantom +val items<'T> : QuerySource<'T> +type CosmosQuery<'T> = { Expr : Expr; Cardinality : Cardinality } // pure, container-independent + +type CosmosQueryBuilder () = + member Quote : Expr<'T> -> Expr<'T> + member Run : Expr> -> CosmosQuery<'R> + member For : QuerySource<'T> * ('T -> QuerySource<'R>) -> QuerySource<'R> + member For : seq<'T> * ('T -> QuerySource<'R>) -> QuerySource<'R> // JOIN t IN c["arr"] + member Yield : 'T -> QuerySource<'T> + [] + member Where : QuerySource<'T> * [] ('T -> bool) -> QuerySource<'T> + [] member Select : QuerySource<'T> * [] ('T -> 'R) -> QuerySource<'R> + [] member SortBy : QuerySource<'T> * [] ('T -> 'K) -> QuerySource<'T> + // sortByDescending, thenBy, thenByDescending, skip, take, top, distinct, withPartitionKey, orderByRank, + // count, exists, head, headOrDefault (voption), sumBy/averageBy/minBy/maxBy, + // groupBy … into g : Group<'K,'T> (Key/Count/Sum/Average/Min/Max/DistinctCount/CountIf only) +[] module CosmosQueryRunExtensions = // FSharp.Core LowPriority/HighPriority trick for scalar terminals + module LowPriority = type CosmosQueryBuilder with member Run : Expr<'T> -> CosmosQuery<'T> + module HighPriority = type CosmosQueryBuilder with member Run : Expr> -> CosmosQuery<'T> +val cosmosQuery : CosmosQueryBuilder + +module Query = // whole module, every function curried with the query last + val source<'T> : CosmosQuery<'T> + val ofType<'Base, 'Derived when 'Derived :> 'Base> : CosmosQuery<'Base> -> CosmosQuery<'Derived> // discriminator filter (§21.1) + val where : Expr<'T -> bool> -> CosmosQuery<'T> -> CosmosQuery<'T> + val whereIf : bool -> Expr<'T -> bool> -> CosmosQuery<'T> -> CosmosQuery<'T> + val whereSome : 'V voption -> ('V -> Expr<'T -> bool>) -> CosmosQuery<'T> -> CosmosQuery<'T> + val select : Expr<'T -> 'R> -> CosmosQuery<'T> -> CosmosQuery<'R> + val sortBy : Expr<'T -> 'K> -> CosmosQuery<'T> -> OrderedCosmosQuery<'T> // no 'K : comparison constraint + val sortByDescending : Expr<'T -> 'K> -> CosmosQuery<'T> -> OrderedCosmosQuery<'T> + val thenBy : Expr<'T -> 'K> -> OrderedCosmosQuery<'T> -> OrderedCosmosQuery<'T> // thenBy without sortBy does not compile + val thenByDescending : Expr<'T -> 'K> -> OrderedCosmosQuery<'T> -> OrderedCosmosQuery<'T> + val orderBy : SortKey<'T> seq -> CosmosQuery<'T> -> OrderedCosmosQuery<'T> // sort field and direction chosen at run time + val groupBy : Expr<'T -> 'K> -> CosmosQuery<'T> -> CosmosQuery> // must be followed by select over the group + val distinct : CosmosQuery<'T> -> CosmosQuery<'T> + val take : int -> CosmosQuery<'T> -> CosmosQuery<'T>; val top : int -> CosmosQuery<'T> -> CosmosQuery<'T> + val skip : int -> CosmosQuery<'T> -> CosmosQuery<'T> + val skipSome : int voption -> CosmosQuery<'T> -> CosmosQuery<'T>; val takeSome : int voption -> CosmosQuery<'T> -> CosmosQuery<'T> + val withoutTypeFilter : CosmosQuery<'T> -> CosmosQuery<'T> // opt out of the discriminator filter + val paging : skip : int -> take : int -> CosmosQuery<'T> -> CosmosQuery<'T> // OFFSET @skip LIMIT @take + val page : PageRequest<'Id, 'Key> -> id : Expr<'T -> 'Id> -> keys : SortKey<'T> seq -> CosmosQuery<'T> -> CosmosQuery<'T> // keyset, §9a + val withPartitionKey : PartitionKey -> CosmosQuery<'T> -> CosmosQuery<'T> // prebuilt, hierarchical or prefix keys accepted + val crossPartition : CosmosQuery<'T> -> CosmosQuery<'T> + val whereExpr : Expr -> CosmosQuery<'T> -> CosmosQuery<'T> // programmatic quotations (§20.10) + val constant : 'T -> 'T; val parameter : 'T -> 'T // force literal / slot inside a quotation +module SortKey = + val asc : Expr<'T -> 'K> -> SortKey<'T>; val desc : Expr<'T -> 'K> -> SortKey<'T>; val invert : SortKey<'T> -> SortKey<'T> +module Predicate = + val and' : Expr<'T -> bool> -> Expr<'T -> bool> -> Expr<'T -> bool>; val or' : Expr<'T -> bool> -> Expr<'T -> bool> -> Expr<'T -> bool> + val not' : Expr<'T -> bool> -> Expr<'T -> bool>; val all : Expr<'T -> bool> seq -> Expr<'T -> bool>; val any : Expr<'T -> bool> seq -> Expr<'T -> bool> + val ofDerived<'Derived, 'Base when 'Derived :> 'Base> : Expr<'Derived -> bool> -> Expr<'Base -> bool> // type test plus coerced predicate +module Field = + val ofName<'T> : string -> Expr // member by CLR or JSON name, nested with '.', resolved through the layout + +type TranslatorOptions = + { Layout : IJsonLayout; Semantics : Semantics; Plugins : ImmutableArray; PropertyStyle : PropertyStyle + EvaluationPolicy : EvaluationPolicy; ParameterizeLiterals : bool; InlineInThreshold : int + PartitionKeyPaths : FrozenDictionary>; PlanCacheSize : int; Trace : bool + OnWarning : (Warning -> unit) voption + // fields introduced by §9, §20 and §21: + RootAlias : string voption; NarrowOptionalGuards : bool; PreferArrayContains : bool + ComputedProperties : FrozenDictionary; VectorFields : FrozenDictionary + MemberPaths : FrozenDictionary // query-only aliases and backing fields + MemberDefaults : FrozenDictionary // non-optional members that may be absent + OptionalEncodingOverrides : FrozenDictionary; EmptyCollections : FrozenDictionary + Discriminators : DiscriminatorOptions // matching mode, per-type overrides, automatic filter + Scope : QueryScope voption; ScopePolicy : FrozenDictionary } +[] type Translator = + static member Create : TranslatorOptions -> Translator // validates registry; RegistryConflictException + static member Default : Translator // STJ + FSharp.SystemTextJson defaults (container-less use only) + static member OfClient : CosmosClient -> Translator // layout from ClientOptions.UseSystemTextJsonSerializerWithOptions; LayoutRequired otherwise + member TryTranslate : CosmosQuery<'T> -> Result + member Translate : CosmosQuery<'T> -> CompiledPlan + member ToQueryDefinition : CosmosQuery<'T> -> QueryDefinition + member PredicateSql : Expr<'T -> bool> -> string // Inline mode, for Patch.filterPredicate + member Explain : CosmosQuery<'T> -> TranslationReport +module JsonLayout = + val fSharpSystemTextJson : JsonSerializerOptions -> IJsonLayout // reads JsonFSharpConverter.Options + val systemTextJson : JsonSerializerOptions -> IJsonLayout // names only; option/union members and struct tuples without IncludeFields refused + val ofCosmosLinqSerializer : CosmosLinqSerializer -> IJsonLayout -> IJsonLayout + val ofClientOptions : CosmosClientOptions -> IJsonLayout +module CosmosQuery = + val compile : Translator -> Expr<'Args -> CosmosQuery<'T>> -> CompiledQuery<'Args, 'T> + val ofReflected : Translator -> Expr<'Args -> CosmosQuery<'T>> -> CompiledQuery<'Args, 'T> + +module Cosmos = + module Functions = // markers: throw outside quotations, recognised by MemberKey, never client-evaluated + val isDefined : 'T -> bool; val isNull : 'T -> bool; val coalesceUndefined : 'T -> 'T -> 'T + val iif : bool -> 'T -> 'T -> 'T; val now : unit -> DateTime; val regexMatch : string -> string -> string voption -> bool + val arrayContains : 'T seq -> 'T -> bool; val arrayContainsPartial : 'T seq -> 'U -> bool + val vectorDistance : 'V -> 'V -> bool -> VectorDistanceOptions voption -> float // 'V = float32[] | byte[] | sbyte[] | Half[] | ReadOnlyMemory<_> via type mappings + val fullTextContains : string -> string -> bool; val fullTextScore : string -> string seq -> float; val rrf : float seq -> float + val udf<'R> : string -> obj seq -> 'R; val raw<'R> : string -> obj seq -> 'R; val clientOnly : unit -> 'T + module Geo = // library-agnostic; providers supply GeoShape + val distance : 'G -> 'G -> float; val within : 'G -> 'G -> bool; val intersects : 'G -> 'G -> bool + val isValid : 'G -> bool; val isValidDetailed : 'G -> obj; val area : 'G -> float + +[] module ContainerQueryExtensions = + type Container with + member QueryIterator<'T> : CosmosQuery<'T> * [] translator : Translator * [] QueryRequestOptions * [] continuationToken : string -> FeedIterator<'T> + member QueryAsync<'T> : CosmosQuery<'T> * [] Translator * [] QueryRequestOptions * [] CancellationToken -> IAsyncEnumerable<'T> + member QueryCountAsync<'T> : … -> Task; member QueryLongCountAsync<'T> : … -> Task; member QueryExistsAsync<'T> : … -> Task + member ExistsWhereAsync<'T> : [] predicate : Expr<'T -> bool> * … -> Task // plain lambda accepted + member CountWhereAsync<'T> : [] predicate : Expr<'T -> bool> * … -> Task + member QueryTryFirstAsync<'T> : … -> Task>>; member QueryFirstAsync<'T> : … -> Task<'T> + member QueryTryExactlyOneAsync<'T> : … -> Task<'T voption>; member QueryExactlyOneAsync<'T> : … -> Task<'T> + member QueryPageAsync<'T> : CosmosQuery<'T> * pageSize : int * [] continuationToken : string * … -> Task; ContinuationToken : string voption |}> + member QueryPagedAsync<'T> : CosmosQuery<'T> * pageNumber : int * pageSize : int * … -> Task> // OFFSET LIMIT plus COUNT, two queries + member QueryKeysetPageAsync<'T, 'Id, 'Key> : CosmosQuery<'T> * PageRequest<'Id, 'Key> * … -> Task> +module CancellableTaskSeq = val ofQuery : CancellationToken -> Translator -> Container -> CosmosQuery<'T> -> IAsyncEnumerable<'T> +[] module PatchQuotationExtensions = + type PatchBuilder<'T> with [] member FilterPredicate : PatchOperation<'T> * Expr<'T -> bool> -> PatchOperation<'T> + +// ---------- usage ---------- +let translator = + { TranslatorOptions.Default with Layout = JsonLayout.fSharpSystemTextJson jsonOptions } + |> TranslatorOptions.withPlugin NetTopologySuitePlugin.plugin + |> Translator.Create + +let adults minAge city = + cosmosQuery { + for p in items do + where (p.Age >= minAge && p.City = city && p.Nick.IsSome && p.Tags |> List.contains "vip") + sortBy p.Name + take 20 + select {| Name = p.Name; Nick = p.Nick |> Option.defaultValue "-" |} + } +// SELECT TOP @take VALUE {"Name": p["name"], "Nick": ((IS_DEFINED(p["nick"]) AND NOT IS_NULL(p["nick"])) ? p["nick"] : @p1)} +// FROM p WHERE ((((p["age"] >= @minAge) AND (p["city"] = @city)) AND (IS_DEFINED(p["nick"]) AND NOT IS_NULL(p["nick"]))) +// AND ARRAY_CONTAINS(p["tags"], @p0)) ORDER BY p["name"] + +// third-party geometry library in ~20 lines; the core never references it +type FooGeometryProvider () = + interface IGeometryProvider with + member _.Id = "foo-geo"; member _.GeometryType = typeof; member _.Overrides = ImmutableArray.Empty + member _.ToShape value = + match value with + | :? Foo.Geo.Point as p -> ValueSome (GeoShape.Point { Longitude = p.Lon; Latitude = p.Lat }) + | _ -> ValueNone + member _.NativeOperations = + ImmutableArray.Create (struct (MemberKey.Method Foo.Geo.Ops.distanceMethod, { Operation = GeoOperation.Distance; Arguments = ReceiverFirst })) +// where (Cosmos.Geo.distance c.Location center < 500.0) ==> WHERE (ST_DISTANCE(c["location"], @center) < @p0) + +[] // attribute route, zero registration +let isBlue (color : string) : bool = Cosmos.Functions.clientOnly () +``` + +## 16. Roadmap + +**Phase 0 – .NET 10 upgrade, skeleton, minimal spec model, probes (≈2 weeks).** TFMs to `net10.0` with the CI and docs adjustments of §5; `Microsoft.Azure.Cosmos` 3.60.0 → 3.62.0 in the same change set; a non-blocking Windows-emulator workflow, planned as nightly and made weekly in the review of #45, that runs the probes and the feature-gated categories (§22.5); IcedTasks added to CPM and **gated by a Debug-build probe** on the pinned SDK (multi-await, `try/finally`, `use` and cancellation cases for `valueTask`, `cancellableTask`, `cancellableValueTask`, in Debug and Release, asserting results and not only absence of exceptions – the `taskSeq` regression dotnet/fsharp#20466 silently lost data in the dynamic path; until the probe passes library code keeps using `task`; result below); projects + CPM entries; both solution files; `testsGlob` narrowed; `tests/Cosmos.Tests.Infrastructure` extracted and consumed by the existing test project; `tests/Cosmos.Sql.Hedgehog` with the helper modules and `Gen` (§22.6), which the printer laws of the exit criterion need; the Hedgehog MSTest adapter `tests/Hedgehog.MSTest` and its suite `tests/Hedgehog.MSTest.Tests` (§22.6), through which those laws run; `Cosmos.Sql` AST, printer (both modes) and validator for the subset Phase 1 needs, catalog data file seeded (all names; `Unsupported` markers allowed); golden-baseline harness with rewrite switch; BenchmarkDotNet spike (literal evaluation, shape key, printer, `QueryDefinition`, round trip). **Probes that settle unverified facts:** which subquery re-nestings the service accepts (`FROM (SELECT VALUE …) AS x`, non-correlated subqueries, `ORDER BY`/`OFFSET LIMIT` inside subqueries) – the lowering table is written from the results; `INTDIV`/`INTMOD` sign and division-by-zero behaviour; whether the SDK needs `Newtonsoft.Json` on disk to serialise a `QueryDefinition` even when the client uses STJ (the SDK assembly still references it and `SqlQuerySpec` serialisation is Newtonsoft-based internally; the repo sets `AzureCosmosDisableNewtonsoftJsonCheck=true` and the tests get the assembly only transitively from the test host) – if it does, the Quotations package declares that dependency so consumers do not fail at runtime (it does, but the maintainer decided against the declaration, see the result below); `??` behaviour on explicit `null`; `JsonLayout.ofClientOptions` detection of `JsonFSharpConverter`/`JsonFSharpOptions`; `IS_DEFINED(x) AND x = @v` index-seek behaviour (index metrics); vNext emulator support for `ST_*`, GROUP BY, DISTINCT, scalar subqueries; `DebugRange` presence in Release builds. Decision records; docs skeleton. Exit: printer laws pass on generated scalar trees as adapter properties; the adapter suite passes, a failing property reports its recheck data and `[]` replays it; the adapter is opened as a pull request to `hedgehogqa/fsharp-hedgehog` (`src/Hedgehog.MSTest` with its tests, in upstream's style, maintainer decision 2026-09-28) – its review does not block Phase 1, which runs on the in-repository copy; benchmark numbers recorded. + +**Phase 0 result: the IcedTasks probe** *(verified)*. `tests/Cosmos.Tests/IcedTasksProbeTests.fs`, 30 tests over `valueTask`, `valueTaskUnit`, `cancellableTask` and `cancellableValueTask`, passed in Release and failed in Debug on IcedTasks 0.11.9. With SDK 10.0.401 a Debug build compiles no resumable state machine at all (0 in the library and 0 in the test assembly, against 23 and 175 in Release), so every CE runs its builder's dynamic path. There IcedTasks 0.11.9's `RunDynamic` for `cancellableTask`, `cancellableValueTask` and `coldTask` creates the state machine and its resumption record once per value, outside the function that starts it: starting the same value a second time resumes where the previous start last suspended and returns wrong data, and a start made while an earlier start is still suspended blocks the calling thread until that await completes. `valueTask` and `valueTaskUnit` start immediately and pass every probe in Debug and Release. The four failing probes were skipped under `#if DEBUG` until the fix: our TheAngryByrd/IcedTasks#67, released in IcedTasks 0.11.10-beta001, creates the state machine per start, and with that version (#40) all 30 probes pass in Debug and Release with none skipped *(verified)*; a stable release of this library waits for IcedTasks 0.11.10 stable, because a stable package should not depend on a prerelease (NU5104, §18 item 15) (§2, §12, §17, §18 item 13). The partition cost of a container on the Windows emulator, another Phase 0 probe, is recorded in §22.4. + +**Phase 0 result: the query semantics** *(verified)*. `tests/Cosmos.Tests/QuerySemanticsTests.fs` (#44, 22 test methods, 52 cases) asks the emulators how they evaluate the constructs the translator relies on and asserts their exact answers. The Windows emulator and the Linux vNext emulator of CI give the same answers, except that the vNext emulator's ignore-case flag equates three more non-ASCII letters with their lowercase forms, that it reports substatus 0 where the Windows emulator reports 4001, that, having no composite indexes yet, it sorts by two properties in directions that no composite index matches, which the Windows emulator refuses, and that it has no spatial functions: one in the projection fails with HTTP 500, "This query type isn't supported yet", and a spatial filter gets an answer without documents, which the SDK cannot read. The results settle the comparison table behind guard elimination – equality and `IN` across JSON types are `false`, ordering across types and over arrays and objects is `undefined`, and a missing value makes every comparison `undefined` (§20.3); `??` keeping `null` and the rules of integer and floating-point division (§9); a parameter as the case flag of the string functions, as the whole predicate and as an indexer, `STRINGEQUALS` over non-strings, and case folding (§20.6, §20.12); object equality without property order (§20.9); the re-nesting table, including the filter over a `GROUP BY` subquery that stands in for `HAVING` (§8, §20.10); the sources `IN` accepts, which corrects the `ObjectToArray` lowering (§21.3); `ORDER BY` over missing values and mixed types, by one property and by two over a composite index (§9a); and the spatial functions over GeoJSON parameters, which measure in metres on the WGS-84 ellipsoid (§8, §13 rung 9). Index usage and the Newtonsoft dependency are settled below. Three probes that need no emulator are settled as well: quotations carry source ranges in Release builds too (§12), `JsonLayout.ofClientOptions` can read the serializer options back from the client (§10), and FSharp.SystemTextJson writes private-case wrappers unwrapped (§22.6). + +**Phase 0 result: index usage** *(verified on the Windows emulator)*. A probe script ran the predicates the translator emits over 3000 documents of one logical partition – every tenth without the probed property, every tenth other one with `null` in it – and read the request charge, the retrieved document count of the query metrics and the index metrics. An index seek that returns one document costs 2.92 RU there. The facts stay out of the test suite: request charges are not stable enough to assert, and the vNext emulator of CI reports none and has no index yet but the DiskANN vector index. + +- Guards keep the seek: `IS_DEFINED(c.v) AND c.v = @v` costs the 2.92 RU of the bare comparison, the full `Typed` guard `(IS_DEFINED(c.v) AND NOT IS_NULL(c.v)) AND c.v = @v` 3.02 RU, and both read only the matching document through the index `/v/?` (§9, §20.3). +- Equality with an object-valued parameter is a seek as well: `c.o = @o` costs 2.92 RU through the index `/o/?`, `c.o.x = @x AND c.o.y = @y` 3.02 RU, so the advice `CQ1019` is withdrawn (§20.9, §20.15). +- A captured collection keeps the index: `ARRAY_CONTAINS(@values, c.v)` costs what `c.v IN (…)` with the same values costs, 3.05 RU for 2 values and 5.81 RU for 24 (§9, §20.7). `ARRAY_CONTAINS(c.tags, @t)` and `EXISTS(SELECT VALUE t FROM t IN c.tags WHERE t = @t)` are both seeks. +- Arrays of objects lose nothing to `EXISTS`: `EXISTS(SELECT VALUE t FROM t IN c.items WHERE t.name = @n)`, its `JOIN` form and `ARRAY_CONTAINS(c.items, {"name": @n}, true)` all cost 2.92 RU through the index `/items/[]/name/?`, and with a second condition on the same element `EXISTS` costs 3.03 RU and the partial `ARRAY_CONTAINS` 3.02 RU (§9, §20.7). +- A parameter as the ignore-case flag is planned from its value: `STRINGEQUALS(c.s, @s, @f)` and `STARTSWITH(c.s, @s, @f)` cost 2.92 RU with `false`, and `STRINGEQUALS` costs 4.40 RU with `true`, exactly what the literal flags cost (§20.6). +- Ignoring case costs a scan: `STRINGEQUALS(c.s, @s, true)` costs 4.40 RU and `STARTSWITH(c.s, @s, true)` 4.82 RU, the expanded index scan of the indexing overview, although the reference page of `STRINGEQUALS` says that the function performs an index seek; `CONTAINS` and `ENDSWITH`, full index scans, cost 4.12 and 4.11 RU (§20.6). +- `LOWER(c.s) = @s` and `UPPER(c.s) = @s` cost 4.41 RU and read only the matching document, like the case-insensitive `STRINGEQUALS`, although the indexing overview and the query troubleshooting guide call them a full scan that loads every item. `CQ1001` keeps following the documentation, which describes the service (§20.15). + +**Phase 0 result: the Newtonsoft dependency** *(verified)*. The SDK needs `Newtonsoft.Json` on disk whatever the serializer: a console program that references `Microsoft.Azure.Cosmos` 3.62.0 alone, with `AzureCosmosDisableNewtonsoftJsonCheck` set and so without `Newtonsoft.Json.dll` in its output, fails in the `CosmosClient` constructor – in the constructor of the internal `DocumentClient`, before any request – with `FileNotFoundException` for `Newtonsoft.Json, Version=10.0.0.0`, also when `UseSystemTextJsonSerializerWithOptions` is set. The SDK package declares no dependency on it; instead its targets, shipped under both `build` and `buildTransitive`, fail the build of every project that references the SDK, directly or through a package such as `FSharp.Azure.Cosmos` – checked with a packed build of it – unless the project references `Newtonsoft.Json` 10.0.2 or later, which a package that depends on it does as well, or sets `AzureCosmosDisableNewtonsoftJsonCheck`. A consumer therefore fails at run time only after opting out of that check. Because the check already tells every consumer what to reference, no package of this repository declares the dependency (maintainer decision 2026-10-10, §18 item 16): `FSharp.Azure.Cosmos` stays as it is, the Quotations packages follow it, and the consumer keeps choosing the `Newtonsoft.Json` version, as the SDK intends. + +**Phase 0 result: the adapter upstream** *(verified)*. The adapter of #43 is proposed to `hedgehogqa/fsharp-hedgehog` as `src/Hedgehog.MSTest` with `tests/Hedgehog.MSTest.Tests.FSharp` (hedgehogqa/fsharp-hedgehog#487, opened 2026-10-10 from the fork `xperiandri/fsharp-hedgehog`, branch `mstest-adapter`); its review does not block Phase 1. The proposal is the same adapter in upstream's terms; since the review of #43 the in-repository copy differs from it in style (§18 item 12), so a change of behaviour goes into both: + +- It targets `net8.0` and builds with upstream's SDK 8.0.416, so with F# 8, which has no scoped `#nowarn`: the suppression of FS0057 for `TestContext.Current` is the last directive of its file there. +- The files shared with `Hedgehog.NUnit` keep upstream's documentation and carry only the functional changes, each in a commit of its own: the non-generic `GenAttribute` base, the `Seed` setting, and the fixes of `NonZeroInt` and of the generic `AutoGenConfigArgs`. Upstream's `NonZeroInt(0, 5)` generates -1 and 0, and `NonZeroInt(-5, 0)` generates 0 and 1. +- Its tests run under VSTest and pass on MSTest 4.2.3, 4.3.2 and 4.5.1 (140 then; 142 since the cancellation fix below, run on 4.5.1 there and on 4.2.3 in this repository, where a test of a null array of `AutoGenConfigArgs` makes it 143). MSTest 4.1 has no `TestContext.Current`, so 4.2 is the adapter's lowest MSTest version; a C# test class with `[Property]`, a generator attribute and data rows passes as well. +- On MSTest 4.5.1 one test of the suite does not compile as #43 writes it: `Assert.ContainsSingle` gained overloads that make its call with an F# lambda ambiguous (FS0041). The upstream copy asserts the count of a filtered array instead, and #43 needs the same change before MSTest is raised that far. +- Its first review (Copilot, 2026-10-10) found two things, fixed in both copies. An invocation that MSTest could not run as a test – an outcome other than `Passed`, `Failed`, `Inconclusive` and `Timeout`, such as `Error`, or an exception of `InvokeAsync` – was shrunk like a counterexample and reported as `Failed`; it now stops the run, and the property reports that outcome and exception. Upstream's `Odd` and `Even` leave their range by one: `Odd(-1, 0)` gives 1 and `Even(1, 2)` gives 0; both now generate from the odd or even values inside the range and reject a range without one. +- The automatic review of #50, which is built on #43, found one more thing (2026-10-10), fixed in both copies. MSTest reports a test method that returns without observing its cancelled token as passed *(verified with a cooperative `[]` on MSTest 4.2.3)*, and such a case stopped the property only when a later case read the token: a property failed when its first case overran the timeout and passed when its last one did. The token is now checked after every invocation, whatever its outcome, so the property stops and fails wherever the cancellation reached it. +- **`DateOnly` and `TimeOnly`** (review of #43): Hedgehog 2.0.4 has no generator for them, and `Gen.auto` passes arbitrary integers to their constructors, which throw `ArgumentOutOfRangeException` *(verified)*, so a property with such a parameter fails in every adapter. hedgehogqa/fsharp-hedgehog#488 (opened 2026-10-10, branch `dateonly-timeonly`) adds `Gen.dateOnly`, `Gen.timeOnly`, their `Hedgehog.Linq` counterparts, default generators and the attributes `[]` and `[]` for `Hedgehog.Xunit` and `Hedgehog.NUnit`; #487 has the two attributes for MSTest, generating over the day number and the ticks themselves so that it does not depend on #488. Until a release of Hedgehog has the generators, the adapter of this repository registers the two default generators of #488 itself, in the configuration that every property starts from (`AutoGenConfig.propertyDefaults`, 2026-10-11): a parameter of one of the two types, or of a type that holds such a value, needs no attribute, and a configuration that a test names is merged over it as before. A test of the adapter's suite states that `Gen.auto` cannot generate the two types; it fails when a release of Hedgehog can, and the stand-in goes then. The upstream copy needs none: it is built with the Hedgehog of its own repository. + +**Phase 0 result: the spec model in two pull requests** (maintainer request in the review of #42, 2026-10-10). `EquatableArray<'T>` and the `ImmutableArray` patterns `Arr0` to `Arr3` and `ArrN` (§4) are split off into #48, which creates the project `FSharp.Azure.Cosmos.Sql` with `IsPackable=false`: a push to `main` publishes every packable project, and the two collection types alone are no package. #42 builds on it with the syntax tree, the printer, the function catalog and the validator, and makes the project packable in the commit that gives the package its metadata. + +**Phase 0 result: the cost model** *(measured, #56)*. `benchmarks/Cosmos.Quotations.Benchmarks` measures what a query costs on the client, and one request to the Windows emulator to compare it with (BenchmarkDotNet 0.15.8, .NET 10.0.12, FSharp.Core 10.0.100, on a machine under other load: three runs differed by a factor of up to three, so the times are orders of magnitude). Evaluating a quotation literal costs 24 µs and 22 KB for a predicate of one condition and 75 µs and 55 KB for one of four: the compiler stores the literal as pickled bytes, and `Expr.Deserialize40` unpickles the whole tree on every evaluation, resolving every type and member through reflection again; only the bodies of `[]` members are cached. Reported upstream as [dotnet/fsharp#20764](https://github.com/dotnet/fsharp/issues/20764), which asks FSharp.Core to unpickle a literal once. A prototype of the shape-key pass (§11), one walk that also takes out the slot values and evaluates closed subtrees by reflection, costs 0.9 µs, the plan lookup 0.1 µs and a `QueryDefinition` with three parameters 0.05 µs. A closed subexpression costs 0.2 to 0.4 µs by reflection and 98 to 705 µs through `LeafExpressionConverter.EvaluateQuotation`. Building, validating and printing the `SqlQuery` of the sample predicate costs about 1 µs. A point read takes 1.4 ms and a query that returns 10 of 100 items 2.1 ms. What follows: the plan cache takes the translation off the path of a call but not the literal, which the library receives already built, so an inline quotation adds between 1 and 5 percent to a request, and `CosmosQuery.compile`/`ofReflected` (§11), which evaluate their quotation once, are the form for hot paths; evaluating closed subtrees by reflection and never through `Expression.Compile` (§11) is confirmed by the numbers; the SQL model is no cost. The translation itself cannot be measured before the translator exists, so the precompilation tool of Phase 5 stays undecided. + +**Phase 0 result: the grammar oracle** *(verified, #57)*. The SDK keeps the sources that ANTLR 4.7.2 generated from `sql.g4` in its repository, so they are copied unchanged instead of generated again: `tests/Cosmos.Sql.Grammar.Generated` (C#) holds them and needs neither Java nor the ANTLR tool, and the empty `LEX_IDENTIFIER` alternative stays as the SDK has it. The runtime is `Antlr4.Runtime.Standard` 4.9.3 and has to stay below 4.10, against which these sources do not compile. The conversion of a parse tree into the syntax tree is F#, `SdkGrammar.parse` in `tests/Cosmos.Sql.Grammar`, and that is a project of its own that never references the SDK: the SDK assembly has internal types named `sqlParser` and `sqlLexer` as well, and in a project that references it F# resolves the names to those and does not compile. Every project that references `FSharp.Azure.Cosmos` therefore reaches the parser only through that one function. The SDK's own conversion decodes three string escapes; this one decodes all of the grammar. Two laws run on generated valid queries: the parser reads the bracket-style text as the very tree it was written from, and it reads the dot-style text into a tree that is written as the same text. What the laws and the unit tests found: the lexer takes a sign directly before a digit into the number, so `1 -2` is rejected and whatever writes query text puts spaces around a minus; and three distinctions of a tree are not in its text – a join on the right side of a join (read back left-deep), a double without a fraction (`2.0` is written `2` and read as an integer) and, under the dot style, a string step of an input path (read as an identifier step). The generator of valid trees builds the first two the way the parser reads them; whether `SqlQuery.validate` should report a join on the right side of a join is open. + +**Phase 1 – First vertical slice: predicates end to end (3–4 weeks).** Normalizer subset (`eraseStructReceivers`, witnesses, booleans, lets, pipes, composition, ReflectedDefinition, residual check), every rebuild keeping the tuple kind (§4); `eraseStructReceivers` belongs to Phase 1 because every later pass rebuilds nodes with `ExprShape.RebuildShapeCombination`, which throws `ArgumentException` on a `PropertyGet` whose receiver is `AddressOf` *(verified in fsi)*, so without it a struct anonymous record would surface as a raw exception instead of a positioned error; parameterizer with named slots, dedup, `PropertyChains` evaluation, `Query.constant/parameter`; registry core with conflict validation and `Explain`; `JsonLayout.fSharpSystemTextJson`, `systemTextJson`, `ofClientOptions`; built-in packs Operators, Strings, Options (option/voption/Skippable/Nullable truth table), Collections (contains/exists/forall/length), Enums, DateTime basics, TypeChecks, attribute route, `Cosmos.Functions` core markers; `Query.where`, `Query.source`/`CosmosQuery.items<'T>` (the functional API's source constructor) and the CE with `for/where/select`-identity plus the `count`/`exists`/`head`/`headOrDefault` terminals needed by the definition of done; `Translator` with `TryTranslate/Translate/Explain`; execution extensions and `CancellableTaskSeq.ofQuery`; `patch { filterPredicate <@ … @> }` (inline mode) and quotation overloads for `ExistsAsync`/`IsNotDeletedAsync`; golden baselines, normalizer laws, registry conflict tests, grammar conformance, emulator smoke tests, first two-way differential run (Unquote eval vs emulator) and TLP on the emulator; `Reference/Construct_Mapping.md` as a Phase 1 deliverable – one row per F# construct marked Supported (SQL form + golden test id) or Unsupported (reason + escape hatch), checked against the registry like the function coverage page, including the long tail: `Map`/`dict`/`Dictionary` indexers and `ContainsKey` (`c["m"]["k"]`, `IS_DEFINED`), `Set`, `Result`/`Choice`, `String.IsNullOrEmpty/IsNullOrWhiteSpace/Join/Split/Replace/Trim/Substring/IndexOf`, `Enum.HasFlag`, `compare`/`CompareTo`, `<||`/`<|||` (reference tuples by signature, eliminated by the normalizer, never materialised), tuple forms (struct first, each with its reference twin; the forced-reference forms of §4; compile errors that get a documentation row and no translator rule: `.Item1` on a struct tuple FS0039, `fst`/`snd`/`||>` on a struct tuple FS0001, member access on a struct anonymous record held in an F# record field FS0450 and on a captured local of a value type FS3155 (§20.11), calls with `out` parameters such as `Int32.TryParse` or `TryGetValue` FS0462 in every spelling *(verified in fsi)*), `LanguagePrimitives.*WithMeasure` (units of measure are not fully invisible in quotations), `sprintf` with non-`%P` formats, system properties `_ts`/`_etag`/`id`/`ttl`, comparing two optional members (`None = None` is true in F#, `undefined = null` is undefined in SQL), `DateTime.Now`/`Kind = Local|Unspecified` (warning `CQ1010 LocalDateTime`), case-insensitive ordering (refused explicitly), and `VarSet`/`PropertySet`/`Sequential`/`DefaultValue` in the residual check. **Definition of done (result-equivalence, not byte-equivalence – the alias policy and parameterisation change the text):** the raw SQL strings in the repo today (`countQuery`, `getExistsQuery`, `IsNotDeletedAsync`'s predicate `NOT IS_DEFINED(x) OR IS_NULL(x) OR x = false`, `src/Cosmos/Cosmos.fs:207-211` and `:364-386`) have quotation equivalents whose SQL parses under the SDK grammar and returns the same ids/counts on the existing integration data – the deleted flag is modelled as `Deleted : bool option` (or `Skippable`) so `Typed` semantics reproduce the guard, and `Explanations/Null_And_Undefined.md` documents that a non-optional `bool` field under `Typed` translates to a bare `NOT c["deleted"]` (use `Strict` to keep the old behaviour); `Getting_Started.md`'s `SELECT * FROM c WHERE c.age > 25` is expressed as `Query.where <@ fun c -> c.age > 25 @>`; `patch { filterPredicate }` works end to end on the emulator; 200 generated predicates agree between Unquote eval and the emulator under `Typed` semantics (one `[)>]` of the adapter over the shared dataset); docs page and CHANGELOG entry. + +**Phase 1 result: the normalizer** *(verified, #58)*. `src/Cosmos.Quotations/Normalizer.fs` brings a quotation into its normal form, and `TranslationError` with `TranslationErrorCode` and `SourceRange` is the error contract of §12 with the fields a first stage needs (`Code`, `Message`, `Fragment`, `Location`, `Hints`; `Path` and `Trace` come with the translator, and `Fragment` is how F# prints a quotation until the F#-syntax printer exists). The assembly `FSharp.Azure.Cosmos.Quotations` is not packed until the stages produce a query. Where the implementation departs from §7: + +- *One walk instead of rounds.* A node is rewritten until no rule applies to it, its parts first, and what a rewrite gives is normalized again. With rounds, a reflected definition that calls itself twice would double the quotation every round before the limit of 32 is reached. A definition that calls itself is found at its first inner call instead, by the metadata token of the method, so through every instantiation and through mutual recursion. Two limits remain, both reported as `NormalizationDiverged`: 10 000 rewriting steps, and the stack of the thread, asked through `RuntimeHelpers.TryEnsureSufficientExecutionStack`. Both are reachable without any recursive function: a union that holds a function over itself, `type Loop = Loop of (Loop -> bool)`, can apply itself, and its quotation has no normal form. +- *Substitution.* A quotation that is spliced twice and a definition that is inlined twice arrive as the same nodes, variables included, so one variable can be bound at two places, one inside the other. `Expr.Substitute` replaces a variable inside an inner binding of the same variable too, which is wrong. It is used when no binding in the body is the variable or a free variable of the value; a substitution of the assembly that leaves a hidden variable alone and renames a capturing binding does the rest. The parameter of a delegate is such a binding too: a quotation keeps every variable it binds in a lambda under the node, also for `NewDelegate`, `Let`, a loop and `TryWith`, and `ExprShape` gives that lambda as a part *(verified in fsi)*. +- *Struct receivers* are erased at their node before the parts of the node are looked at, not in a pass of their own, and only for `PropertyGet`: no other receiver takes an address *(verified in fsi: fields, properties and methods of `[]` types, struct records and struct tuples carry none)*. The compiler refuses `[]` on an instance member of a struct (FS1220), so no definition has a struct receiver. +- *What stays although it has a reflected definition:* a module-level value, which F# computes once – its definition is the value itself, while the definition of a static property is a function of the unit value, and that tells them apart; and a member that a derived class can override, whose definition in the base class says nothing about the override. +- *A member of a base class* is defined for the base class, so a receiver of a derived class is passed as `Coerce (receiver, base)`: `Expr.Application` wants the exact type. +- *Closed applications.* An application of a compiled function is `CapturedFunctionValue` only when it has a free variable, which is how it reaches the document; without one it is left for the stage that computes values outside the query (§11). The function itself is a compiled one when it reads no variable, however the quotation gets to it: by its name, from a property or a field of a captured value (`rules.IsListed p`), or as what a call returns, where it is the definition of the called function that cannot be read whatever the call is given. Any other function that reads a variable, as the result of a choice does, is chosen for every document and is `Unsupported` without the advice about one compiled function (review of #58: only a function with a name, a module-level value and a call were taken for compiled ones, so a function in a property of a captured value lost the advice). So is a bound value that is used twice, and an argument that a function written in place uses twice: it is written at every place as the very same node, by which that stage can tell that the places are one value and computes it once, as the code does. +- *A bound value stays one node* (review of #60). `Expr.Substitute` builds every node of a body anew. So with two `let`s, writing the second value into the body made two nodes of the first, and the next stage would have computed it once for each. The values that are written at more than one place are remembered for one normalization. A body that holds such a node is substituted by the substitution of the assembly, which returns a node without the variable as it is and rewrites a node with the variable once for all its places; and such a node is normalized once for all its places, because a later rewrite inside it would otherwise give every place a result of its own. A quotation without such a value pays nothing for the substitution, one with two `let`s about a seventh more. +- *A node that comes at several places stays one node* (review of #61). A quotation that is spliced into two places is the same nodes at both, and code can build a tree with one node twice. Such a node was one node in the normal form only as long as nothing was rewritten in it or written around it *(verified: a spliced quotation with a binding in it came out as two nodes, and so did one in normal form under a `let` that was written into the quotation)*, while the next stages take one node for one value. The quotation is walked once before it is normalized, and every node that stands at more than one place of it is remembered as a bound value is; a variable is no such node. The walk stops where the stack runs out, and the normalization that follows reports the quotation as nested too deeply. The walk costs about a fifth more for a condition that is in normal form already: one of four comparisons took 7.4 µs without it and 8.9 µs with it *(measured in a Release build)*. Whether one node at two places should be one value at all, where compiled code would run it at each place, is a question that the review left with the maintainer. +- *Code that never runs* (review of #59). A choice has its condition normalized first. When the condition is a constant then, the branch it does not choose is not read at all, so `if true then p.Active else …` has a normal form also when the other branch calls a function that calls itself; `false && …` and a condition that becomes a constant only by normalization are covered. So is the default value of a boolean (review of #60): the compiler writes `bool ()` as `DefaultValue` and not as a constant, while `Unchecked.defaultof` is a call *(verified in fsi)*. Left as a condition, it kept both branches in the normal form, and the parameterizer computed the one that never runs. A branch that is dead only through a binding before it, such as `match ByName "x" with …` written in place, is still normalized: the body of a binding is normalized before the bound value is written into it, so the value is normalized once and not at every place it is written to. Normalizing such a branch must not fail, then (sixth review of #58): a function that calls itself was an error the moment its call was met, so `let onlyActive = true in if onlyActive then p.Active else factorial p.Age > 0` was one, and so was the same branch behind a parameter of a function written in place, of a pipe or of a reflected definition *(verified)*. Now such a call stays a call: its definition is not written in its place, nor in the place of the call that led to it, and what is left when the whole quotation is normalized is judged like every other construct. A call that reads the document is `NormalizationDiverged`, shown as the quotation has it; a call that reads none, `p.Age > factorial 5`, is a value for the evaluator, as a call of any compiled function is; a call in code that never runs is gone by then. A loop in such a branch never was an error, because a construct that a query cannot express is looked for only in what is left. What still fails inside code that never runs is a quotation whose simplification does not end. +- *Which of several errors is reported* (review of #58). The parts of a node are rewritten before the node is looked at, so a failure inside a construct that a query cannot express came first: a loop whose condition calls a function that calls itself was reported as that function. Now the outermost construct that a query cannot express whatever is inside it (a recursive function written in place, an assignment, a sequence of statements, a loop, exception handling) is the error also when the rewriting fails inside it, as it is when nothing fails. A quotation that is nested too deeply stays the error whatever stands around it (review of #58): the error of a construct shows the construct as text, and printing something that deep needs the stack that has run out *(verified: a sequence of statements around a negation a hundred thousand levels deep ended the process with a stack overflow)*. +- *An inner binding hides an assignment* (review of #58). The question whether a mutable variable is assigned to did not stop at a binding of the same variable inside its scope, which a quotation built through the API can have; the outer binding was kept and reported although nothing assigns to it. The inner binding is reported now. +- *The nodes of a call* (review of #58). A value that a function with a reflected definition binds and reads twice is one node at both places, and the normal form of a definition was kept for the next call of the function, so two calls shared that node: a later stage would compute once what each call computes for itself. The definition is one tree for the process, so every call after the first normalizes a copy of it; `Expr.Substitute` builds every node anew also when it replaces nothing *(verified)*. +- *Inline members* (review of #58). The compiler keeps the definition of an inline function of a module for the form of the function that takes the witnesses, `twice$W`, and the definition of an inline member of a type, static or not, for the member itself *(verified by reflection over a compiled assembly)*. Both take the witnesses first, then the receiver, then the arguments. A call of such a member raised `ArgumentException` out of the normalizer: after the witnesses were dropped, the call found that definition and gave it the receiver in the place of the witness. A call with witnesses takes the definition from either method now, and a definition is applied only to arguments of the types it takes, so a call that is built through the API without witnesses stays a call. +- *Not in this step,* because they need the registry of §8: `etaExpand` for function-typed arguments of registered members, `normalizeDelegates`, `desugarLiterals`, and never inlining a registered member. Until then `sprintf` is reported as a compiled function that is applied to the document. + +The cost, measured in a Release build on the machine of #56 and so an order of magnitude: 5 µs for a predicate of four comparisons that is already in normal form, 15 to 25 µs for one with a few pipes, bindings or inlined functions. FSharp.Core takes most of it. `Expr.TryGetReflectedDefinition` unpickles a definition on every call, 16 µs, and searches the resources of the assembly for a method that has none, 1 µs; the normalizer keeps both answers for the process. Building one access to an instance member through `Expr.PropertyGet` or `ExprShape.RebuildShapeCombination` costs 4 µs, because `checkObj` asks `FSharpType.IsUnion` every time, against 0.2 µs for `Expr.Substitute`, which builds nodes without validating them – the other reason to use it where it is safe. What followed for §11 was a proposal for the parameterizer step: compute the shape key from the quotation as it is written and normalize only on a cache miss, since normalizing on every call would cost several times the shape key. That step weighed the proposal and keeps the key of the normal form; its result below says why. + +The tests (§13 rung 8) are 87 pairs of one way to write a function and the plain way to write the same function, each checked for one normal form, for a normal form that normalization returns as the same node, and for agreement with the compiled code on sample values – Unquote evaluates the normal form, and the compiled code comes with the quotation from one lambda through a parameter marked `[]` – plus tests of what must stay, what must be gone, that a bound value stays one node in a call and not across calls, what every error says and which of several errors is reported: 317 in all. 59 one-line defects planted in the normalizer each fail at least one test, and the 9 tests of the code that never runs fail without that rule. One open check of §16 is settled on the way: MSTest 4.2.3 unpacks a `TestDataRow` into the two parameters of a plain `[]`; rows of more items stay unchecked. + +**Phase 1 result: the evaluator** *(verified, #59)*. `src/Cosmos.Quotations/Evaluator.fs` computes the value of a part of a normalized quotation that reads no variable (§11): `Evaluator.tryEvaluate : Expr -> Result`. It works by reflection and compiles nothing; a part that holds a function written in the quotation has no value. Which parts of a query are computed is the rule of the parameterizer, below. What the step found: + +- *A choice computes one branch*, as compiled code does, so `nick.IsSome && nick.Value.Length > 0` over a captured `None` is `false` and raises nothing. The prototype of #56 computed both operands. +- *37 operators of FSharp.Core cannot be called by reflection.* `-`, `/`, `%`, the unary minus and plus, the bitwise operators, the shifts and the conversions between number types are marked `NoDynamicInvocation` (25 in `Operators`, 12 in `Operators.Checked`): their methods raise `NotSupportedException`, "Dynamic invocation of op_Subtraction is not supported", while `+`, `*`, the comparisons and the mathematical functions can be called. `LanguagePrimitives` has a function for every one of them that works for operands of any types (`SubtractionDynamic`, `ExplicitDynamic`, …), and the compiler passes that function to the operator as its witness; the evaluator reads the pairs from the witnesses of template quotations and calls the function with the types of the operands and of the result. A test asks FSharp.Core for its marked operators and fails when one has no such function. +- *The checked operators are paired by hand.* The compiler passes `Checked.byte` and its siblings the unchecked `ExplicitDynamic`, and `Checked.(~-)` the unchecked `UnaryNegationDynamic`, so through their witnesses a checked conversion would not raise. The evaluator pairs them with `CheckedExplicitDynamic` and `CheckedUnaryNegationDynamic`. +- *Enumeration values.* The dynamic functions fail for an enumeration type ("involving coercions is not supported"), also behind `int day`. They are made for the number type under the enumeration, reflection passes an enumeration value as that number, and a result of an enumeration type is made a value of it again with `Enum.ToObject`: a parameter is written by the type its value has. +- *The per-value functions of `FSharp.Reflection` are slow*: `FSharpValue.MakeRecord` 8 µs, `MakeTuple` 3 µs (9 µs for a struct tuple), `GetTupleField` 3 µs. Their precomputed forms (`PreComputeRecordConstructor`, `PreComputeTupleConstructor`, `PreComputeTupleReader`, `PreComputeUnionConstructor`, `PreComputeUnionTagReader`), asked for once per type and kept for the process, cost 0.3 µs. `PreComputeTupleReader` reads struct tuples and tuples of more than seven items; the function §11 warns about is `PreComputeTuplePropertyInfo`. +- *Errors.* An exception of the code that is run is `ClientEvaluationFailed` with the exception in the new field `Cause` of `TranslationError`, taken out of the `TargetInvocationException` of reflection; a receiver that is null raises `NullReferenceException`, as compiled code does, and so does a function that is null when it is applied (review of #59). The error carries no slot name; the parameterizer below says why. +- *A coercion is a cast* (review of #59). The compiler writes `Coerce` only for `:>` and for an argument that is passed as a base type, where the cast cannot fail. A quotation that is built through the API can hold one that fails, and its value would be of another type than the part it is the value of. The evaluator returns a value of the target type, raises `InvalidCastException` for any other and `NullReferenceException` for null as a value type that is not a nullable. It converts nothing: a boxed `int` is no `byte`, in compiled code either. +- *A node is computed once* (review of #59). The evaluator keeps the values of the nodes it has computed, by their identity: `Evaluator.tryEvaluate` for one part, `Evaluator.tryEvaluateOnce` for a caller that computes several parts of one quotation and keeps them. With the normalizer keeping a bound value one node, `let made = obj () in (made, made)` is a pair of one object, as in compiled code. It costs a part that runs code about 0.1 µs. One node at two places is one value whatever put it there (reviews of #59 and #61): one quotation that is spliced into two places is computed once as well, where compiled code would run it at each place, and it is one slot of the parameterizer. This differs only for code that gives another value each time it runs, and a part that reads nothing of the document is computed once for the query in any case, not once for every document. Giving every place nodes of its own would take a pass over every quotation before anything is bound, about a microsecond for a small query; it is not done, and it is the change to make if the maintainer wants two values there. The nodes of a reflected definition are not nodes of the caller's quotation, which is why every call gets its own. +- *A receiver that is null* (review of #59) is reported after the arguments of the call are computed, as compiled code does *(verified)*: `nobody.Substring (1 / zero)` raises the exception of the division, also for an indexed property and for a function that is null. +- *Members of a nullable* (review of #59). Reflection has nothing to call a member of `Nullable` on: a nullable is boxed as its value or, when it has none, as null, never as itself. The boxed value is accepted as the receiver *(verified)*, so only the nullable without a value failed: its `HasValue` raised instead of giving `false`. Every member of a nullable – `HasValue`, `Value`, both `GetValueOrDefault`, `Equals`, `GetHashCode`, `ToString` – is computed by a function that takes the nullable as an argument, where reflection takes null for the nullable without a value. +- *The default value of a struct* (review of #59) is its zeroed value, for which no constructor runs. A struct of another language can declare a constructor without parameters, and `Activator.CreateInstance` runs it *(verified: 7 for a struct whose constructor sets a field to 7, where the default value is 0)*. The value comes from `RuntimeHelpers.GetUninitializedObject`; a nullable without a value stays null as an object, because that function gives a boxed value of the type under it for a nullable *(verified)*. `Unchecked.defaultof` does not come this way: its quotation is a call, which reflection runs. The node is what the compiler writes for a struct that is made without arguments, `DateTime ()`. + +The cost in a Release build on the machine of #56, an order of magnitude: a captured value 0.1 µs, a field of a captured record 0.3 µs, `limit + 1` 0.5 µs, `DateTime.UtcNow.AddDays -30.0` 0.4 µs. The tests are 129 pieces of code that read no variable, each compared with what its compiled code gives, 11 pieces of code that raise, a test of coercions built through the API, a test of the default value of a struct that is made at run time with a constructor without parameters, two tests that count how often the code of a bound value runs, and the test of the marked operators; 26 planted defects each fail at least one test. + +**Phase 1 result: the parameterizer** *(verified, #60)*. `src/Cosmos.Quotations/Parameterizer.fs` tells the values of a normalized quotation from its shape (§11). `Parameterizer.shapeOf : Expr -> Result` gives the `ShapeKey` and the values of the slots, which is all that a query needs whose translation is known. `Parameterizer.templateOf` gives the same and, for the translator, the quotation with a variable at the place of every slot. Both come from one walk, so the key is the same either way; the walk builds nodes only for the template. `Query.constant` and `Query.parameter` are the first public functions of the assembly. Where the implementation departs from §11: + +- *A literal is a part of the key, not a slot.* §11 made source-written literals slots by default (`ParameterizeLiterals`), and §21.1 writes a value declared `[]` into the text. The compiler writes such a value into a quotation as the same `Value` node as `18` *(verified in fsi)*, so one rule has to hold for both, and it is the rule of EF Core: a constant is text, a captured value is a parameter. The translator needs that anyway: it has to see `null` in `p.Name = null` and the `StringComparison` that was written (§20.6). A literal is a `Value` the compiler could have written: a boolean, a character, a string, a built-in integer or floating-point number, a value of an enumeration, unit, null. `2m` is a call of `MakeDecimal`, also when it is declared `[]` *(verified in fsi)*, so a computed slot, which is what §9 wants for decimals; a native integer literal is refused in a quotation (FS0459). A `Value` of any other type was put there by code that builds quotations and is taken for data, a slot without a name. So is the null of a union type (review of #60): the compiler writes the case of a union that is represented by null as a case, `NewUnionCase`, for `None` and for a case of a union marked `UseNullAsTrueValue` alike *(verified)*, so such a null is no null literal of a query, and how it is written is a matter of the union. Such code has to pass data of a literal type as `Expr.ValueWithName` or under `Query.parameter`, or every value gets a translation of its own – a note for the how-to of §20.10. +- *What the quotation builds is not computed.* §11 computed `New*` nodes that read no variable. But §20.3 takes a source-written `Some e` apart, §9 writes a source-written list as `IN (…)`, and §11 itself wants a tuple slot by slot; none of that is possible once `Some name` is one slot that holds an option. So `NewUnionCase`, `NewTuple`, `NewRecord`, `NewArray` and `Coerce` stay nodes of the key with their parts treated on their own. What is computed is code: calls, properties, fields and constructors of classes, the largest such part at its place. A translator that wants a built value as one parameter, written by the serializer of the caller, can build it from the values of the slots when it binds. +- *A slot is not a parameter.* It is a value with a type and, for a captured variable, a name. The two parameters of a captured optional value (§20.3), the components of a captured tuple and the flag of a captured `StringComparison` (§20.6) are made of one slot each by the binder of the translator, so a cache hit does no more than this walk. +- *A branch that is not taken is not computed.* New against §11. For `filter.IsNone || p.Name = filter.Value.Trim ()` compiled code never reads `filter.Value` without a filter, and computing every closed part beforehand raises there. When the condition of a choice is a slot with a value, the parts of the branch it does not take are slots without a value (`Values : ImmutableArray`); a part that is also used outside that branch is computed. The key holds both branches and does not depend on the condition, so the rule of §20.16, SQL text never depends on run-time values, holds. The translator binds a slot without a value as null: the query does not take that branch either. `Query.constant` is computed in any branch, because its value is in the key; as a condition it decides its branch as a slot does (review of #60). A choice that reads no variable at all is one computed slot, of which the evaluator computes one branch (#59). +- *One node, one slot* (review of #60). §11 merged identical captured values, and the first version of this step did, by name, type and value. But the compiler writes every use of a captured variable as a `ValueWithName` node of its own, which holds the value, its type and the name and nothing else *(verified in fsi)*. Two uses of one variable cannot be told from two variables of one name that two spliced quotations captured, and telling them by their values made the key depend on the values: `(%olderThan a) p && (%olderThan b) p` had one slot when `a` and `b` happened to be equal and two when not, against the rule of §20.16 that SQL text never depends on run-time values. So a slot stands for one node: a captured variable that is used twice is two slots with one name, and the key is the same whatever the values are. Uses are one slot where the quotation says that they are one value, `let id = userId in …`, because the normalizer writes a bound value at every place as the same node (#58), which the walk recognises. +- *A bound value is computed once* (review of #60), wherever it is read. In `let limit = read () in p.Age > limit + 1 || p.Age < limit + 2` each sum is the largest part at its place that can be computed, so each is a slot of its own, and each ran `read`. The walk keeps what it has computed, node by node, and the evaluator takes the value of a node from there (`Evaluator.tryEvaluateOnce`), so `read` runs once, as in compiled code. Nothing is promised for one quotation that is spliced into two places: a part that reads nothing of the document is computed once for the query and not once for every document, so how often its code runs was never what F# does, and the normal form assumes code without side effects. +- *The key.* A list of tokens, every node before the nodes under it. A token is the kind of the node and the member, type or union case that reflection gives, compared the way reflection compares them, which tells `List.contains` from `List.contains` and a struct tuple from a reference tuple. A bound variable is the distance to its binding, also under `NewDelegate`; a slot is its index, type and name; a literal is its type and value, a floating-point one compared bit by bit, because `0.0` and `-0.0` are equal numbers and different text. The names of lambda variables are not in the key (§7). The names of captured variables are: the parameter of the query is named after them, and a text that depended on which of two queries came first would make baselines unstable. The hash code is computed while the tokens are written. `ShapeKey` is a type of this assembly; `EquatableArray` of #48 is not referenced yet. +- *The key is of the normal form.* The proposal of the normalizer step, a key of the quotation as it is written with normalization only on a miss, is not taken. It would spare 5 to 25 µs per run. It would need a second set of slots over the quotation as written, a mapping from them to the slots of the template, and an evaluator that computes a part with other captured values than it was normalized with, because a computed part of the normal form does not exist before normalization. Evaluating the quotation literal costs 24 to 75 µs on every run in any case (#56), and a query on a hot path is a compiled query, which is normalized once. +- *Function values.* A slot whose type is a function or a delegate is `CapturedFunctionValue`, before anything is computed for it; where the type cannot say what the value is, for a part typed `obj` or as an interface, the value is looked at (review of #60). `Query.constant` refuses a function the same way. The normalizer reports the compiled function that is applied to the document; this is the one that is passed, as in `p.Tags |> List.exists isVip`. The null and the default value of a function type, which only code can build into a quotation, are refused the same way (review of #60): a null is the literal of every class, so it was written into the key before its type was asked. +- *A function by its type* (review of #60) is also a class made of the type of F# functions, which the class of every compiled function is and which code can declare; a value of such a class was a slot before. +- *`Query.constant` takes only what a literal can be* (review of #60): a boolean, a character, a string, a built-in integer or a finite floating-point number, a value of an enumeration or null. The text of a query has no `NaN` and no infinity (`NUMERIC_LITERAL` of the grammar is digits, a fraction and an exponent), so such a number is a slot when code builds it into a quotation, and `Query.constant` of one is `Unsupported`. The key holds the constant, and equal keys have to mean one text. An object is equal to itself after it has changed, an array is equal to nothing but itself, and two objects can be equal and be written differently, so the equality of any other value promises nothing about the text. Such an argument is `Unsupported`, with the hint to drop `Query.constant`, which makes the value a parameter. The type that a value is declared with decides whether it is a literal (review of #60): a nullable with a value is boxed as that value, and a nullable or an option without one is a null, but the compiler writes none of them as a value, so they are data in a quotation that code builds and no constants for `Query.constant`. §11 hashed any inlined value into the key; a text of the value as the query writes it is not at hand at this stage, which has no serializer. +- *Errors.* `ClientEvaluationFailed` carries the part that failed and its cause, not a slot name as §12 says: the part failed before it became a slot, and a slot has an index and no name until the translator names the parameters. A captured value carries no `DebugRange` of its own, and a node that the normalizer built has the range of a part under it, so an error about such a part gets the place of the nearest node around it that has one. +- *Not in this step:* the `EvaluationPolicy` of §11, which is an option of the translator – its stricter values leave closed parts for the translator to express, and there is no translator yet; the list of markers that are never computed on the client (§8), which comes with `Cosmos.Functions`; the plan cache (Phase 2). + +The evaluator got the check this step needs, `Evaluator.canEvaluate`: whether a part is made only of what the evaluator computes, told without running any of it. The walk asks about a part and then about the parts under it, and to refuse a part the evaluator walks down to the variable that is the reason, so the parts that were refused are remembered for one walk; that took the key of four comparisons from about 4.6 µs to about 3.5. `Evaluator.tryEvaluateOnce` computes a part with what the same walk computed before. + +The cost in a Release build on the machine of #56, an order of magnitude: the key and the values of one comparison with a captured value 1 µs, of four comparisons with three slots 3.5 µs, of `p.Created > DateTime.UtcNow.AddDays -30.0 && p.Age > limit + 1 && p.Tenant = settings.Tenant` 4 µs. The template costs about twice that, which is why it is built only on a miss. The tests (§13 rung 8) are 47 functions of a person written over two sets of captured values and checked for five laws – one key and one hash code whatever the values are, different keys for different functions, the same key and values with and without the template, a variable for every slot, and a template that with its values computes what the compiled code computes – plus tests of the slots, the branches, the two functions of `Query`, the tokens of a key and every error: 414, and 2 for the check of the evaluator. 66 one-line defects are planted, and each fails at least one test. + +**Phase 1 result: the layout** *(verified, #65)*. `src/Cosmos.Quotations/JsonLayout.fs` has `IJsonLayout` with seven members, `MemberName`, `EnumEncoding`, `TupleEncoding`, `CollectionEncoding`, `MapEncoding`, `UnionCaseEncoding` and `Polymorphism`, and one implementation, `JsonLayout.ofSerializerOptions`. `FSharp.SystemTextJson` 1.4.36 is a dependency of the assembly now, as §2 decided. On 2026-10-12 the maintainer chose to build the layout narrow first: it supports the set-ups that it has been compared with the serializer for and refuses the others. The layout of every setting of the library that §10 describes was built and reviewed too (#61, #62, #63); it is shelved, below, and is the way on from here. Where the implementation departs from §10: + +- *The set-ups that are supported, and the rest refused.* `ofSerializerOptions : JsonSerializerOptions -> Result>` gives the layout or one sentence for each setting that it does not support. Supported: one `JsonFSharpConverter`, of that class itself, with `JsonFSharpTypes.All` among the converters; unions with an adjacent or an internal tag, `UnwrapOption`, no `UnwrapSingleFieldCases` and no `UnionFieldNamesFromTypes`, and an adjacent tag under another name than the fields; options that handle no references, write no numbers as texts and ignore no read-only members. Refused: the rest of those settings, no converter of the library, more than one, and a class that an application derived from it. A set-up that is not listed is one that nothing has compared with the serializer. Every other setting is supported as far as the tests show it: 22 ways to set the serializer up are written and compared, among them the defaults of the library, one change at a time, and the production profile of §21.3 *(verified)*. +- *The production profile lacks one converter.* The application writes its records with `AllowNullFSharpRecordConverterFactory`, which stands before the converter of FSharp.SystemTextJson in its options, names a member by the naming policy alone and leaves out `null` and `Skip`. The layout cannot know what it writes, so a record of the application has no member names, and the tests use the profile without that factory. How such records get described is open: a small interface that a converter of an application implements to say how it names members, or the options of the library (`AllowNullFields`, `SkippableOptionFields`) in place of the converter. Left with the maintainer. +- *`NotDescribed` for what the layout cannot describe*, one case of each answer instead of a missing value or `Opaque`: a type that a converter of the application writes, a member that names a converter of its own, a type whose contract a resolver of the application made, a record or a union that has options of its own (in the overrides, or in an attribute where the common options allow one), a record that writes two of its members under one name, a union with a case that is `null`, with a tag that is no text, with a tag that another case has, with a name declared for a field or with the name of the tag written twice, a type with discriminators that are numbers. +- *Names that collide* (review of #65) *(verified)*. FSharp.SystemTextJson writes `{"title":"a","title":"b"}` for a record with the fields `Title` and `title` under camel case, `{"same":1,"same":2}` for two fields that declare one name, and the same for a property that asks to be written under the name of a field; two cases of a union that declare one tag, or that a naming policy gives one (`FooBar` and `Foo_Bar` under snake case), are written with one tag. A query cannot tell which of two it reads, so a record that writes two of its members under one name is not described, and neither is a union whose cases share a tag. The union writes the members of a record that it unwraps itself, also when a converter of the options takes the record, so next to an internal tag a record that the layout does not describe can hold the name of the tag: such a union is not described either, and the members that a described record writes are the ones that are looked at, not the ones that it never writes. Names are compared as a document has them, exactly, so two that differ by the case of a letter are two. +- *One function stands for `fSharpSystemTextJson` and `systemTextJson`.* The serializer is asked who writes each type, so the two cannot differ. Plain System.Text.Json without the converter of FSharp.SystemTextJson is refused, which is §2: the layout is for one family of serializers. +- *No `OptionalEncoding`.* A query guards a member that can be without a value with `NOT IS_DEFINED(x) OR IS_NULL(x)`, which is true however an option without a value was written, and §9 has that as the guard that is right whatever the documents hold. `NarrowOptionalGuards` (§20.12) needs the encoding and comes with the full layout. +- *No `Identity`*, which nothing asks yet. +- *Every question takes the member that holds the value*, a `DocumentMember`: the member and the type of its receiver. A quotation names an inherited property as the class that declares it has it, and a member can name a converter of its own, which writes its value and no other *(verified, shelved below)*. +- *The question about unions is about a case.* `UnionCaseEncoding` gives the mark of a case, the steps from the value of the union to its tag, and where each field is, as steps (`JsonStep`) to a value or to the object that holds the members of a record. §10 has one `UnionLayout` for a union. A union is described as a whole, because a document of one case can hold the tag of another. +- *`TupleEncoding` is `Array` or `NotDescribed`*, since plain System.Text.Json is refused. A tuple is an array only when a class of the assembly of FSharp.SystemTextJson writes it: the classes of its tuple converters are public and open to inheritance. +- *`CollectionEncoding` and `MapEncoding` name the types of the items*, without the `Empty` part: whether an application stores an empty collection as null is no fact of the serializer (`EmptyCollections` of §15 stays the place for it). +- *`Polymorphism`* reads `JsonPolymorphic` and `JsonDerivedType` from the contract of the type: the property of the discriminator and the discriminator of each derived type, none for a type that is written without one. §21.1 builds the filter of the translator on it. +- *Every question is answered once*, also when several threads ask at a time, and the layout asks a copy of the options, with the resolver of the caller shared with the serializer *(verified, shelved below)*. +- *Not in this step:* `ofClientOptions`, which needs the client and comes with the functions that run a query; `MemberDefaults`; `OverrideMembers` of FSharp.SystemTextJson, which the library keeps internal; legacy union encodings (phase 2). + +The tests take the serializer as the oracle and do not know the rules. Types of a model made for the layout are written with every way to set the serializer up, and the document is searched for each member, for the kind of JSON value of each enumeration, tuple, collection and map, for the mark of each case of each union and for the discriminator of each derived type. The ways to set the serializer up that are refused each have a test that names the reason. 1248 tests in all, 367 of them for the layout. 155 one-line defects are planted in the layout, and each fails at least one test; where one did not, a test was missing, and four lines that nothing could reach were taken out: the check of the class of the factory that the refusal at the start makes, the default name of the discriminator property, which the serializer always gives, the condition on the name of a field in the tag of a case, which the refusal of such a union comes before, and the inheritance of an attribute of a type, which a record or a union cannot have. + +**Shelved: the layout of every setting** *(verified, #61, #62, #63)*. The three results below were built and reviewed to the end for a layout that describes every setting of FSharp.SystemTextJson, including how an option without a value is written, the options of a type in the overrides and in an attribute, and the fields of a case as members. The maintainer decided on 2026-10-12 to build the narrow layout above first and to come back to this later, so #61, #62 and #63 are drafts and the branches stay, with a branch for the fields of a case after them (`feat/quotations-layout-union-fields`). What they verified holds: it is kept here as it was written, and the narrow layout relies on the same facts. + +**Shelved: the names and the optional values of the layout** *(verified, #61)*. `src/Cosmos.Quotations/JsonLayout.fs` has the first two members of `IJsonLayout` (§10), `MemberName` and `OptionalEncoding`, and one implementation, `JsonLayout.ofSerializerOptions`. Both members take a `DocumentMember`, the member with the type it is read from. `FSharp.SystemTextJson` 1.4.36 is a dependency of the assembly now, as §2 decided. Where the implementation departs from §10: + +- *The serializer is asked, its configuration is not read again.* `JsonSerializerOptions.GetTypeInfo` gives the contract the serializer will use for a type, and the contract names the converter that writes the type. So one function stands for `fSharpSystemTextJson` and `systemTextJson`: a type that FSharp.SystemTextJson writes as a record (`JsonRecordConverter`) follows the rules of that library, a type that System.Text.Json writes as an object follows its contract, and a type that anything else writes has no member names. What decides who writes a type counts as it does when a document is written: the order of the converters, `JsonFSharpTypes`, a converter that a type names in an attribute. A contract can be asked only of options that have a resolver and do not change any more, so the layout asks a copy and leaves the options of the caller as they are. +- *Plain System.Text.Json writes F# options*: `Some x` as `x` and `None` as `null` *(verified, .NET 10)*. §10 had `systemTextJson` refuse option members; they are described. +- *A value option is never "null" for an ignore condition* *(verified)*. `WhenWritingNull` leaves out `None` and writes `ValueNone` and `Skip` as `null`, because they are structs; only `WhenWritingDefault` leaves them out, and System.Text.Json refuses `JsonIgnore(Condition = WhenWritingNull)` on such a member. FSharp.SystemTextJson has the same split for the fields of a record: with `SkippableOptionFields.FromJsonSerializerOptions` and null ignored, `None` is left out and `ValueNone` is written; only `SkippableOptionFields.Always` leaves out both. +- *FSharp.SystemTextJson does not read the condition of `JsonIgnore` on a record field* *(verified)*: any `JsonIgnore` there means never written. The conditional forms of §21.2 make a member addressable only on a class. +- *An override has a name of its own* *(verified)*. When a base class declares `[] abstract Tenant` and the derived class overrides it without the attribute, the document has both `pk` and `Tenant`. The layout names each declaration as the serializer does. +- *A record that names its converter in an attribute*, `[]`, is written by a converter that the attribute makes with options of its own when no converter of the options takes records: no naming policy, and null is not ignored, whatever the options of the caller say *(verified)*. +- *A member is asked about with the type it is read from* (review of #61). §10 has `MemberName : MemberInfo -> string voption`. A quotation names an inherited property as the type that declares it has it *(verified: `fun (talk : Talk) -> talk.Room` holds `Meeting.Room`, reflected from `Meeting`)*, so the member does not tell which type's documents are meant, and the serializer can write a derived class by a converter and its base class as an object. The questions of the layout take a `DocumentMember = { Holder : Type; Member : MemberInfo }`: in a quotation the holder is the type of the receiver. In the contract of the holder the layout takes the entry of the member itself and, when there is none, the entry of the property that overrides it; the name and the `JsonIgnore` condition are read from the member that the entry was made of. For a property that the holder overrides, compiled code names the override and F# Interactive the declaration of the base class *(verified)*; both are described alike. +- *A null that is a value to the library* (review of #61). Where null is ignored, FSharp.SystemTextJson still writes a field that is a record, a union or a tuple and is null, and leaves out a null of any other type. It also leaves out a null of a type that it reads a value of from a null *(verified)*: a union marked `UseNullAsTrueValue`, and a union of one case that is written as the value it wraps where that value can be null – an option, a value option that is written as its value, the unit value, a skippable value around one of these, any type when `AllowNullFields` is set, or another such union. The layout mirrors the function of the library that finds that value (`tryGetNullValue`), with the encoding of unions that the options of the record inherit. +- *The extension data* (review of #61) has no name, and no absence that the layout describes: its entries are properties of the document itself, and nothing is written for it when it is null *(verified)*. The question about maps is answered the same way for it (#62). +- *A member of a record that is not written* (review of #61), because it is marked to be ignored or is a property that FSharp.SystemTextJson does not include, has the name it declares when it declares one, and without a value it is `NullOrUndefined`: whatever writes its property decides. One function says which members of a record the library writes. +- *A rule that code sets to null* (review of #61) cannot be told from no rule. With `IgnoreReadOnlyProperties` the serializer writes a read-only member whose `ShouldSerialize` a modifier of the contract set to `null` *(verified)*, and it keeps that in a field that is not public; the contract shows the same `null` as for a member that nothing touched. The layout takes such a member to be left out, which is right for every member that nothing touched. Reading the private field is the change to make if this case has to be told. +- *A read-only member can be left out without the contract saying so* (review of #61). With `IgnoreReadOnlyProperties` a property that nothing can assign is not written, and its entry in the contract still has a getter and no rule *(verified)*. The layout applies the rule of the serializer: read-only, no `JsonIgnore` of any condition on the member, and no collection or dictionary, which the serializer writes all the same; `IgnoreReadOnlyFields` is the same for fields. A record that System.Text.Json writes as an object is `{}` under that option, because every field of a record is read-only. F# declares a field that is not mutable as a property over a field that is not public, so a public read-only field comes only from a type of another language. A read-only member with a rule that code has set, `ShouldSerialize`, is not left out by the option: the serializer asks the rule instead *(verified, review of #61)*. A member is a collection by the converter that writes it, which can be one that the member names with `[]` *(verified, found while the next step was built)*: a read-only array whose converter writes it as one text is left out, and one whose converter factory hands it back to the converter of the options is written. The contract of such a member is asked from options that hold its converter before all others. A factory makes the converter with the options of the layout first: a factory that asks the options it is given for a converter is asked without end by options that hold the factory itself *(verified: a stack overflow)*. +- *Several converters of FSharp.SystemTextJson* (review of #61) can stand among the options, for different kinds of types. A record is written by the first one that takes it, as the serializer chooses, so its options are the ones that are read; the first converter of the list can be one for unions only. A `JsonRecordConverter` that is added to the options by itself shows nothing of the options it was made with, so its record is not described. The same holds for a record whose converter an attribute of the application makes (review of #61): when no converter of the options takes a record, its options are read only from the attribute of FSharp.SystemTextJson, `IJsonFSharpConverterAttribute`, which shows them. A record that names another converter in an attribute of its type is written by that converter only when no converter of the options takes it, because the serializer asks the options first *(verified)*; the layout follows the converter that the contract shows. Only the factory of FSharp.SystemTextJson itself, the exact type, shows the options of a record (review of #62): a class that an application derives from it can make the converter of a record with other options than its own *(verified)*, so its records are not described. +- *A record is described only when the serializer made its contract* (found by a planted defect that no test failed for any more). The rules above are the ones the serializer follows when it chooses a converter. A resolver of the application can make the contract of a record from any converter instead, also from a `JsonRecordConverter` with options of its own while the options hold the factory with other ones *(verified: the layout read the options of the factory)*. `JsonTypeInfo.OriginatingResolver` names the resolver that made a contract: the resolver of the serializer, also as a class that an application derives from it, and nothing for a contract that `JsonMetadataServices.CreateValueInfo` made from a converter *(verified)*. A record whose contract the serializer did not make is not described. For the same reason the contract of a member with a converter of its own is asked from options that have the resolver of the serializer: the serializer writes such a member with its converter whatever contract a resolver of the application makes for the type *(verified: a read-only array that its converter writes as one text was taken for a collection when a resolver made the contract of arrays, and an enumeration was described by the contract of the resolver and not by the converter of its member)*. +- *Every question is answered once* (review of #62). The layout keeps what it finds out. `ConcurrentDictionary.GetOrAdd` with a factory can run the factory for every thread that misses at a time, and finding an answer asks the serializer, which can run code of the application, so the dictionary holds a `Lazy` for each question: one thread works the answer out, and the others wait for it. The four questions about values (#62) keep their answers with the same function. +- *The resolver is shared with the serializer* (review of #61). The layout asks a copy of the options, and a change of the options after the layout is made does not reach it. The resolver of the contracts is not copied, because it cannot be: it can be any object. So the layout asks the resolver that the serializer asks. A change of it before the layout is asked for the first time reaches both, and from that question on the resolver of System.Text.Json refuses a change of its modifiers, as it does after the first document is written *(verified)*. A copy of the modifiers, which the review asked for, would let a modifier that is added later reach the serializer alone, and nothing would tell. The documentation said that no later change reaches the layout, which was wrong for the resolver; it is corrected, and a test pins the behaviour. Left with the maintainer. +- *The setting of old that ignores null* (review of #61). `JsonSerializerOptions.IgnoreNullValues` is obsolete, and System.Text.Json and FSharp.SystemTextJson still follow it *(verified)*: null is left out as with `DefaultIgnoreCondition = WhenWritingNull`, while the options show the condition `Never` and the contract no rule. The layout reads the setting where it reads the condition; the options refuse the two together. +- *What a member declares is its own*: an override has neither the `JsonPropertyName` nor the `JsonIgnore` condition of the declaration it overrides, for the serializer *(verified)* and for the layout (the condition: review of #61). +- *A property of a record that is not public* (review of #61) is not written by FSharp.SystemTextJson, whatever its options or a `JsonInclude` say *(verified)*: the library writes the fields of a record, whatever their accessibility, and of the other properties only public ones. System.Text.Json writes an internal property with `JsonInclude` for an object. +- *A member that names a converter* (review of #61). The serializer leaves a null of such a member out by the `JsonIgnore` condition of the member or by the condition of the options, and writes `null` itself otherwise, before it asks the converter; the converter gets the null only when it asks for it with `HandleNull` *(verified)*. So a reference or a nullable of a member with a converter is described as any other member, and is `NotDescribed` only when the converter asks for the null and is no converter of the serializers. An option, a value option or a skippable value of such a member stays `NotDescribed`: the converter writes all of it. +- *What is written for a null is asked of the serializer* (review of #61). The serializer writes a null that is not left out as `null`, unless the converter of the type asks for it with `HandleNull` *(verified)*. A converter of an application can write anything for it then, and three converters of FSharp.SystemTextJson ask for it and fail on it with `NullReferenceException` *(verified)*: the ones of tuples, of maps, and of unions without fields that are written as their tags. The first version read `HandleNull` and trusted the converters of the two libraries, so it said `null` for a value that no document can hold. Now the layout has the serializer write a null with the contract in question – for a value option or a skippable value, the one without a value – and describes `null` only; anything else, or an exception, is `NotDescribed`. Where the options or the member leave a null out, it is left out as always, because the null never gets to the converter. +- *A type without a contract* (review of #61). The serializer needs the contract of a type also to write a null of it, so it writes no document with a member of a type that it refuses, whatever the member holds *(verified)*. A value of such a type is `NotDescribed`, with a value and without one. +- *A member that is never written and declares a name has that name* (§21.2): `JsonPropertyName` next to `JsonIgnore`, on a record field also `JsonName`. A plain `JsonIgnore` gives no name, which the translator will report as `MemberNotSerialized`. +- *Not in this step:* `Identity`, unions and polymorphism, and the members for enumerations, tuples, collections and maps, which the next step adds; `ofClientOptions`, which needs the client and comes with the execution extensions; `MemberDefaults` for a member that is not optional and is left out all the same, because the options ignore default values and it holds one; an option that FSharp.SystemTextJson writes as a union (`UnwrapOption` off), which is `NotDescribed` until unions are; and `OverrideMembers` of FSharp.SystemTextJson, which that library keeps internal. + +The tests (§13 rung 13) take the serializer as the oracle. Nine types of a model made for the layout are written with every one of 25 ways to set the serializer up that can write them, 9 of System.Text.Json alone and 16 with FSharp.SystemTextJson, the production profile of §21.3 among them; each type once with every optional member present and once with every one absent. A member is found in the first document by a value that no other member holds; the layout has to give the name it is found under and, for the member without a value, what the second document shows. These are 198 rows. A null of 18 types of F# is asked about by itself, in a field of a record and in a property of a class with 9 ways to set the library up, 162 rows, and the layout has to say what the serializer does with it; one more test checks that it writes some, leaves some out and fails on others. 33 tests state what a document cannot show: 394. 105 one-line defects are planted, and each fails at least one test. The whole list is run again after every change of the layout: the second review made two of the defects pass unnoticed, one in a line that could not be reached any more, which is gone, and one that a record with a converter of its own tells now. The eighth review made one pass unnoticed again, any record taken for one that FSharp.SystemTextJson writes: the check of the exact factory had taken over the case its test was about, and what was left to tell it was the gap of the resolver above. The values are written by hand; the Slim and Fat variants of §22.6 come with the generators. + +**Shelved: the values of the layout** *(verified, #62)*. `IJsonLayout` has four more members, `EnumEncoding`, `TupleEncoding`, `CollectionEncoding` and `MapEncoding`, and `JsonLayout.ofSerializerOptions` answers them from the contract of the serializer: the contract says whether a type is written item by item, as a dictionary or as an object, and names the converter, by which the converters of FSharp.SystemTextJson for tuples, lists, sets and maps are told. Where the implementation departs from §10: +- *Every question takes the member*, also the ones about an enumeration and a map, as a `DocumentMember`. A member can name a converter with `[]`: System.Text.Json follows it on a property of a class and on a field of a record that it writes as an object, and FSharp.SystemTextJson does not read it on a member of a record *(verified)*. A converter on the member wins over one in the options, `JsonNumberEnumConverter` over `JsonStringEnumConverter` *(verified)*. +- *One case says that the layout does not know*, `NotDescribed`, instead of a missing value and of `Opaque`, as for `OptionalEncoding`. +- *An enumeration is written to see how it is written.* System.Text.Json has one converter class for numbers and for names, and what an instance writes is private to it *(verified)*. So the layout has the serializer write every value of the enumeration that has a name, and all of them have to come out as one kind of JSON value, a number or a string. That also describes a converter of the application, unless it writes one value as a name and another as a number *(verified, review of #62)*: such an enumeration is written in no one way and is not described. A value without a name, alone or as a combination of flags, is written as its number also by the converter of names, unless that converter was made to refuse it *(verified)*. A converter that refuses a value, with `JsonException` or with `NotSupportedException` (review of #62: only the first was caught, and the second came out of the layout), leaves the enumeration not described. +- *A converter of a member writes the whole value of the member.* On a nullable the serializer has it write the value under the nullable, so that value is described by it; on an option it writes the option, and a value under the option is not described *(verified)*. Without such a converter the value under an option is described as any value of its type, and `OptionalEncoding` says whether the option is written as that value. +- *A struct tuple that System.Text.Json writes as `{}`* is `TupleEncoding.Object` whose items have no names in `MemberName`, not a missing answer: the translator reports `MemberNotSerialized` for an item without a name, as for any member. With `IncludeFields` the items have names. +- *A converter of tuples is the one of the library only when its class is* (review of #62). `BaseJsonTupleConverter` and the classes that FSharp.SystemTextJson derives from it are public and open to inheritance *(verified)*, so a converter of an application can be derived from one of them and write a tuple as text. A tuple is `Array` only when the class of its converter is in the assembly of the library and is derived from that base. +- *A member that the serializer never writes is not described*, as it has no name unless it declares one (§21.2): something else writes its property. +- *The contract of a member with a converter of its own* (found in this step, fixed in #61) is asked from options that hold what the converter factory made, not the factory. A built-in collection converter works on a member only when it is the one the options have for the type anyway *(verified: the serializer raises `NullReferenceException` otherwise)*. +- *The encoding of an enumeration is a kind of JSON value* (review of #62), a number or a string, and not what is written for one value: a naming policy can spell the name (`"darkBlue"`), an attribute can declare one, and a converter of the application writes what it likes. The kind decides what a query may do with the member, since only a number can be compared by order and tested for bits. What stands for one value in a query has to come from the serializer, written with the contract of the member that the value is compared with, because a member can name a converter; a parameter that the SDK writes with the contract of the type is not enough there. The parameterizer keeps a literal of an enumeration in the key with its declared type, so the translator has the value and the type to ask with. This is a requirement on the translator step (§8, §11), and it holds for every value whose member names a converter, not only for an enumeration. Left with the maintainer. +- *Not in this step:* the `Empty` part of `CollectionEncoding`, because whether an application stores an empty collection as null is no fact of the serializer (`EmptyCollections` of §15 stays the place for it); how a key of a map is spelled as a property name (`DictionaryKeyPolicy`, a key that is no string), which comes with the translation of a lookup; `Identity`, unions and polymorphism. + +The tests take the kind of JSON value in a document as the oracle: a number or a string for an enumeration, an array or an object for a tuple, an array for a collection, an object or an array for a map, and nothing for a member that was not written. Five types of a model made for these values are written with every one of 31 ways to set the serializer up, which are 155 rows; 31 rows ask about values that no member holds; 14 tests state what a document cannot show: 200. Six ways to set the serializer up joined the 25 of the layout step (enumerations by name with and without FSharp.SystemTextJson, both forms for every map, FSharp.SystemTextJson without its converters of tuples and without its converters of lists, sets and maps), and the production profile of §21.3 writes enumerations by name. 44 one-line defects are planted in the new code, and each fails at least one test; the 105 of the layout step fail against this branch as well. + +**Shelved: the unions of the layout** *(verified, #63)*. `IJsonLayout` has one more member, `UnionCaseEncoding`, about one case of a union: what tells that a value is of the case, its mark, and where each of its fields is, both as steps from the value of the union (`JsonStep`, a property or an item). `JsonLayout.ofSerializerOptions` answers it for FSharp.SystemTextJson from the class of the converter that the contract of the union names, and from the options that the union is written with; those are found as the ones of a record are, by one function for both. Where the implementation departs from §10 and §20.9: +- *The question is about a case.* §10 has one `UnionLayout` for a union, a tag placement and a field layout. The library decides both case by case *(verified)*: with `UnwrapFieldlessTags` a case without fields is its tag alone next to cases that are objects, and `UnwrapSingleFieldCases` and `UnwrapRecordCases` apply to the cases that have one field. +- *Steps, not names of encodings.* The mark is `Tag` (the value at the end of some steps is the tag: `["Case"]`, `[0]`, or no step for a tag that stands alone), `Property` (the object has a property of the name of the case, the external tag), `Null` or `Unmarked`. A place is `Value` with the steps to the value of the field, or `Members` with the steps to the object that holds the members of a record. The translator needs no table of the encodings, and a layout of another serializer can describe a union that fits none of them. The tag is a text, a number or a boolean, as `JsonName` allows *(verified: `{"Case":7}`, and `"7"` as the name of a property)*. +- *A union that is written as its one field* (`ScalarWrapper` in §10, `UnwrapSingleCaseUnions`) is the case `Unmarked` whose field is in place of the union, and no kind of its own. The encoding without tags is `Unmarked` as well: the translator can read a field of such a union and cannot test its case. +- *A null before anything else* *(verified)*. The case that `UseNullAsTrueValue` represents by null is written as `null` in every encoding, and so is the `None` of an option that the library writes as a union. Its mark is `Null`; `OptionalEncoding` says how a member that holds the null is written. The converter that the library has for a union without any field writes a tag for every case and no null, but it never meets one (review of #63): the compiler lets a union be marked with `UseNullAsTrueValue` only when it has a case with fields as well *(verified: error FS1196 otherwise)*. +- *A record in a case* *(verified)*. `UnwrapRecordCases` holds the bit of `NamedFields`, so every case of the union gets named fields. The converter of the union writes the members of the record itself, with its own options of the serializer and with the options of the library that it has or that it finds for the record *(verified)*. So the case is described only when the union is written with the common options and the converter among the options that writes the union makes the converter of the record as well (review of #63): a converter that `[]` on the record makes by itself has no naming policy, and another converter of the library among the options has other options, so in both set-ups the record in the case is not the record that the layout describes *(verified: `{"SignedAs":…}` by itself and `{"signedAs":…}` in the case; an option skipped by itself and written as `null` in the case)*. Next to an internal tag the object is more than the record, which is the place `Members`; everywhere else it is the record as it is written anywhere, the place `Value`. +- *The names* *(verified)*. A tag is the first name of the `JsonName` of the case, then the name of its `JsonPropertyName`, then the name of the case spelled by `UnionTagNamingPolicy`; the naming policy of the serializer does not reach a tag. A field has the name that F# gives it (`Item`, or `Item1`, `Item2`), with `UnionFieldNamesFromTypes` the name of its type and a number where the case has that name twice (`Int321`, `Int322`), or the name that the case declares for it with `JsonName(…, Field = …)`; a name that is not declared is spelled by `UnionFieldNamingPolicy` and, without one, by the naming policy of the serializer. +- *Options that inherit the encoding* *(verified)*. The options that `[]` makes say that they inherit the encoding of unions unless the attribute sets the whole encoding, and the library then replaces the whole encoding by the common one: `UnionNamedFields = true` in the attribute is lost, while the names of the tag and of the fields that the attribute gives stay. Options in the overrides that are made with `InheritUnionEncoding` behave alike. +- *Refusals* *(verified)*. System.Text.Json refuses a union with `NotSupportedException` when it is to write a value, and writes a null without asking what it is a null of. FSharp.SystemTextJson refuses a case with two `JsonName` attributes when it makes the converter, which it does by reflection, so the serializer throws `TargetInvocationException`; the layout counts it as a refusal. An encoding with two places for the tag fails when a value is written, and is not described. +- *The name of the tag twice* (review of #63) *(verified)*. The library writes `{"kind":"Rect","kind":[4,"rect"]}` when the options give the property of the fields the name of the tag, and `{"radius":"Circle","radius":3}` when a field with a name, or a member of a record that a case unwraps, has the name of an internal tag; it reads such a document back. A query reads one property of a name, and nothing says which of the two. The second one is a field, which can hold a text, so a document of that case can hold what is the tag of another case: no case of such a union is described. The layout tells it by the places, without a rule for each encoding: the place of the tag is the place of a field or on the way to one, or a member of the record whose members stand next to the tag has that name. +- *Not described:* a union of a member that names a converter, also when it is a converter of FSharp.SystemTextJson, whose options nothing shows; a union whose contract a resolver of the application made; a union that a class derived from `JsonFSharpConverter` writes; a union with a case that is written with the name of the tag twice; and an option, a value option or a skippable value that is written as its value, which `OptionalEncoding` describes. +- *Not in this step:* a member of a case as a member of a document, that is whether a named field without a value is left out and how an enumeration, a tuple, a collection or a map in a field is written, which the next step adds; the encodings that documents were written with in the past (`withLegacyUnionEncoding`, phase 2); `OverrideMembers` of FSharp.SystemTextJson, which that library keeps internal; `Identity` and polymorphism. + +The tests take the document as the oracle and do not know the rules. A value of every case of twelve union types is written with every one of 58 ways to set the serializer up, the 31 of the layout tests and 27 that write a union differently. The document has to hold the mark of the case and the mark of no other case, and at the place of every field the JSON that the serializer writes for the value of the field; a union that the serializer refuses has to be not described. The same is asked about each union as the value of a member of a record. These are 116 rows, 4 more for the ways to have the name of the tag written twice, and 10 tests state what the rows cannot show: 130. One of them checks that the model is written with every kind of mark and place that the layout has. 74 one-line defects are planted in the new code, and each fails at least one test; the 105 of the layout step and the 44 of the values step fail against this branch as well. + +**Phase 2 – Full query language, caching, compiled queries (4–6 weeks).** CE operations complete (select forms, sort/thenBy, skip/take/top/distinct, `sumBy`/`averageBy`/`minBy`/`maxBy`, `groupBy … into g` with an opaque `Group<'K,'T>` exposing only `Key`, `Count`, `Sum`, `Average`, `Min`, `Max`, `DistinctCount`, `CountIf` – any other use of `g` is a positioned error; `ORDER BY` + `GROUP BY` probed on the emulator and encoded in the validator – nested `for` JOIN, `withPartitionKey`); `QueryModel` lowering table unit-tested against the validator; projections (records, anonymous records, struct and reference tuples per `TupleEncoding` (§10), nested); document-array operations and subqueries; union tag tests through the layout; Math, DateTime parts, Regex, Conversions packs; `Semantics` complete; normalizer completion (interpolation, decimal, `copyOfStruct`, CE tuples (always reference, §7), match guards, active patterns via ReflectedDefinition, bounded fixpoint diagnostics); shape-key plan cache; `CosmosQuery.compile`/`ofReflected`; `Seq` pipeline front end; shipped scalar parser and `Cosmos.Functions.raw`; non-default `JsonFSharpOptions` coverage (union encodings, `SkippableOptionFields`, per-type overrides); SDK LINQ baseline corpus as goldens; cache/compiled-query laws; docs for semantics and extension routes. + +**Phase 3 – Geometry, vector, full text, UDF, partition keys (3–4 weeks).** `GeoShape`, GeoJSON writer, `IGeometryProvider`, `Geo.*` markers, fake third-party provider test; `Spatial` (SDK ≥ 3.62) and `NetTopologySuite` plugins; vector (`vectorDistance`, `[]`, `orderByRank` validation), full text, UDF, `arrayContainsAll/Any/partial`, `documentId`; partition-key and point-read lifting behind `Options.PartitionKeyPaths`; plugin conformance kit; emulator feature gating; spatial, UDF and index tests enabled on the Windows lane; docs. + +**Phase 4 – Verification depth and 1.0 (4–6 weeks).** Interpreter port completed and validated against ported SDK tests and the Learn corpus; three-way Hedgehog differential suite per layout; NoREC/PQS; coverage matrix flipped to strict mode and published; native planner oracle category; fuzzing of unsupported programs; diagnostics snapshot tests; benchmark comparison; lockstep stable release of `Sql`, `Quotations` and the two plugins. + +**Phase 5 – Optional.** `…Quotations.Build` precompilation tool; FSharp.Analyzers.SDK analyzer; per-package versioning in `build.fs` if plugins need their own cadence; C#-consumer companion only on demand. + +**Adjustments from the gap checks (§20–§22).** + +- *Phase 0 probes added:* comparison and equality of `null` and `undefined` against each JSON type (the guard-elimination table); index usage of equality with an object-valued parameter; `ORDER BY` over undefined paths with single and composite indexes; a boolean parameter as third argument of `STRINGEQUALS`/`CONTAINS`/`STARTSWITH`/`ENDSWITH`; a bare boolean parameter as predicate (`WHERE @p0`); `STRINGEQUALS` over `null`, `undefined` and non-string operands; case folding of the ignore-case flag on non-ASCII text; a parameter as indexer (`c["m"][@k]`); `ObjectToArray` on the vNext emulator; auto-quotation on extension members and on custom-operation parameters; FSharp.SystemTextJson unwrapping of private-case unions; quotation shapes of every `etaExpand` operand (struct tuples and struct anonymous records are settled, §7); deserialisation of struct tuples, struct anonymous records and `[]` records through the SDK feed path; text written by STJ for `DateTime (Utc)` versus `DateTimeOffset`; a client built with a custom `CosmosLinqSerializer`; MSTest unpacking of `TestDataRow` rows into the parameters of a plain `[]` and beyond arity 2 (§22.7); for the Hedgehog adapter (§22.6): a build of the ported sources inside this repository in upstream's style (§18 item 12) – the two adapter projects set `Nullable=disable` and are listed in `.fantomasignore`, because the nine upstream files give 23 nullness warnings under the repository's `Nullable=enable` *(verified)* and upstream does not enable nullness checking (no `Nullable` setting in any of its project or props files *(verified)*); since the maintainer's answer of 2026-10-10 only the verbatim port and the attribute commit keep the two settings, and the commit that applies the conventions formats the projects and turns nullness checking on (§18 item 12). Already run in the adapter prototype on MSTest 4.2.3 *(verified)*: `[]`, `[]` and `TestDataRow` rows ahead of generated parameters, the tuple arriving as two leading arguments; `[]` re-running the whole property; a static property method failing discovery of the whole assembly with UTA007, while a generic one is discovered and has to be rejected by the adapter itself; upstream's six constructor overloads combined with the caller-info parameters (a private primary constructor must not share the signature of the `(Type, tests, shrinks)` overload plus caller info, FS0041); a `uint64` `Seed` argument from a `[]`. +- *Moved into Phase 1:* `copyOfStruct` inlining (voption and `ImmutableArray` receivers), the `Set` and immutable-collection member families, `StartsWith`/`EndsWith`/`Contains`, scalar wrappers with ordering operators, the full `Query` module with `OrderedCosmosQuery`, auto-quoted `Container` members, discriminator filters (§21.1), the database test infrastructure of §22, the port of the Marten corpus shapes as golden cases. +- *Generators (§22.6):* Phase 1 adds `tests/Cosmos.Quotations.Tests.Models` and `tests/Cosmos.Quotations.Tests.Models.Hedgehog` (GenConfigs with `Slim`/`Fat`/`Default`, top-level and quotation generators, the variant `AutoGenConfig` types the adapter reads) and the shared-dataset scenario, which the Phase 1 exit criterion "200 generated predicates agree" needs. +- *Phase 2 additions:* keyset paging (§9a), `QueryScope`, page-number and continuation-token paging terminals, legacy union read encodings, programmatic quotations with the `ObjectListFilter` how-to, map members as objects. +- *Phase 3 additions:* `DeleteWhereAsync`/`PatchWhereAsync` composites and `Translator.PatchPath`, point-read lifting with id encoding, optional NodaTime plugin. +- *Phase 4 additions:* user-facing `CosmosQuery.runInMemory` in the `.Testing` package. + +## 17. Risks + +- **Serializer mismatch** silently returns no rows → one supported serializer family (STJ + FSharp.SystemTextJson), `ofClientOptions` detection with a `LayoutRequired` error for anything else, per-variant differential runs, `Explain` showing resolved names. +- **SDK-internal Newtonsoft dependency**: dropping the Newtonsoft *layout* does not remove `Newtonsoft.Json` from the process; the SDK may still need it to serialise the query spec → Phase 0 probe, dependency declared by the package if needed. **Materialised in Phase 0** *(verified, §16)*: the `CosmosClient` constructor loads `Newtonsoft.Json` whatever the serializer, and the SDK's build check, which also reaches consumers of packages built on the SDK, asks every consumer to reference it. **Resolved:** that check is enough, so no package of this repository declares the dependency (maintainer decision 2026-10-10, §18 item 16). +- **IcedTasks under the Debug regression** (dotnet/fsharp#20466) → Phase 0 probe gates every use; `task` remains the fallback. **Materialised in Phase 0** *(verified, §16)*: in a Debug build, where no resumable state machine is compiled, `cancellableTask`, `cancellableValueTask` and `coldTask` of IcedTasks 0.11.9 share one state machine between all starts of a value, so a second start resumes the first one's suspended state and returns wrong data, and a start that overlaps a suspended one blocks its caller. The defect lies in the dynamic path of IcedTasks, not in this library. Scope: Debug builds only (Release compiles the state machines and passes every probe), those three builders only (`valueTask` and `valueTaskUnit` are not affected), and only a value started more than once – the normal use of a cold or cancellable task value, which is a function meant to be invoked again, for example on retry. Not a misuse of `ValueTask` *(verified)*: Microsoft's rules forbid consuming one `ValueTask` instance more than once (awaiting it twice or concurrently, calling `AsTask` twice, reading `Result` early or twice); the probes call the function value twice and await each returned instance once, the pattern the CA2012 documentation marks as correct, and the defect reproduces with the `Task`-returning `cancellableTask` and `coldTask`, which those rules do not cover. IcedTasks' README lists all three builders as "Multi-start: multiple" ("being able to start the asynchronous code block again") and `CancellableValueTask` separately as "Multiple Awaits: Once"; its static path copies the state machine for every start, while `RunDynamic` creates it once per value outside the start function. The static default-scheduler path of `backgroundCancellableTask` and `backgroundColdTask` lacked the per-start copy too and failed a second start even in Release *(verified upstream)*. **Resolved:** our TheAngryByrd/IcedTasks#67 fixes all of these and is released in IcedTasks 0.11.10-beta001; #40 references it and runs every probe in both configurations with none skipped. Residual risk: the dependency is a prerelease until IcedTasks 0.11.10 is stable (§18 item 15). +- **net10.0-only packages** drop `net8.0` consumers of the existing library → major version bump, stated in CHANGELOG and README. +- **Null/undefined semantics** (SDK #4864 class) → truth table, settled on both emulators by #44 (`??` keeps `null`; comparisons across types are `false` or `undefined`, never true for a missing value), `Typed` guards, interpreter oracle. +- **Normalisation divergence/duplication** → bounded fixpoint with typed errors; idempotence and eval-preservation properties; let-inlining duplication accepted in 1.0, `JOIN (SELECT VALUE e) AS x` hoisting as a later pass. +- **Shape-key bugs** returning the wrong plan → single-pass key with typed slots; properties that distinct queries have distinct keys and alpha-equivalent queries share keys; cache bypass in `Explain`. +- **Plugin ambiguity** (NTS and Spatial both loaded) → build-time conflict detection, `Overrides`, conformance-kit law. +- **Emulator coverage** (vNext: no UDFs, full text or spatial functions, index policies ignored, vector crash shapes) → emulator-free rungs carry most assurance; capability gating keeps CI green; the weekly Windows lane covers what the vNext emulator lacks. +- **Interpreter drift** (OfflineEngine is test-only SDK code) → validated against docs corpus and emulator; deviations documented; hybrid/vector stay on translation-only baselines. +- **Numeric fidelity** (decimal, int64 > 2^53, division by zero, JS-int32 bitwise, Cosmos ticks epoch) → coded warnings, `INTDIV/INTMOD/INTBIT*`, documented policies, edge-value tests. +- **Reflection evaluation** runs user code at translation time and is not AOT-safe → configurable policy, never `Expression.Compile`, deny-list. +- **Scope for a small team** → narrow first slice with a byte-level definition of done; everything else additive through the registry; catalog strictness deferred to Phase 4. +- **Toolchain friction** from the C# grammar project → generated sources checked in, no Java in CI, outside Fantomas, excluded from `dotnet test` by the narrowed glob. +- **Branch in flight**: `origin/copilot/support-throughput-bucket-operation-again` is not needed (maintainer decision 2026-10-02), so Phase 0 starts from `main` and the extraction of `tests/Cosmos.Tests.Infrastructure` has nothing to wait for. +- **Docs strictness** (`fsdocs --strict`) → XML docs on every public member from day one; `.fantomasignore` entries for vendored/generated files. The existing docs have known defects (Getting_Started uses nonexistent `Created`/`Conflict` cases; Background references nonexistent `CancellableTaskSeq.ofAsyncEnumerable`; How_Tos are Rx placeholders) – fix alongside. +- **Hedgehog MSTest adapter** relies on MSTest behaviour the API does not promise – the per-invocation lifecycle of `ITestMethod.InvokeAsync`, the experimental `TestContext.Current` (FS0057 on 4.2.3 and 4.3.2), the per-invocation `[]`, `ITestMethod.MethodInfo.ReflectedType` naming the derived test class of an inherited property, the unpacking of rows into `ITestMethod.Arguments` – and one such behaviour already changed between 4.2.3 and 4.3.2 (a cooperatively timed-out invocation reports `Failed`, then `Timeout` *(verified)*) → the adapter suite pins each behaviour it relies on and runs on every MSTest bump; the adapter stays small and free of repository references, because the same code goes into the upstream pull request and is kept in step with `Hedgehog.NUnit` and `Hedgehog.Xunit`; until upstream merges and publishes it, the in-repository copy is the one the library's tests run. + +## 18. Open questions for the maintainer (with recommended defaults) + +1. C# test-only grammar project – **answered 2026-09-26: yes** (`tests/Cosmos.Sql.Grammar`, generated sources checked in, `IsTestProject=false`). +2. Versioning – **answered 2026-09-26: lockstep** with `FSharp.Azure.Cosmos` for 1.0 (single `CHANGELOG.md`); per-package versioning in Phase 5 only if plugins need their own cadence. +3. Windows-emulator CI lane – **answered 2026-09-27: yes**, a nightly (weekly since the review of #45, 2026-10-10), non-blocking Windows-emulator lane from Phase 0 for spatial/UDF/index behaviour, the Phase 0 shape and index-usage probes and the native planner oracle; `Assert.Inconclusive` gating stays on the default Linux lane. +4. SDK bump – **answered 2026-09-27: yes**, `Microsoft.Azure.Cosmos` 3.60.0 → 3.62.0 in Phase 0. +5. Superseded branches – **answered 2026-09-27: yes**, `origin/copilot/implement-linq-translation-methods` and `…/handle-fsharp-language-constructs-in-linq` are deleted after their construct lists are mined into `Reference/Construct_Mapping.md` (Phase 1), not before. +6. Shared-dataset test pattern – **answered 2026-09-27: yes**, a `[]` seeded dataset next to database-per-test for the large conformance suites (§22.2). +7. Ordering between optional values – **answered 2026-09-27: SQL meaning**, absent values are excluded (§20.3). +8. Members with a plain `[]` – **answered 2026-09-27: error until the path is declared** (§21.2). +9. Discriminator filter – **answered 2026-09-27: automatic behind a translator option**, default off (§21.1). +10. Definitions for the Marten corpus port – **answered 2026-09-27: take the Marten layer's own definitions as the model and rename them** (neutral names, same shape and serialisation) (§20.14). +11. CompareNETObjects – **answered 2026-09-27: yes**, test-only (§22.7). +12. **Test data generators and seeding (§5, §20.14, §22.2, §22.6, §22.7)** – partly answered 2026-09-28; the plan uses these defaults for the entries still open: + - Generator layout? – **answered 2026-09-28: two `.Hedgehog` projects** (`Cosmos.Sql.Hedgehog`, `Cosmos.Quotations.Tests.Models.Hedgehog`) plus the test-model library; `Cosmos.Quotations.Testing` drops Hedgehog. + - Names? Root `FSharp.Azure.Cosmos.Tests.Models`, `DocumentsContainer`/`TargetsContainer`, `DatabaseTestFactory`/`IDatabaseTestFactory<'Factory>`, `TestDatabaseFactory`, `Sample.singleWith`/`multipleWith`, `Gen.voptionBalanced`/`skippableBalanced`/`optionBalanced` and the balanced emptiness twins `CollectionGen.Balanced`, `DefaultGen`/`getDefaultGen` (by analogy with the `SlimGen`/`getSlimGen` names of the maintainer's generator conventions, rather than their `defaultGen`, which named the superseded pinned variant), one variant `AutoGenConfig` type per container and variant (`DocumentsDefault`, `TargetsFat`, …), and the neutral stand-in names of §20.14 (`SpaceId`, `TypedGuid`, `TypedString`, `TenantKey`, `ReviewStatus`). + - A fourth, balanced variant beside Slim and Fat? – **answered 2026-09-28: `Default` replaces it**, with the maintainer's definitions: Slim – all optionals empty; Fat – all optionals filled, no empty value; Default – total random. How Default keeps absence and emptiness common – balanced 1:1 helpers instead of the sized `Gen.voption`/`Gen.skippable` of the generator conventions and of 0-bounded collection ranges – is the plan's choice (§22.6). + - Slim and fat in hand-written emulator tests? Two named `DataRow`s on a `withOptionalFields : bool` parameter; properties choose their variant through generators instead (§22.6). + - `_etag`/`_ts` in whole-document comparisons? – **answered 2026-09-28: comparer ignore-lists** instead of the application's copying of the ETag onto generated values (§22.7). + - Where do scenarios live? In `tests/Cosmos.Quotations.Tests/Scenarios/`; the infrastructure keeps the base factory and the fixtures. + - Seeds? A fixed `[]` seed per suite for differential suites, shared datasets and data-driven rows; `Sample.single` stays random in hand-written tests and in scenarios whose data need not be replayed; properties take the suite seed through the adapter's `Seed` setting, which upstream lacks (§22.6). + - Marten stand-in details? The inferred union encodings of §20.14, `Guid.Empty` as the `SpaceId` sentinel, a recreated legacy converter. + - Where does the Hedgehog MSTest adapter live? – **answered 2026-09-28: both** – in this repository as `tests/Hedgehog.MSTest` with `tests/Hedgehog.MSTest.Tests`, and as a pull request to `hedgehogqa/fsharp-hedgehog` as `src/Hedgehog.MSTest` next to `Hedgehog.NUnit` and `Hedgehog.Xunit`; the in-repository copy gives way to the package once upstream publishes it (§5, §16). + - How do properties get the GenConfig variants? – **answered 2026-09-28: variant `AutoGenConfig` types** in the `.Hedgehog` projects, which need only Hedgehog, named on the class or method, **plus `GenAttribute<'T>` subclasses** in the test projects for single parameters. + - Adapter additions and departures beyond upstream? `Seed`, data rows as leading arguments, `Assert.Inconclusive` as a discard, class settings read from the reflected test class so an inherited property sees its derived class's `[]`, a derived class's `[]` over its base's, `GenAttribute` found through the inheritance chain, generic property methods rejected by the adapter, memoised invocations (§22.6); a give-up fails, as in both upstream adapters, which the prototype did not follow. + - ~~Adapter code style?~~ – **answered by the review of #43 (2026-10-10): the coding guidelines of this repository apply to the ported code** – `voption` for every absent setting, arrays instead of lists, array and sequence expressions instead of `append`, the shared collection functions of #47, one internal module (`KnownTypes`) for the types that code compares with, functions with named parameters instead of function values. The upstream pull request keeps upstream's style in a copy of its own. Formatting and nullness checking follow the repository as well (maintainer answer of 2026-10-10): the two projects left `.fantomasignore` and build with `Nullable=enable` without a warning. A data row and the arguments of an invocation or of a config method are arrays of `objnull`, a null array is checked for where it enters, and one scoped `#nowarn "3261"` remains in a helper of its own, because MSTest declares the elements of the arguments of `ITestMethod.InvokeAsync` as non-null although it passes `ITestMethod.Arguments`, with nullable elements, to that method itself *(verified in the attributes of MSTest.TestFramework 4.2.3)*. Only the first two commits of #43, the verbatim port and the attribute, keep upstream's style. + - Where do writing properties (round trips) keep their data? – **answered 2026-09-28: there are none** – property tests only read class-scoped datasets; round trips stay hand-written slim/fat tests with per-test databases (§22.2, §22.6). + - Later-phase members in Fat? Left out of the test model until their phase, so Fat keeps no empty value (alternative: the empty-in-Fat exception of the generator conventions, which contradicts the maintainer's Fat definition). +13. IcedTasks after the failed Phase 0 probe (§2, §12, §16, §17) – **answered 2026-10-03**: + - Allow `valueTask` and `valueTaskUnit` alone, scoping the gate per builder? – **answered 2026-10-03: yes, only these two**, starting with the hand-rolled `ValueTask` wrapper of the feed iterator (`ValueTask (moveNext)` in `src/Cosmos/IterationExtensions.fs`), because they start immediately and pass every probe in Debug and Release; `cancellableTask`, `cancellableValueTask`, `coldTask` and their background variants stay out of library code until their probes pass with none skipped. + - Report the defect upstream to `TheAngryByrd/IcedTasks`? – **answered 2026-10-03: yes, once it was confirmed not to be by design** (§17): reported as TheAngryByrd/IcedTasks#65 and fixed by our TheAngryByrd/IcedTasks#67, released in 0.11.10-beta001 (§18 item 15). +14. Default emulator partition budget (§22.4) – **answered 2026-10-03: keep 25** (the Windows emulator's default partition count); a development machine whose emulator holds fewer containers starts it with a larger `/PartitionCount` (which takes effect only after the emulator's data is reset) or sets `COSMOS_EMULATOR_PARTITION_COUNT` lower. +15. **A stable release on the IcedTasks prerelease (§2, §16, §17)** – the probes pass only from IcedTasks 0.11.10-beta001 on (#40), and NuGet warns with NU5104 when a stable package depends on a prerelease. Recommended default: prereleases of this library may use it, a stable release waits for IcedTasks 0.11.10 (alternative: release on the prerelease and accept NU5104). +16. Which package declares `Newtonsoft.Json` (§5, §16, §17) – **answered 2026-10-10: none.** The SDK loads it in the `CosmosClient` constructor whatever the serializer, declares no dependency on it, and fails the build of every consumer without a reference to it unless the consumer opts out of the check. That check keeps asking consumers, as it does today; neither `FSharp.Azure.Cosmos` nor the Quotations package declares the dependency. The recommended default had been a declaration in `FSharp.Azure.Cosmos`, which the Quotations package would have inherited. + +## 19. Verification of this plan once implemented + +- `dotnet build FSharp.Azure.Cosmos.slnx` and `./build.cmd DotnetBuild` succeed with all new projects in `.slnx` and `.slnf`; `fsdocs --strict` passes. +- `dotnet test --no-build` runs only `*.Tests` projects (narrowed glob) with trx output; unit/spec-conformance categories pass on Windows/macOS without the emulator; emulator categories pass on the Linux vNext emulator in CI; feature-gated tests report `Inconclusive` rather than fail. +- Every emitted SQL string in every test parses under the SDK grammar (rung 1) and validates (rung 2). +- Phase 1 definition of done (§16) demonstrated end to end on the emulator, including `patch { filterPredicate <@ … @> }`. +- Differential suite: three-way agreement on generated predicates per layout; metamorphic TLP/NoREC hold on the emulator. +- Plugin conformance kit passes for `Spatial`, `NetTopologySuite` and the fake third-party provider; a `RegistryConflictException` test proves conflicting plugins fail fast. +- Benchmarks show cached translation cost is negligible against a round trip, which decides whether Phase 5 tooling is ever built. +- Every ported Marten corpus case (§20.14), including the 17 cases the Marten layer had to ignore, has a full-text golden baseline and runs through the grammar, the interpreter and the emulator. +- The query shapes of the maintainer's Cosmos application (§21) translate and return the same results as the application's current SDK LINQ queries on the same data. +- Database tests follow §22: the partition budget is never exceeded under method-level parallelism, no database is left behind after a run, and translation tests pass on a machine without an emulator. +- The generator projects are built on their own first, then the test projects that consume them are built and run on the emulator, and a changed generator's failures are compared with a run on the base branch – the verification the maintainer's generator conventions prescribe (`dotnet build --nologo` on its own, then the consuming test projects). The library adds that the generator projects build without warnings, XML-doc warnings included; the conventions do not ask for that. +- The Hedgehog adapter suite passes on MSTest 4; a deliberately failing property yields one failed result with `Recheck Seed:` and `Test parameters:`, and `[">]` replays it in one invocation; a derived `[]`'s `[]` reaches a property inherited from its abstract base; `--list-tests` succeeds for every test executable, so a static property or one whose return type is not `unit`, `Task` or `ValueTask`, either of which fails discovery of the whole assembly, fails CI early; no test code calls `Property.check*` or `Property.recheck*`; no `[]` method sits in a per-test-database class. + +## 20. Parity with the maintainer's Marten F# layer + +Source: the maintainer's earlier F# LINQ layer over Marten (below: the Marten layer; 57 source files) and its test suite (20 test files, 245 test methods). A gap check extracted **489 distinct cases**; a second, independent pass verified every classification against the plan text: **233 covered, 202 partially covered, 28 missing, 26 not applicable** (PostgreSQL/Marten mechanics). The rules below close the partial and missing cases; where they differ from earlier sections they take precedence. SQL shown is illustrative: parenthesisation comes from the printer and key casing from the layout. `h` and `v` denote the has-value flag and the value of an optional pair (§9). + +### 20.1 No partial translation + +A translator may never drop, weaken or approximate a sub-expression. `NotApplicable` or `Failed` on any node – a conjunct of `&&`, a stage of a collection pipeline, a lambda inside `Option.exists`/`Seq.exists`, a method argument – fails the whole query with a positioned `Unsupported` error. There is no lenient `AND`, no "array is not empty" or "is not null" fallback, and no catch-all exception handler in translation code. This is the dominant bug class of the Marten layer, including a fallback to `IS NOT NULL` that returns documents the predicate should exclude, a data leak. Tests: removing any one registered rule turns every golden case that used it into an error, never into shorter SQL; every conjunct of a generated predicate has a term in the emitted SQL; each built-in pack works when loaded alone; the coverage report lists rules never hit; no test asserts only non-emptiness or a substring. + +### 20.2 Normalizer additions + +- `etaExpand` applies to the root **and to every function-typed argument of a registered higher-order member**. Operand shapes: lambda; method group (`Option.isSome`); operator section or partial application (`(=) userId`, `(>) 5`, `isOwnedBy userId`); composition whose operands have any of these shapes; first-class operators such as `not`; a function value or a closed call returning a function → `CapturedFunctionValue` unless `[]`. The synthesised variable is typed with `FSharpType.GetFunctionElements`, never `obj`. +- Pipes and composition are defined independently of operand shape: `op_PipeRight`/`op_PipeLeft` → `Application (f, x)`; `op_PipeRight2/3` and `op_PipeLeft2/3` → nested applications. Their tuple operand is a reference tuple by the operators' signatures – a struct operand does not compile (FS0001) – and never reaches the normalizer as one value: the compiler passes a syntactic tuple as separate arguments (`Call (op_PipeRight2, [a; b; f])`) and a tuple value `t` as `Let (func, f, Let (arg1, TupleGet (t, 0), Let (arg2, TupleGet (t, 1), Call (op_PipeRight2, [arg1; arg2; func]))))`, with `t` first bound to `tupledArg` when it is not a variable *(verified in fsi)*; `inlineLets` removes these bindings, and the law pair below has no struct twin; `op_ComposeRight [f; g]` → `fun x -> g (f x)`. Operators are matched by FSharp.Core `MethodInfo`, never by name. +- `betaReduce` covers unit thunks (`defaultWith`, `orElseWith`) and curried application; `Coerce (e, seq<_>)` inserted when a collection member is passed to `Seq.*` or LINQ is erased; `a = true` → `a`, `a = false` and `a <> true` → `NOT a`. +- Residual check on application spines: a function-value head with a document-dependent argument → `CapturedFunctionValue` at the outermost application; with closed arguments the whole spine is client-evaluated into one slot. +- Laws with golden pairs: `x |> f a` = `f a x` (the piped value is the last curried argument); `(a, b) ||> f` = `f <|| (a, b)` = `f a b`; `(op) a b` = `a op b`; operator sections preserve operand order (`xs |> Seq.exists ((>) 5)` → `… WHERE (@p0 > t)`); `not << _.IsActive.Value` = `_.IsActive.Value >> not` = `fun d -> not d.IsActive.Value`. Pipes and sections are tested three lambdas deep. + +### 20.3 Optional values under comparison + +- **Equality on pairs.** `(h1, v1) = (h2, v2)` → `((NOT h1 AND NOT h2) OR (h1 AND h2 AND (v1 = v2)))`. Statically known sides simplify: `= Some e` → `(h AND (v = e))`; `= None` → `NOT h`; `<> Some e` → `(NOT h OR (v != e))`; `<> None`, `not x.IsNone` → `h`. A source-written `Some ` is destructured, never serialised whole. Operands are symmetric. +- **Captured optional values** (`option`, `voption`, `Skippable`, `Nullable`) bind as **two slots** `(@o_has : bool, @o : inner value or null)`: `x = o` → `((NOT @o_has AND NOT h) OR (@o_has AND h AND (v = @o)))`. The SQL text is identical whether the value is `Some` or `None` at run time, a captured `Skip` is expressible, and negation stays correct. This replaces the whole-option parameter of earlier drafts. +- **Ordering of optional values has SQL meaning** (maintainer decision 2026-09-27): `<`, `<=`, `>`, `>=` and `compare` between optional values are true only when both values are present, `a < b` → `(ha AND hb AND (va < vb))`; with a captured optional the has-value slot takes the place of `hb`. Absent values are excluded, as the SDK LINQ provider does today and as the application expects (`token.ExpirationDate < now` must not match a token without an expiration date). This deliberately differs from F#, where `ValueNone < ValueSome x` is true: advice `CQ1021 OptionalOrdering` says so, `Explanations/Null_And_Undefined.md` documents it, and the differential oracle evaluates ordering between optionals with the same rule instead of F# structural comparison. Equality keeps F# meaning (`None = None` is true). Keyset paging (§9a) builds its own predicates and is not affected. +- **Null tests on nullable references.** `isNull p`, `p = null`, `match p with null` → `(NOT IS_DEFINED(p) OR IS_NULL(p))`; the negations → `(IS_DEFINED(p) AND NOT IS_NULL(p))`. +- **NOT-safety invariant.** For every optional predicate `q`, the translation of `not q` is `NOT (translation of q)` and agrees with F# on documents where the member is missing or null. +- **Guard elimination** (`simplify`). `(h AND (v op e))` → `(v op e)` in positive polarity when `e` is a slot or a non-null literal: the Phase 0 table shows that the bare comparison is never true for `null` or `undefined` – equality and `IN` across JSON types are `false`, `null` included, ordering across types is `undefined`, and a missing value makes both `undefined` *(verified on both emulators, #44)*. In negative polarity the guard stays: `NOT (v = e)` is true for `null` but `undefined` for a missing value, so without the guard a negated predicate would lose the documents that lack the member. The guard never costs the index seek – the guarded comparison reads only the matching document, at 3.02 RU where the bare one costs 2.92 *(verified on the Windows emulator, §16)* – so the rule shortens the SQL of the common case `doc.DocumentNumber |> Option.exists (fun n -> n = docNumber)` → `(doc["documentNumber"] = @docNumber)` and saves the guard's small charge. +- **Laws.** `x.IsNone` = `not x.IsSome` = `x = None`; `Option.exists ((=) v) x` = `Option.contains v x` = `x = Some v`; `a <> b` has the row set of `not (a = b)`. Goldens bind the same quotation once with `Some` and once with `None` and assert identical SQL. +- **Migration note.** `= Skip` means *missing only*; the Marten layer treats `Skip` and null alike. `Skippable.toOption x = None` gives the old meaning. + +### 20.4 Partial unwraps (`.Value`, `Option.get`, `ValueOption.get`) + +- The unwrap reads `v`; member access continues the path at any depth (`d.Submitter.Value.Name` → `inner(d["submitter"])["name"]`), and an optional member there is again a pair. The compiler-generated getters `Value` (option) and `Item` (voption, Skippable) are the same node. +- **Precondition.** F# throws on an absent value, SQL yields `undefined`. Under `Typed` and `Strict` the `h` of the unwrapped value is a precondition of the smallest enclosing boolean atom, hoisted over `NOT` and local to its operand of `AND`/`OR`: `Option.get x > 5` → `(h AND (v > @p0))`; `not d.IsActive.Value` → `(h AND NOT v)`. Documents on which F# would throw are excluded in both polarities. Guard elimination may drop the precondition. `Native` emits the bare `v`. +- **Throwing branches** of an inlined `[]` helper (`failwith`, `invalidOp`, `raise`) contribute the negation of their condition as a precondition, so project-level helpers such as a user-defined `Skippable.get` translate without registration. +- **Projections.** The precondition is hoisted into `WHERE` with warning `CQ1012 UnguardedUnwrap`. A top-level scalar projection of the optional value itself (`select p.Nick`) is `SELECT VALUE (h ? v : null)` so the row count is preserved and `None` round-trips; a bare top-level `Skippable` projection is `InvalidQueryShape`. The Marten workaround "select `.Value`, then `Option.ofObj` on the client" is obsolete. + +### 20.5 Pair algebra completion + +- The default of `defaultValue`/`defaultArg`/`defaultWith` is any scalar expression, also one that reads the document (`doc.Nick |> Option.defaultValue doc.Name`). `defaultWith (fun () -> d)` = `defaultValue d`, `orElseWith` = `orElse`. +- `flatten (h, (h', v'))` → `(h AND h', v')`; `map2 f a b` → `(ha AND hb, f va vb)`; `fold f s` → `(h ? f s v : s)`; unit-returning functions (`iter`) are `Unsupported`. +- **Predicate position.** A bool-typed `(h ? v : d)` is emitted as `((h AND v) OR (NOT h AND d))`, simplified for literal defaults: `defaultValue true` → `(NOT h OR v)`, `defaultValue false` → `(h AND v)`. +- **Optional-typed branches** of `if`/`match` → `((c AND ha) OR (NOT c AND hb), (c ? va : vb))`. +- **Nested optionals.** `Skippable<'T option>`: outer pair `(IS_DEFINED(p), p)`, payload pair `(NOT IS_NULL(p), p)`. `'T option option` under unwrapped encodings is not representable → `AmbiguousNestedOption`. +- Pair rules apply to computed optionals and to paths rooted at any alias (root, JOIN alias, element alias). +- One rule table parameterised by the carrier (`Option`, `ValueOption`, `Skippable`). `Skippable` built-ins are exactly the functions of the package *(verified in the FSharp.SystemTextJson sources)*: `map`, `map2`, `map3`, `bind`, `apply`, `defaultValue`, `defaultWith`, `orElse`, `toOption`, `ofOption`, `toValueOption`, `ofValueOption`, `filter`, `isSkip`, `isInclude`. Functions the package does not define are user code, translated when `[]` or registered; members are never matched by simple name. +- Test matrix: optional kind × function × position {predicate, negated predicate, `&&`/`||` operand, comparison left/right/both sides, arithmetic operand, function argument, method receiver, projection, sort key, element predicate}. + +### 20.6 Strings + +| F# / BCL | SQL | +|---|---| +| `s.Contains x` | `CONTAINS(s, x)` | +| `s.StartsWith x` | `STARTSWITH(s, x)` | +| `s.EndsWith x` | `ENDSWITH(s, x)` | +| `a = b`, `a.Equals b`, `String.Equals (a, b)`, `a.Equals (b, Ordinal)`, `String.Equals (a, b, Ordinal)` | `(a = b)` | +| `a.Equals (b, OrdinalIgnoreCase)`, `String.Equals (a, b, OrdinalIgnoreCase)` | `STRINGEQUALS(a, b, true)` | +| `a.Equals (b, cmp)`, `String.Equals (a, b, cmp)` with a captured `cmp` | `STRINGEQUALS(a, b, @cmp)` | +| `s.Length`, `String.length s` | `LENGTH(s)` | +| `ToLower`/`ToLowerInvariant`/`ToUpper`/`ToUpperInvariant` | `LOWER(s)`/`UPPER(s)` with `CQ1001` | +| `s.[i]`, `s.Chars i` | `SUBSTRING(s, i, 1)` | + +**String equality and the `StringComparison` argument** *(quotation shapes verified in fsi; SDK behaviour read from `StringBuiltinFunctions.cs`; index behaviour from the indexing overview on Learn and from the Windows emulator, §16)*. + +- **Recognised members**, each by exact `MethodInfo`: instance `Equals (string)`, `Equals (obj)` (the `Box` around a string-typed operand is stripped; any other operand type is `Unsupported`) and `Equals (string, StringComparison)`; static `String.Equals (string, string)` and `String.Equals (string, string, StringComparison)`; `StringComparer.Ordinal`/`OrdinalIgnoreCase`/… `.Equals (a, b)`; `MemoryExtensions.Equals (a.AsSpan (), b.AsSpan (), cmp)`, where `AsSpan ()` of a string is the string itself and `AsSpan (start, length)` is `SUBSTRING`; `String.Compare (a, b, cmp) = 0` and `<> 0`. The instance form arrives as `Call (Some a, Equals, [b; cmp])` and the static form as `Call (None, Equals, [a; b; cmp])`; both build the same scalar tree, so they give byte-equal SQL and share one cached plan. The SDK LINQ provider translates only the instance forms and gives up when the comparison is not a constant. +- **The comparison selects the SQL form; it is never passed through.** + - *Source literal* (`Value (StringComparison.OrdinalIgnoreCase)`): a translation-time fact, always inlined and hashed into the shape key, never a slot, also under `ParameterizeLiterals = true`. `Ordinal` → `(a = b)`, the same tree as the F# `=` operator, so partition-key lifting, point-read detection and guard elimination see one canonical form of equality. `OrdinalIgnoreCase` → `STRINGEQUALS(a, b, true)`, an expanded index scan by the indexing overview and on the Windows emulator (4.40 RU where `=` costs 2.92, §16) – not the index seek that the reference page of `STRINGEQUALS` states, which holds for the case-sensitive form only. A query that needs the seek compares a lowercase computed property instead (`ComputedProperties`, §21.2), as advisor rule QA1003 recommends. + - *Captured value* (`ValueWithName (cmp)`, a module value, any closed expression): one boolean slot; the SQL is always `STRINGEQUALS(a, b, @cmp)` and the binder maps the enum value to the flag, so the text never depends on the run-time value. A value outside the enum is `InvalidParameterValue`. Both emulators accept a parameter in this position *(verified, #44)*, so the flag stays a slot and the SQL text does not depend on the comparison; neither does the plan, because the Windows emulator charges a flag parameter exactly what it charges the literal of the same value (§16). A captured comparison never yields the `=` form, so partition-key lifting needs the literal. + - *Document-dependent expression* (`if p.Strict then Ordinal else cmp`): `Unsupported`, with the hint to branch outside the call. +- **Culture-sensitive values** (`CurrentCulture`, `InvariantCulture` and their `IgnoreCase` variants, the `(string, bool, CultureInfo)` overloads, `StringComparer.InvariantCulture…`): Cosmos compares strings ordinally and has no collation. `Semantics.CultureComparison = Reject | AsOrdinal`, default `Reject`: the error `CultureComparisonNotSupported` names the ordinal replacement, in line with §20.1 (no approximation). `AsOrdinal` maps each value to its ordinal counterpart with warning `CQ1014`, which is what the SDK LINQ provider and the Marten layer do silently. A captured culture value is rejected or mapped at bind time by the same policy. The application uses culture comparisons only in client-side in-memory filters; its one Cosmos query of this kind is ordinal. +- **Operands** are any scalar in either role: member against slot, member against member, slot against member (`"x".Equals (p.Name, OrdinalIgnoreCase)` → `STRINGEQUALS(@p0, p["name"], true)`), the value of an optional pair, a scalar wrapper. Operand order is kept as written. A call with no document-dependent operand (`"djkj".Equals ("dsd", StringComparison.Ordinal)`) is a closed sub-tree: under `EvaluationPolicy.PropertyChains` .NET evaluates it into one boolean slot (`WHERE @p0`), culture-sensitive values included; under `LeavesOnly` or `Never` it is translated like any other call (`(@p0 = @p1)`). +- **Null.** Operands typed `string` are assumed present under `Typed`. For operands typed `string | null` or taken from an optional pair the static forms follow pair equality (§20.3) with the chosen function in place of `=`, because `String.Equals (null, null, cmp)` is true: `((NOT ha AND NOT hb) OR (ha AND hb AND STRINGEQUALS(va, vb, true)))`, simplified when one side is known to be present. In the instance forms the receiver is a partial unwrap (§20.4): `ha` is a precondition and a null argument gives false, `(ha AND hb AND STRINGEQUALS(va, vb, true))`. A captured `string | null` binds as two slots like any captured optional value. +- **Functions without a case flag.** `INDEX_OF`, `REPLACE` and the ordering operators have none: `IndexOf`/`Replace` with `OrdinalIgnoreCase` and case-insensitive ordering (`String.Compare (a, b, OrdinalIgnoreCase) < 0`) are `Unsupported`; the hint shows the `ToLowerInvariant` spelling, which is a scan. +- **Required consumer shape** (a prefix-existence check of the maintainer's application): `p.PrefixNormalized.Equals (query.Prefix, StringComparison.OrdinalIgnoreCase)` → `STRINGEQUALS(p["prefix_normalized"], @p0, true)`. +- **Probes and tests.** Phase 0 results *(verified, #44)*: a parameter works as the third argument and a bare boolean parameter as the whole predicate (`WHERE @p0`); `STRINGEQUALS` is `undefined` unless both operands are strings, so over `null`, a missing value or a number it drops the row in either polarity; the ignore-case flag folds some non-ASCII letters otherwise than .NET `OrdinalIgnoreCase` – on the Windows emulator only the Greek final sigma (`ς` against `Σ`: not equal, while .NET calls them equal), on the vNext emulator also the dotted capital I, the Kelvin sign and the Angstrom sign, which it equates with the letters they lowercase to. The divergence depends on the engine and is reported as advice `CQ1022 CaseFoldingFidelity`. Truth table as data-driven cases: six comparison values × {instance, static, `StringComparer`, span} × {literal, captured} × {member–slot, member–member, slot–member, closed} × {predicate, negated, inside `Option.exists`, projection}. Laws: instance and static forms give byte-equal SQL; every `Ordinal` form is byte-equal to `a = b`; literal and captured comparison return the same rows. + +**Other string functions.** Overloads of `Contains`/`StartsWith`/`EndsWith`: `string`, `char`, `(string, StringComparison)`, `(string, bool, CultureInfo)`. The comparison argument follows the rules above, except that `Ordinal` gives the two-argument form of the function; a captured comparison binds the third argument as a boolean slot, which both emulators accept *(verified, #44)*. Receiver and argument are any scalar, including the value of an optional pair and another member. `LIKE` is never generated for these functions, so `%` and `_` in user input need no escaping (the Marten layer builds unescaped `LIKE` patterns). `string` is never a collection: `Seq.*`/`Enumerable.*` applied to a string are `Unsupported`; string `Contains` and collection `Contains` are told apart by declaring type. + +### 20.7 Collections + +- **Keyed by operation, not by collection type.** Any member whose `CollectionEncoding` is a JSON array is a path, and every member family maps `contains`, `exists`/`Any`, `forall`/`All`/`TrueForAll`, `filter`/`where`/`Where`, `map`/`Select`, `length`/`count`/`Length`/`Count`, `isEmpty`/`IsEmpty`/`IsDefaultOrEmpty` and the indexer to the same SQL forms. Families: FSharp.Core `List`, `Array`, `Seq`, `Set`; FSharp.Collections.Immutable modules (compiled names `ImmutableArrayModule`, `ImmutableListModule`, `ImmutableHashSetModule`, `ImmutableSortedSetModule`, recognised by declaring-type full name); `System.Linq.Enumerable` **and `System.Linq.ImmutableArrayExtensions`** (the overloads F# binds for `ImmutableArray` receivers – missing them was a production bug of the Marten layer) and `MemoryExtensions`; BCL instance members; interface members through a pattern translator keyed on `IEnumerable<'T>` plus JSON-array encoding, so set and list types of other libraries (FSharp.HashCollections) work without a plugin. Each key carries an argument-role map (collection, element, predicate) because F# modules and LINQ use opposite argument orders. The element type comes from `IEnumerable<'T>` of the **static** type. +- **Captured collections.** Direction is decided by free-variable analysis, not by module: closed collection and document-dependent element → `ARRAY_CONTAINS(@xs, )`, which the Windows emulator serves from the index at the cost of the equivalent `IN` (§16); document path → `ARRAY_CONTAINS(, )`; both closed → client-evaluated bool slot. Any closed `IEnumerable<'T>` is one JSON-array slot, enumerated exactly once at bind time, each element encoded with the static element type, so the element JSON equals the JSON stored for a member of the same CLR type. The shape key uses the element type, not the concrete collection type. +- **Empty and null.** A captured empty collection keeps the SQL text; a source-literal empty list folds to `false`. A null captured collection is the bind-time error `InvalidParameterValue`. `None` is never replaced by `default('T)`: `values |> List.contains p.X` with optional elements binds `(@hasNone, @somes)`. +- **Pipeline fusion** (new pass `fuseCollectionPipelines`): `exists q (filter p xs)` → `exists (fun t -> p t && q t) xs`; `isEmpty (filter p xs)` → `not (exists p xs)`; `contains v (map h xs)` → `exists (fun t -> h t = v) xs`. One subquery per pipeline: `xs |> Seq.where p1 |> Seq.where p2 |> Seq.exists q` → `EXISTS(SELECT VALUE a FROM a IN doc["approvals"] WHERE ((p1 AND p2) AND q))`. +- **Same-element semantics.** All conjuncts are evaluated on one element. `ARRAY_CONTAINS(arr, {...}, true)` is used only for one conjunction of equalities packed into one partial object, opt-in through `PreferArrayContains`, which shortens the SQL and gains no index use: both forms are seeks (§16). The Marten translation lets different elements satisfy different conjuncts. +- **Element equality.** `exists (fun t -> t = e)`, `exists ((=) e)` → `ARRAY_CONTAINS(xs, e)`; `forall (fun t -> t <> e)` → `(NOT ARRAY_CONTAINS(xs, e))`. +- **Negation and correlation.** `not (xs |> Seq.exists p)` → `(NOT EXISTS(...))`, true over a missing array as F# `forall` over an empty collection. Element predicates may reference outer aliases; the query variables are the root alias plus every enclosing element-lambda variable. +- **Emptiness on members that may be absent.** `isEmpty` → `(NOT IS_ARRAY(x) OR (ARRAY_LENGTH(x) = 0))`, `length` → `(IS_ARRAY(x) ? ARRAY_LENGTH(x) : 0)`. +- **Option-returning functions.** `tryHead xs` → pair `((ARRAY_LENGTH(xs) > 0), xs[0])`; `tryItem`; `tryFind`/`tryPick`/`tryLast` are `Unsupported` until the subquery probes allow them. `Set.isSubset`/`isSuperset` → `ARRAY_CONTAINS_ALL`. +- **Named goldens** are the maintainer's production shapes, for example the "action required" predicate over `doc.Approvals` with `Seq.where`, `ValueOption.exists` and `not (… Seq.exists (fun a -> a.Status <> Approved))`. + +### 20.8 Scalar wrappers and transparent members + +- **Detection is structural**, with no whitelist: a type is a scalar wrapper when it is a union with exactly one case and one field, including private or internal cases, struct unions and closed generic instantiations with phantom arguments (`TypedGuid<'P>`, `TypedString<'P>`). Classes or structs with a custom converter register `TypeMapping.scalarWrapper`. The Marten layer uses a closed whitelist of six generic definitions and needs edits in four places for every new wrapper kind. +- **Derived mapping.** `JsonType`, `IsOrderable`, `ToLiteral` and the numeric/date warnings are those of the innermost field type; nested wrappers compose. +- **Values** are always one parameter serialised by the user's serializer. A captured value without a `TypeMapping` is still bound; `NoTypeMapping` arises only when a literal is required. +- **Operators.** `=`, `<>` compare the path with the parameter; `<`, `<=`, `>`, `>=`, `compare`, `CompareTo` are the same operator on the inner path when `IsOrderable`, otherwise `OrderingNotSupported`. +- **Sort and group keys.** `sortBy d.Name`, `sortBy d.Name.Value`, `sortBy (TypedString.value d.Name)` → `ORDER BY d["name"]`; wrapper accessors never trigger `OrderByNotPath`. +- **Transparent members.** `TypeMapping.Members : ImmutableArray`, `MemberProjection = Identity | Path | Sql | Unsupported`. `Identity` is the path of the owner itself: `MailAddress.Address`, `Uri.OriginalString`, wrapper `.Value`/`Item`, module accessors such as `TypedGuid.value`, `op_Implicit`. A member that is not listed is `Unsupported`; it is never a nested path and never silently the scalar (the Marten check ignores which member is accessed, so `e.Host` would compare the whole address). Accessors are recognised when compiler-generated, `[]`, marked `[]`/`[]`, or listed. +- **Type-owned hooks.** `TypeMapping.Equality` and `TypeMapping.Containment` let a type own `=`, `<>`, ordering and "member in captured collection". `StoredShape` declares the stored JSON of a type whose converter hides it. +- Provider resolution is by specificity (exact type, generic definition, pattern) and only then by registration order; shadowed providers are reported. Registration order is never load-bearing. + +### 20.9 Unions + +SQL text depends on the static shape of the expression only, never on the run-time case of a captured value. + +- **Fieldless source literal.** `x.Status = Pending` is normalised to `UnionCaseTest`: identical SQL to `x.Status.IsPending` and to `match`. Tag test per layout: adjacent or internal tag → `(x["status"]["Case"] = "Pending")`; bare string → `(x["status"] = "Pending")`; external tag → `IS_DEFINED(x["status"]["Pending"])`. The tag value is the case's `JsonName` when present. +- **Enum-like unions, captured value or collection.** `x.Status = status` → `(tagPath = @status)`; `statuses |> List.contains x.Status` → `ARRAY_CONTAINS(@statuses, tagPath)`; the slot binder extracts the layout tag names. +- **Unions with fields.** A captured value or a closed case with fields is **deep equality on the whole value** `(p = @p0)`, serialised with the slot's static type, which the Windows emulator serves as an index seek (§16). Object equality ignores the order of properties and array equality does not *(verified on both emulators, #44)*, so the property order of the parameter does not matter while a list field keeps the order semantics of F#. When a field depends on the document → `(tagTest AND (fieldPath = e) …)`. Tag-only comparison must be asked for explicitly (`u.IsC`, `match`, `Cosmos.Functions.sameCase`). The Marten layer compares case names only, so `Rejected "a"` equals `Rejected "b"`. +- **Inequality.** Case with fields → `(NOT tagTest OR (fieldPath != @x))`, so documents of other cases are included. +- **Ordering** on a multi-case union → `OrderingNotSupported` (F# orders by tag index, JSON stores case names). +- **Null.** A captured union that is null at bind time is that case only when the type uses `UseNullAsTrueValue`; otherwise `InvalidParameterValue`. Deliberate non-parity with the Marten layer, which reads null as the default case. +- **Layout model.** `UnionLayout` = tag placement × field layout (§10), total over union types found with non-public binding flags, resolved recursively. Field paths: `Positional` → `p["Fields"][i]`; `Named` → `p["Fields"][""]` (a single unnamed field is `"Item"`); `UnwrappedSingleField` → `p["Fields"]`. The adapter maps every `JsonUnionEncoding` flag. **Legacy read encodings**: `JsonLayout.withLegacyUnionEncoding` adds a read encoding; tag tests become an OR over the encodings with advice `CQ1015 MixedEncodingScan` – the Marten data exists in two encodings at once. + +### 20.10 Entry points and API surface + +- The functional `Query` module is complete (§15), mirrors the Marten `Queryable` module (24 functions) and keeps compile-time ordering state through `OrderedCosmosQuery<'T>`. Migration table: `filter` → `where`, `map` → `select`, `orderBy` → `sortBy`. +- `Query.groupBy` must be followed by `Query.select` over the group; `where` over the result of that `select` lowers to a filter outside a `GROUP BY` subquery, `SELECT VALUE x … FROM (SELECT … GROUP BY …) AS x WHERE …`, which stands in for the missing `HAVING` *(verified on both emulators, #44)*; a sort by an aggregate of the group is `OrderByNotPath`, because an `ORDER BY` outside a subquery accepts only paths of the documents (error 2206, verified). `Group<'K,'T>` accepts the property spelling (`g.Count`) and the LINQ spelling (`g.Count ()`, `g.Sum (fun x -> …)`). +- Terminals (§15): throwing and try forms of first and exactly-one, 32- and 64-bit counts, predicate counts, continuation-token page, page-number page with total, keyset page. Try-terminals carry no `not struct`/`not null` constraint: emptiness is decided from the feed response item count, never from a null or default check (the Marten terminals use null as the "no result" signal and are restricted to reference types). Curried token-first pipeline terminals (`|> Query.countAsync ct container`) mirror the Marten module and return the same types as the `Container` members (the continuation-token page is `struct {| Items : ImmutableArray<'T>; ContinuationToken : string voption |}`, never a tuple); the tupled `Container` members stay the primary documented surface. +- **Programmatic quotations.** The translator accepts any well-typed `Expr`, including trees built with `Expr.Lambda`, `Expr.PropertyGet`, `Expr.Call`; the shape key is structural. `Field.ofName`, `Query.whereExpr` and `SortKey` values serve filters and sort fields that arrive at run time. A how-to shows an adapter for the FSharp.Data.GraphQL `ObjectListFilter`, which the application applies to its queries today. +- **Delegate-rooted quotations.** `<@ Func<'T, bool> (fun x -> …) @>`, the shape the application uses for expressions that are also lowered with `LeafExpressionConverter`, is accepted by `Query.where` and the `Predicate` combinators; `normalizeDelegates` turns the root into a lambda. The struct preference does not extend to such shared expressions: `LeafExpressionConverter.QuotationToLambdaExpression` throws `ArgumentException` on any `TupleGet` over a struct tuple – the shape of every struct destructuring – and `NotSupportedException` on member access to a struct anonymous record, while struct tuple construction converts *(verified in fsi, FSharp.Core 10.1.401)*; expressions that are also lowered that way keep the reference forms. +- **Module-level queries.** `CosmosQuery` values and results of `CosmosQuery.compile` are evaluated by the static initialiser of their file; a value that refers to a value declared later in the same file fails at initialisation and breaks every static value of that file. `CompiledQuery` therefore translates lazily on first use, and the documentation tells users to declare fragments before the queries that splice them. +- **Telemetry.** Parameterised query text contains no values. `Inline` print mode and `InlinePrimitiveLiterals` put values into the trace attribute `db.query.text`; both stay opt-in. + +### 20.11 Booleans, comparison functions, struct projections + +- `<>` on non-optional members → `(a != b)`; under `Strict` → `(NOT IS_DEFINED(a) OR (a != b))`. The translator never rewrites `<>` into `NOT (=)`: they differ on undefined. +- `a.Equals b`, `Object.Equals`, `IEquatable.Equals`, `String.Equals (a, b)` translate as `a = b`; `compare a b op 0`, `a.CompareTo b op 0`, `String.Compare`/`CompareOrdinal (a, b) op 0` are rewritten to `a op b`. For `string` operands `a.CompareTo b`, `String.Compare (a, b)` and `String.Compare (a, b, ignoreCase)` are culture-sensitive in .NET, while F# `compare` and the comparison operators are ordinal, so the former follow the culture policy of §20.6; `String.CompareOrdinal` and `String.Compare (a, b, StringComparison.Ordinal)` always translate. A static `op_*` comparison operator declared on any type is translated when the operand type's mapping is scalar (equality) and orderable (ordering). +- **Ordering fidelity.** `Guid` compares in Cosmos as its lower-case string, which differs from `Guid.CompareTo`; strings compare ordinally. `IsOrderable` is true for `Guid` only together with warning `CQ1018 OrderingFidelity`. +- **Struct tuples are the documented tuple forms** (maintainer decision 2026-09-27, §4): `select struct (a, b)` for positional projections (an anonymous struct record `select struct {| … |}` instead of a tuple where names are wanted); `groupBy (struct (p.City, p.Country)) into g` for a multi-member group key (`GROUP BY p["city"], p["country"]`; components read with `let struct (city, country) = g.Key`; a projected `g.Key` is materialised through `TupleEncoding`); `sortBy (struct (p.Name, p.Age))` for a tuple sort key (§9); struct tuples as the keyset `'Key` (§9a); `fun (struct (a, b)) -> …`, `let struct (a, b) = …` and `match … with struct (…)` for destructuring. The how-tos and `Reference/Construct_Mapping.md` show these first. +- **Reference forms stay accepted** because users write both, and a selector whose result type is generic (`Query.groupBy : Expr<'T -> 'K>`, a CE operation typed `'T -> 'R`) turns a plain `(a, b)` into a reference tuple *(verified in fsi)*. Both kinds arrive as the same `NewTuple`/`TupleGet` nodes, told apart only by `Expr.Type` *(verified in fsi)*, and translate to byte-equal SQL – the tuple-kind law of §7 with a golden twin per case (§13) – except where a tuple is materialised into JSON and `TupleEncoding` (§10) answers differently for the two CLR types (plain STJ without `IncludeFields`, a converter for one of them); there one twin is refused. Struct anonymous records and `[]` records translate exactly like their reference counterparts; member access on a struct anonymous record goes through `eraseStructReceivers` (§7). No API overloads a generic selector with a struct-typed one (`Expr<'T -> 'K>` next to `Expr<'T -> struct ('A * 'B)>`): a plain `(a, b)` silently picks the generic overload and `struct (a, b)` becomes ambiguous (FS0041) *(verified in fsi)*. +- Object keys are emitted in the target type's field order: declaration order for records and classes; for anonymous records of either kind the order of the compiled type, which sorts the field names ordinally (uppercase before lowercase: `{| b = 1; A = 2 |}` gives `A, b`, `{| a = 1; B = 2 |}` gives `B, a`), the order both serializers write *(verified in fsi)*; the source order survives only as partial evaluation-order `Let` hoisting, skipped for variable values *(verified in fsi)*. +- **Struct values the compiler rejects in quotations** *(verified in fsi, F# 10 / SDK 10.0.401)*. Member access on a captured local of a value type is compile error FS3155 ("A quotation may not involve an assignment to or taking the address of a captured local variable"): `args.MinAge` on a captured `struct {| … |}`, `s.X` on a captured `[]` record, and equally `v.IsSome`/`v.Value` on a captured `voption`, `d.Year` on a captured `DateTime`, `kv.Key`, `g.ToString ()`. Member access on a struct anonymous record stored in a record field (`p.Anon.A`) is FS0450 ("Quotations cannot contain expressions that take the address of a field"). Struct tuples are not affected: a captured struct tuple is destructured as `TupleGet (ValueWithName (t), i)`, and a method call on one goes through `copyOfStruct`. Where the documentation uses struct anonymous records or `[]` records, it shows them as lambda parameters (compiled-query arguments), quotation-local values and projection results, and tells users to read members of captured struct values outside the quotation (`let minAge = args.MinAge`), to rebind them inside it (`let a = args in a.MinAge`), or to use module functions (`ValueOption.isSome v`, `match v with ValueSome x -> …`); all of these compile. Reference anonymous records and `option` members are unaffected. + +### 20.12 Serializer facts read by the layout + +- **Optional guards** are narrowed to `AlwaysUndefined`/`AlwaysNull` only with `NarrowOptionalGuards = true` and, for `SkippableOptionFields.Always`, only when `deserializeNullAsNone` is false. The maintainer's containers hold documents written by more than one serializer configuration, and hand-written or legacy documents contain explicit nulls. +- `AllowNullFields = true` → advice `CQ1017 NullableFieldsAllowed`; `Semantics.NullByType` lets only legacy types pay for guards. +- `MapEncoding`: object → `m.[k]` is `c["m"][@k]` *(a parameter as indexer: verified on both emulators, #44)*, `ContainsKey` → `IS_DEFINED`; keys are rendered through the key converter's property-name form. +- `EnumEncoding` is `String` when a `JsonStringEnumConverter` applies. +- **Unknown converters.** The effective converter per type is resolved with `options.GetConverter` (first match wins, as in STJ). A converter not known to the layout makes the value *opaque*: whole-value comparison with a parameter is allowed, member access inside it is `LayoutRequired` with advice `CQ1016 CustomConverterUnknownShape`, unless a `TypeMapping`, a `StoredShape` or an override is registered. This also covers F# records claimed by a user converter registered before `JsonFSharpConverter`. +- Fields are serialised only with `IncludeFields` or `JsonInclude`. +- NodaTime values work as parameters through the user's converter; member calls need the optional plugin. + +### 20.13 Query scope and write-side helpers + +- **Scope.** `QueryScope = { PartitionKey; Filter per type }` is a plain value passed to the execution extensions or carried by a thin `ScopedContainer` wrapper, applied to every query unless the query calls `withPartitionKey` or `crossPartition`. It replaces the ambient tenant of the Marten sessions and serves soft-delete filters as well. Advice `CQ1013 CrossPartitionQuery` when no key can be derived. +- **Partition-key merge rule.** A partition key already present in the caller's `QueryRequestOptions` wins over lifted keys and `withPartitionKey` hints; lifting with hierarchical paths yields a full key when all levels are equality-constrained and a prefix key when only the leading levels are. +- **Ids and partition keys.** A Cosmos `id` is a JSON string: numeric and wrapper ids are converted to the string the serializer writes; partition-key values must map to string, number, bool or null. +- **Write-side helpers** are client-side composites, not write statements: `Translator.PatchPath : Expr<'T -> 'V> -> string` resolves a member chain into a JSON Pointer (`/address/city`) – the application hand-rolls this today; `Patch.set`/`add`/`replace`/`remove`/`increment` take the selector quotation; `DeleteWhereAsync` and `PatchWhereAsync` query ids, partition keys and `_etag` into a `[]` row record with fixed JSON names (a struct tuple row would depend on `TupleEncoding` – a JSON array under FSharp.SystemTextJson, `{"Item1": …}` under plain STJ only with `IncludeFields`, and no readable form otherwise, because plain STJ throws on an array and fills defaults from an object *(verified)*), then write per item or per `TransactionalBatch`, documented as not atomic across partitions; their result is an anonymous struct record. +- A user-facing in-memory runner `CosmosQuery.runInMemory` in the `.Testing` package replaces the dual code path the Marten paging code needs for in-memory providers. + +### 20.14 Reuse of the Marten test corpus + +- **Port shapes, not assertions.** The quotation bodies are copied verbatim; every PostgreSQL expectation and every weak assertion (`sql.Length > 0`, substring checks) is discarded. Expected output is regenerated as full-text golden baselines. +- The document model of the Marten layer's test suite, together with the two document types declared in individual test files – the only documents with a space-identifier member (stand-in `SpaceId`) or a tenant-identifier member (stand-in `TenantKey`) – is recreated in the test model project (§22.6) from the Marten layer's own definitions and usages, with neutral names (maintainer decision 2026-09-27, §18 item 10); the maintainer supplies nothing else. What the layer and its test suite contain *(verified by reading them)*: + - The approval-status union (stand-in `ReviewStatus`) is not external: the test suite defines it (`Pending | Approved | Rejected`), so it is copied as is under the neutral name. + - The Guid wrapper, the string wrapper, the space identifier and the tenant identifier (stand-ins `TypedGuid<'T>`, `TypedString<'T>`, `SpaceId` and `TenantKey`) are defined in neither the layer nor its test suite; both open domain namespaces of the application that come from outside through two project references to the application's domain projects. The stand-ins are inferred from how the layer and its tests use them: + - `TypedGuid<'T>`: a generic single-case union over `Guid` with a non-public case constructor `NewId` (case `Id`) and a `Value` property, module functions `TypedGuid.newGuid`, `TypedGuid.value` and `TypedGuid.toString`; `type UserId = TypedGuid`. + - `TypedString<'T>`: the same shape over `string` with a case named like the type, constructed directly as `TypedString s` and read with `TypedString.value`; `type DocumentNumber = TypedString`. + - `SpaceId`: a `TypedGuid<_>` instantiation (its values come from `TypedGuid.newGuid ()` and share an array with `SpaceId.default`) with a sentinel `SpaceId.default` tested by `SpaceId.isDefault`, stored by a custom converter of the production serializer, defined outside the layer, in a legacy union shape – `{"Case":"DefaultSpaceId"}`, `{"Case":"SpaceId","Fields":{"Item":""}}` under `NamedFields`, `{"Case":"SpaceId","Fields":""}` otherwise. The corpus store itself registers only `JsonFSharpConverter` and its space-identifier tests assert SQL text only, so that shape is known from the production configuration, not from the corpus. The stand-in recreates that converter, so it is an opaque converter-backed type (§20.12); the sentinel's GUID is not visible *(unverified; `Guid.Empty` assumed)*. + - `TenantKey`: `Default | Custom of TypedString<_>`, stored as `{"Case":"Default"}`, `{"Case":"Custom","Fields":{"Item":"S2"}}` or `{"Case":"Custom","Fields":"S2"}`; the phantom type of its `TypedString` is not visible. + - Two union-encoding constants of the application's JSON module, defined outside the layer – one for serialization, one for the web – configure the layer's serializers; the corpus store uses the serialization constant with `allowNullFields = false`. Their values are not in the layer and are inferred from the JSON the code documents or expects: adjacent tag `Case` with `Fields`, fieldless cases kept as objects (`{"Status":{"Case":"Pending"}}`), single-case wrappers stored unwrapped (the containment the corpus expects for a `UserId` is a bare GUID, `"ApproverUserId":""`), `NamedFields` in the serialization constant, `UnwrapSingleFieldCases` in the web constant (the layer's remark that the tenant value is "a plain JSON string" contradicts the `{"Case":"Custom","Fields":"S2"}` documented next to it and is not followed), and unwrapped options, because `Option.get d.Name = "Unknown"` is expected as `CAST(d.data ->> 'Name' as varchar) = 'Unknown'`. Stand-ins: serialization = `AdjacentTag ||| NamedFields ||| UnwrapSingleCaseUnions ||| UnwrapOption`, web = `AdjacentTag ||| UnwrapSingleFieldCases ||| UnwrapSingleCaseUnions ||| UnwrapOption`. Under FSharp.SystemTextJson 1.4.36 they reproduce every documented union shape, and without `UnwrapOption` a `Some` value would be stored as a `{"Case":"Some",…}` object *(verified in fsi)*; flags that none of these documents reveals are *(unverified)*. + - Neutral names *(proposed)*: `TypedGuid`, `TypedString`, `TenantKey`, `ReviewStatus` and `SpaceId` (stored cases `DefaultSpaceId`/`SpaceId`) stand in for the application's types; the application's namespaces become the test model's. Captured values the Marten tests create with `TypedGuid.newGuid ()` become fixed literals or seeded samples (§22.6), because `.params.json` baselines must be stable. +- One corpus case per Marten test with a stable id `marten//`. Where the suite has a "LINQ" and a "Quotation with pipe" variant, both are kept and asserted as a normalizer law (byte-equal SQL with a fixed `RootAlias`). +- The 17 ignored tests (Option.map, Option.get and Option.defaultValue in SELECT, `defaultValue` as method receiver) become required cases. +- Every corpus predicate also runs through the grammar, the interpreter and the emulator over a dataset that contains the discriminating documents: member missing, explicit null, empty array, missing array, arrays with mixed cases. The dataset is a scenario of §22.6: its `TestData` names every document a case asserts on (`Known…` fields), and documents the serializer cannot write under the layout (an explicit `null` where the layout omits, wrong types) are written as streams. +- Value variants (the three space-identifier tests, empty collections, every captured option bound once as `Some` and once as `None`) become one quotation with several bindings that assert identical SQL and a plan-cache hit. +- Holes of the corpus are filled generatively: `forall`, `isEmpty`/`length`, `Set`, `ImmutableList`, `ResizeArray`, ordering operators, Skippable, Nullable, `match` on options, sort or group by optional members, paging. +- Application names are neutralised if the corpus is committed to the public repository. + +### 20.15 Warning codes + +| Code | Meaning | +|---|---| +| `CQ1001 FullScanFunction` | `LOWER`/`UPPER` and similar in a predicate | +| `CQ1002 SortOnOptionalField` | withdrawn: `ORDER BY` keeps the items without the sort value and sorts them where F# sorts `None` (§9a) | +| `CQ1003 DateTimeOffsetWithOffset` | non-zero offset breaks lexicographic order | +| `CQ1004 DecimalPrecision` · `CQ1005 Int64Precision` | numeric fidelity | +| `CQ1006 UndefinedInProjection` | `SELECT VALUE ` omits the item | +| `CQ1007 NondeterministicTop` | `take` without `sortBy` | +| `CQ1008 NativeGeometrySemantics` | library-style geometry members | +| `CQ1009 LayoutNotConfigured` | defaults assumed for optional or union members | +| `CQ1010 LocalDateTime` | `DateTime` with `Kind` Local or Unspecified | +| `CQ1011 DefaultIgnoredInOrderBy` | unwrapped optional sort key | +| `CQ1012 UnguardedUnwrap` | `.Value`/`Option.get` precondition hoisted into WHERE | +| `CQ1013 CrossPartitionQuery` | no partition key derivable | +| `CQ1014 CultureIgnored` | culture-sensitive comparison translated as its ordinal counterpart (`Semantics.CultureComparison = AsOrdinal`) | +| `CQ1015 MixedEncodingScan` | legacy union read encodings | +| `CQ1016 CustomConverterUnknownShape` | opaque converter-backed type | +| `CQ1017 NullableFieldsAllowed` | `AllowNullFields = true` | +| `CQ1018 OrderingFidelity` | Guid or string ordering differs from .NET | +| `CQ1019 ObjectEqualityScan` | withdrawn: equality with an object-valued parameter is an index seek (§16) | +| `CQ1020 SkipWithoutTake` | `OFFSET @m LIMIT 2147483647` | +| `CQ1021 OptionalOrdering` | ordering between optional values excludes absent values, unlike F# (§20.3) | +| `CQ1022 CaseFoldingFidelity` | the ignore-case flag folds some non-ASCII letters otherwise than .NET `OrdinalIgnoreCase`, and differently on each emulator (§20.6) | + +### 20.16 Lessons from the Marten layer that the design respects + +Never degrade silently and never swallow failures (`try/with` plus `Debug.Assert`, `Debugger.Break` in library code). Translation is position-independent: one scalar translator for every clause, with a test per context. Optional values are composable pairs, not per-comparison rewrites (the Marten layer needs 36 marker methods and hand-codes nesting for exactly one and two levels). Guards of `exists`-style functions stay inside a negation, preconditions of partial unwraps stay outside. SQL text never depends on run-time values. Members are matched by exact identity while whole overload families are registered. Normalisation happens once, at quotation level, in one bounded fixpoint that revisits children. Absent values are never mapped to `default(inner)`. Layout facts come from one layout service derived from the real serializer options, never from flags passed by hand. Process-wide caches that ignore configuration are avoided. Element types come from static types. Null ordering and paging rules are re-derived for Cosmos, not copied from PostgreSQL. All entry points share one normalizer. Weak tests hide wrong translations. + +## 21. Requirements from the maintainer's Cosmos application + +Source: the production persistence code and the test projects of the maintainer's Cosmos DB application (below: the application). The application queries Cosmos through the SDK LINQ provider with quotations lowered by `LeafExpressionConverter`, plus a few raw `QueryDefinition` strings, and consumes `FSharp.Azure.Cosmos` 1.0.1 from NuGet. It is the first real consumer of the new package, so its query shapes are requirements. + +### 21.1 Type discriminators for heterogeneous containers + +- Documents of several types share a container and are distinguished by System.Text.Json polymorphism: `JsonDerivedType (derivedType, discriminator)` on abstract bases, JSON property `$type`. Values are versioned, `+v` (`order+v1`, `vehicle-electric-car+v1`). Queries never compare the full value: they test a **prefix without the version** (`order+`) or a family prefix (`vehicle-electric`, `vehicle`), so every schema version and every family member matches. The trailing `+` separates `item+` from `item-group+`. +- Today every persisted type carries a hand-written static discriminator expression, and bases enumerate an OR-chain of `StartsWith` calls by hand. +- **Design.** `IJsonLayout.Polymorphism` reads the discriminator property name and the registered `(derived type, discriminator)` pairs from `JsonPolymorphic`/`JsonDerivedType` (attribute subclasses included) and from FSharp.SystemTextJson internal tags. `DiscriminatorOptions.Match = Exact | Prefix of separator : string` (the application uses `Prefix "+"`). `items<'T>` for a type in a polymorphic hierarchy adds the filter for `'T` and its registered descendants, collapsing to the shortest common prefixes: `STARTSWITH(c["$type"], "order+")`, or an OR of prefixes for a base type. `:? Derived` inside a predicate, `Query.ofType<'Derived>` and `Predicate.ofDerived` lower to the same test. The filter is overridable per type (`DiscriminatorOptions.Overrides : Type -> Expr`), because lookup documents filter on a type property of their own, not on `$type`. +- **Automatic filter behind a translator option** (maintainer decision 2026-09-27). `DiscriminatorOptions.AutoFilter : bool`, default `false`. When `true`, `items<'T>` and `Query.source<'T>` add the filter for every type of a polymorphic hierarchy, and `Query.withoutTypeFilter` opts one query out. When `false`, the filter appears only where it is asked for: `Query.ofType`, a `:?` test, `Predicate.ofDerived`. The option is part of the translator, so it is part of the plan-cache identity, and `Translator.Explain` shows whether a filter was added and for which types. +- **Details.** The application has no `[]` attribute; `$type` is the System.Text.Json default name. `JsonDerivedType` attributes are repeated on intermediate bases and on the derived class itself, so discovery de-duplicates. A base type yields the OR of the per-type prefixes of its registered descendants; prefixes are not collapsed to a common prefix automatically, because a shorter prefix may match a type outside the hierarchy (`item+` against `item-group+`); family prefixes such as `vehicle-electric` are declared through `Overrides`. Both prefix tests and exact equality on the discriminator are required shapes. Discriminator constants are `[]` values, so they arrive in quotations as constants and are inlined; golden cases fix that form. Old schema versions stay in containers, so test data holds documents of two versions under one prefix, and the polymorphism rung has an accept list and a reject list per type. +- `c["$type"]` needs bracket notation, which the printer always uses. +- Test rung: for a hierarchy with `JsonDerivedType` registrations, the generated filter of every base type accepts every registered discriminator under it and rejects the others, driven by reflection, so a newly registered type cannot be silently missed. + +### 21.2 Members that the serializer ignores but queries use + +- The application marks query-only members `[]`: the discriminator (`[]`), normalised members that are container computed properties (`EmailNormalized`, `NameNormalized`, `PrefixNormalized`), and wrapper properties over nullable backing fields (`token.Subject` over `val mutable subject : string | null`). Its own `SerializeMemberName` ignores `JsonIgnore` completely. +- **Design** (maintainer decision 2026-09-27). Addressable in a query is not the same as written by the serializer. A member is addressable when it carries `JsonPropertyName`, has `JsonIgnore(Condition = WhenWriting | WhenWritingNull | WhenWritingDefault)`, is mapped through `[]` or `TranslatorOptions.MemberPaths`, or is a registered computed property. A member with a plain `[]` and none of these is the error `MemberNotSerialized`, whose hint names the two ways to declare the path. This protects against queries over a property that is not in the document, which return nothing without any error. +- **Migration cost for the application.** Its most frequent wrapper members (`token.Subject`, `FirstName`, `LastName`, `Payload`, `ReferenceId`) are unconditional `JsonIgnore` wrappers without `JsonPropertyName`; they work today only because the policy name of the wrapper equals the policy name of the backing field. Each needs one `[]` attribute or one `MemberPaths` entry. +- **Computed properties** apply in `WHERE`, `SELECT` and `ORDER BY`: a model member that *is* the computed property translates to its path, and `p.Name.ToLower ()` may be rewritten to `c["name_normalized"]` when `ComputedProperties` maps the expression, which also removes `CQ1001`. +- **Wrapper getters change meaning.** `Subject` returns `""` when the stored member is absent, so F# `token.Subject = ""` is true for a document without `subject` while SQL `c["subject"] = ""` is not. `TranslatorOptions.MemberDefaults` (or `[]`) marks a non-optional member as possibly absent with a default, translated as `(IS_DEFINED(p) ? p : @default)`. The same switch covers optional-with-default members that are not option-typed (empty string, `0`, enum default). +- Member-level `JsonIgnore(Condition = WhenWritingNull | WhenWritingDefault)` on class properties and fields decides the absence encoding of that member, next to `SkippableOptionFields`. + +### 21.3 Document and naming shapes + +- Document types are **classes**: constructor parameters for required values, settable auto-properties for optional ones, public or internal mutable fields with `JsonInclude`, abstract and overridden properties, inheritance. Member-name resolution looks at the declaring type of an inherited or overridden property. Projection into a class (`NewObject` followed by property setters) is defined like a record projection. +- Naming policy is `SnakeCaseLower` for Cosmos, set after a shared configuration that sets camelCase: names are resolved through `options.PropertyNamingPolicy.ConvertName` of the **final** options instance, never a hard-coded routine. `JsonPropertyName` takes precedence. +- A `JsonExtensionData` dictionary holds unknown properties: `e.Properties["k"]` translates to the **root** path `e["k"]`, not `e["properties"]["k"]`. +- The production `JsonFSharpOptions` are `InternalTag | AllowUnorderedTag | NamedFields | UnwrapSingleCaseUnions | UnwrapRecordCases | UnwrapOption | UnwrapFieldlessTags` with tag name `$type`, `SkippableOptionFields.Always` with `deserializeNullAsNone`, `MapFormat.Object`, string enums. This exact combination is a named, mandatory variant of the test matrix ("maintainer production profile"). +- Converter-backed types (id wrappers serialised as string or number, sets as arrays, `Claim` as object, `Uri`, `MailAddress`) are opaque scalars (§20.12). +- Map members are JSON objects: `user.ScopeRoles.Values.Contains roleId`, `Map.exists`, `Map.containsKey` lower through `ObjectToArray` into `EXISTS(SELECT VALUE kv FROM kv IN (SELECT VALUE ObjectToArray(c["m"])) WHERE (kv["v"] = @p))`, and `ARRAY_CONTAINS(ObjectToArray(c["m"]), {"v": 2}, true)` finds a value as well: the source of `IN` must be a path or a subquery, so the direct form `kv IN ObjectToArray(c["m"])` is a syntax error (SC1001) *(verified on both emulators, #44)*. The application uses a UDF for this today with the comment "TODO: Use LINQ". The pair forms reach the translator as forced reference tuples (§4) and read the same element: `Map.toSeq`/`Map.toList` elements destructured with `fun (k, v) -> …` (`TupleGet (tupledArg, i)`) and the `KeyValue` pattern (`TupleGet (Call (KeyValuePattern, [kv]), i)`) *(verified in fsi)* lower like `kv.Key`/`kv.Value` to `kv["k"]`/`kv["v"]`. +- STJ writes `DateTimeOffset` with `+00:00` and `DateTime (Utc)` with `Z` and drops trailing zero fractions, so the two types do not give comparable strings. F# comparison operators need equal operand types, so such a predicate cannot be written with operators; this stays a Phase 0 probe and a documentation note. +- Partition-key members carry `[]`; the same name declared twice in a hierarchy throws in System.Text.Json, so the layout resolves names on overridden members through the declaring type. +- Members typed `string | null` raise nullness warning 3261 inside quoted lambdas; the application brackets such quotations with `#nowarn 3261`/`#warnon 3261`. The library's samples and tests follow the repository rule (smallest possible scope, never file-wide). + +### 21.4 Query shapes in use + +Equality and AND chains on strings, Guids and wrappers; `<>` against union-case literals; `OR`; `DateTime <`; optional equality with `ValueNone`/`ValueSome`; `Equals (x, OrdinalIgnoreCase)`; `StartsWith`; nested paths; enum equality; collection `Contains` in four forms (member array contains captured scalar, captured sequence contains member, member array of non-primitive elements, third-party set type); UDF invocation; scalar and multi-member projections (the latter delegated to C# helpers today because of dotnet/fsharp#3782, which a quotation translator removes); single and multi-key ordering including `_ts` and computed properties; `CountAsync` and raw `SELECT VALUE COUNT(1)`; conditional predicate composition with `ValueOption.foldBack` chains of `Where`; hierarchical partition keys with prefix keys and explicit null or none components, always passed through request options; nested query wrapping `SELECT VALUE root FROM () AS root `, which makes the FROM-subquery probe a requirement of a real consumer. + +The raw SQL strings of the application (`SELECT VALUE COUNT(1) FROM a WHERE STARTSWITH(a["$type"], @Type)`, `SELECT * FROM c where udf.IsUserInRole(c, @roleId)` and others) join the result-equivalence list of Phase 1 and 2. + +### 21.5 Coexistence with the SDK LINQ provider + +Three OpenIddict store methods accept caller-supplied `Func, IQueryable>` because the OpenIddict interfaces require it. They cannot move to quotations, so the application keeps the SDK LINQ provider for them and the existing `IQueryable` helpers of `FSharp.Azure.Cosmos` stay. + +### 21.6 No golden SQL exists in the application + +None of the 16 LINQ test methods asserts SQL text: each writes `QueryText` to the test log, and most have no assertion at all. There are no expected strings to reuse. If application shapes are wanted as reference output, a Phase 0 task runs those tests and captures the logged text; it is reference for result equivalence only, since the SDK inlines captured values and uses the alias `root`. + +### 21.7 Package versions to align with + +| Package | Application | This repository today | +|---|---|---| +| MSTest / MSTest.TestFramework | 4.3.2 | 4.2.3 | +| Microsoft.NET.Test.Sdk | 18.7.0 | 18.5.1 | +| Hedgehog | 2.0.4 | not referenced | +| Unquote | 7.0.1 | 7.0.1 | +| CompareNETObjects | 4.84.0 | not referenced | +| Microsoft.Azure.Cosmos | 3.62.0 | 3.60.0 | +| FSharp.SystemTextJson | 1.4.36 | not referenced | +| IcedTasks | 0.11.9 | not referenced | +| FsToolkit.ErrorHandling | 5.2.0 | 5.0.0 | +| FSharp.Core | 10.1.302 | 10.0.100 | +| System.Linq.Async | 7.0.1 | 6.0.0 | +| FSharp.Control.TaskSeq | 1.1.1 | 1.0.0 – **not bumped**: 1.1.1 silently stops after the first await in Debug builds on SDK 10.0.400+ | + +The new projects add Hedgehog 2.0.4 (TypeShape 9.0.0 transitively), CompareNETObjects 4.84.0 and an `MSTest.TestFramework` entry at the `MSTest` version, all test-only, and FSharp.SystemTextJson 1.4.36 and IcedTasks 0.11.9, both core dependencies, to `Directory.Packages.props` at the application's versions (§5). No Hedgehog.Experimental. The Hedgehog adapter prototype gave the same outcomes on MSTest 4.2.3 and 4.3.2 – 51 results with the same passes and failures, the same lifecycle counts and the same recheck data for a fixed seed *(verified)* – so aligning MSTest with the application needs no adapter change. The one difference: after a cooperative timeout the invocation's `TestResult.Outcome` is `Failed` on 4.2.3 and `Timeout` on 4.3.2 *(verified)*, which the adapter does not depend on because it stops on the cancelled token (§22.6). + +## 22. Database test architecture (the maintainer's conventions) + +The application's conventions are adopted where they apply to a library and adapted where a library with thousands of translation cases needs something else. Since #39 the shared project `tests/Cosmos.Tests.Infrastructure` holds the pattern with the partition budget (§22.4), name sanitising and the class-level identifier (§22.3), and the leftover sweep by age; the STJ serializer configuration and deep comparison (§22.5, §22.7) come with the first test project that needs them. + +### 22.1 Projects and runner + +- Test projects are executables on the MSTest runner: `OutputType=Exe`, `EnableMSTestRunner=true`, `IsTestProject=true`, `IsPackable=false`, versions from central package management, `net10.0`. +- Helper libraries (`Cosmos.Tests.Infrastructure`, `Cosmos.Quotations.Testing`, `Cosmos.Sql.Hedgehog`, `Cosmos.Quotations.Tests.Models.Hedgehog`, the adapter `Hedgehog.MSTest`; the test model `Cosmos.Quotations.Tests.Models` needs no test framework at all) reference `MSTest.TestFramework` only, not the `MSTest` metapackage, so they are never runnable test applications. One test executable is never referenced from another. +- Test executables with Hedgehog properties (`Cosmos.Sql.Tests`, `Cosmos.Quotations.Tests`, the adapter's suite) reference `Hedgehog.MSTest`. MSTest 4 discovers only non-static methods returning exactly `void`, `Task` or `ValueTask` *(verified)*: one `[]` returning `bool`, `Task`, `Task` or `Property`, or one static `[]`, makes discovery of the **whole** assembly fail with UTA007 *(verified)*, because `TreatDiscoveryWarningsAsErrors` defaults to true (`MSTestSettings.cs:54`, read in the source), and the F# build gives no warning *(verified)*; `Result` and `Async` returns fail the same return-type check (`MethodInfoExtensions.cs:92-96`, read in the source, not run). The test application then ends with an unhandled `AggregateException`, exit code -532462766 (0xE0434352), which a shell keeping eight bits shows as 82 – not a Microsoft.Testing.Platform exit code *(verified with the executable and with `dotnet test`)*. Generic test methods pass discovery although the `TestMethodAttribute` XML docs require non-generic ones (§22.6). CI therefore runs `--list-tests` on every test executable before the test run. +- Every test project ships `testconfig.json` with method-level parallelism and `workers: 0`, copied to the output directory. Classes that share mutable state opt out with `[]`. +- When a category filter is applied across several test projects, the run passes `--ignore-exit-code 8`, otherwise a project with zero matching tests fails the lane. + +### 22.2 Base classes and categories + +- `TestBase` owns `TestContext` and exposes `CancellationToken` from `TestContext.CancellationTokenSource.Token`. +- The emulator base carries the category `"Cosmos DB Emulator"` through a `TestCategoryBaseAttribute` subclass (the maintainer's standard filter string), so every derived class inherits it. Finer categories of §13 are added next to it. +- **Translation and golden tests stay off the emulator base class**, so they run on Windows and macOS jobs without an emulator. In the application even pure translation tests inherit the emulator base, which excludes them from emulator-free lanes. +- Two database lifetimes. *Per test* (write tests): created in `[]`, deleted in `[]`; never the home of a `[]` method, because MSTest runs the constructor, `[]` and `[]` for every generated case and every shrink step (§22.6); property tests only read class-scoped datasets, and round trips stay hand-written slim/fat tests with per-test databases (maintainer decision 2026-09-28, §2). *Per class* (read-only conformance suites): a generic `ReadOnlyIntegrationTestBase<'Factory>` with `[]`/`[]`, fixtures stored in a `ConcurrentDictionary` keyed by `FullyQualifiedTestClassName`, the factory created through a static abstract `Create` member. A single static slot on a generic base is shared by every class deriving from the same closed generic type; the application documents that defect. +- The library test projects do not use Microsoft.Extensions.Hosting or DI: there is no application to host. The equivalent extension points are abstract members of the emulator factory (serializer options, container definitions, plugins). +- The infrastructure's base factory is `DatabaseTestFactory`, created through the static abstract `Create` of `IDatabaseTestFactory<'Factory>` *(names proposed)*, the counterparts of the application's database test factory and its generic factory interface *(verified)*. It replaces the repository's own `DatabaseTestApplicationFactory` (`tests/Cosmos.Tests.Infrastructure/IntegrationInfrastructure.fs:47`, moved there by #39), which the per-test base creates through the abstract instance member `CreateApplication` and whose `SeedDataAsync` takes the cancellation token as a parameter (`:200-201`) *(verified)*. Its abstract members are the extension points above and `SeedDataAsync ()`, which does nothing unless a scenario overrides it (the application's `abstract member SeedDataAsync : unit -> Task` with `default _.SeedDataAsync () = Task.CompletedTask`) and takes the token from the factory's `TestContext`, as the application's scenarios do (§22.4). The two lifetimes above create it in `[]` and in `[]`, as the application's per-test and read-only integration test bases do *(verified)*. Scenario factories derive from it, implement `Create` and live next to their tests (§22.6). +- A logical suite is split over several files by an abstract base holding shared data plus one concrete `[]` per file, named `Tests..fs`. Test methods, `[]` ones included, belong to the concrete classes: MSTest 4.2.3 runs a test method declared in the abstract base once per derived `[]` *(verified with an inherited `[]` and two derived classes)*, which only the per-layout-variant classes of §13 rung 6 want. +- Parallel scope follows the fixture lifetime: `method` for projects whose class fixtures are read-only after initialisation, `class` when a class fixture is mutated (the application's host-based integration project uses `class`). +- MSTest allows one `[]` per assembly, so the capability probe and the sweep of leftover databases share one method. +- Category attribute classes replace string constants, as `tests/Cosmos.Tests/TestCategories.fs` already states; the application keeps separate constants and that habit is not copied. The attributes of a `TestCategories.fs` are sorted by type name, and a new one goes to its place in that order (maintainer request in the reviews of #42 and #44, 2026-10-10; #51, with an instruction scoped to these files for GitHub Copilot and Claude Code). +- Test projects reference the infrastructure project through `tests/Directory.Build.props`, with a condition that excludes the helper projects themselves, the test model `Cosmos.Quotations.Tests.Models`, which needs no test framework (§22.1), and the two adapter projects `Hedgehog.MSTest` and `Hedgehog.MSTest.Tests`, which stay free of the Cosmos infrastructure because they are also proposed upstream (§5). + +### 22.3 Database identifier + +The application's algorithm replaces the repository's current one: the test name is read from `TestContext.Properties["TestName"]` and falls back to `FullyQualifiedTestClassName` in class-level contexts (where reading `TestName` throws); invalid characters are replaced by `_`; the name is truncated to 80 characters; data-driven tests get a hash suffix. Corrections to the application's version: + +- sanitise against an explicit set that includes the characters Cosmos forbids in ids (`/`, `\`, `?`, `#`), because `Path.GetInvalidFileNameChars` differs per platform and does not contain `#`; +- include a short hash of the class name, because two classes with an equally named method collide; +- compute the data-row hash with a stable hash function, because `String.GetHashCode` is randomised per process and leftovers of an aborted run are then never reused or swept; +- add a common prefix so an `[]` sweep can delete leftovers by name (the application gets its prefix from the configured database name, to which the identifier is appended); the prefix is `fsac-test-`, and the name alone never marks a database as a leftover, because a concurrent run on the same emulator uses the same prefix: the sweep also requires an age (§22.4); +- truncation must keep the distinguishing part: with a prefix in front, the method name sits at the end, so the name is shortened in the middle or a stable hash of the full name is appended. + +### 22.4 Emulator budget, creation and cleanup + +- A process-wide semaphore guards the emulator's partition budget; a test acquires one permit per container it creates. The limit comes from the environment variable `COSMOS_EMULATOR_PARTITION_COUNT` (default 25, the Windows emulator's default): the application hard-codes 250 while its pipeline starts the emulator with `-PartitionCount 50`. On the Windows emulator one container costs one partition and a database costs none *(verified in Phase 0)*: the emulator refused the 20th container with 503, sub-status 1007, whether the containers sat in 1, 3 or 40 databases. What a container costs on the vNext emulator is still a probe. +- An emulator may hold fewer containers than the default budget: one Windows emulator held 19 *(verified)*. The existing suite still passes there at 32 workers, because creation is serialised (below); machines with a smaller emulator set `COSMOS_EMULATOR_PARTITION_COUNT` (whether the default should drop below 25 is open, §18 item 14). +- The budget and the leftover sweep are per process: two test runs on one emulator share its partitions but not the semaphore. The sweep runs at assembly start and end and deletes only databases with the `fsac-test-` prefix (§22.3) that stayed unmodified for at least an hour (`COSMOS_TEST_LEFTOVER_AGE_MINUTES`), so concurrent runs on one emulator do not delete each other's live databases. +- Permits of one test are acquired **atomically**: acquiring them one at a time can deadlock when several tests each hold part of their permits. The fixture records how many permits it really took and releases exactly that number; the application releases unconditionally, which over-releases the semaphore when initialisation fails early. +- Database and container creation is serialised across parallel tests by a second semaphore, with the wait placed before the `try` so a failed wait cannot release a permit it never took. +- Cleanup is best-effort: a failed deletion is written to `TestContext` and never fails the test; permits are released in `finally`; cleanup tolerates a partially initialised fixture. +- Setup, seeding and cleanup pass the cancellation token, so a hung emulator does not block until the request timeout. +- Databases are created without throughput, as the repository does today. + +### 22.5 Containers, client and serializer + +- Container definitions are data: `ContainerDefinition = { Name; PartitionKeyPaths; UniqueKeys; ComputedProperties; CompositeIndexes; IncludedPaths; DefaultTtl; Udfs; VectorPolicy; FullTextPolicy }` and one creation routine. Test datasets include a container with a two-level hierarchical partition key and a polymorphic class hierarchy with `JsonDerivedType` discriminators, mirroring the application. +- The client keeps the repository's settings (Gateway mode, HTTP client restricted to local hosts), which the Linux vNext emulator needs; endpoint and key are read from `COSMOS_EMULATOR_ENDPOINT` and `COSMOS_EMULATOR_KEY` when set, so the same tests run against the Windows emulator lane. That lane is weekly and non-blocking from Phase 0, because the features the application queries (UDFs, computed properties, composite indexes) are absent or no-ops on the vNext emulator. The application's tests run only on the Windows desktop emulator; the library's default lane is the Linux vNext emulator. +- The serializer of the conformance suites is STJ + FSharp.SystemTextJson. The layout matrix contains the library defaults, the maintainer production profile (§21.3) and the variants of §13; for the tuple assertions the layout conformance rung adds the plain-STJ layout (`JsonLayout.systemTextJson`) with `IncludeFields` off and on. One probe client is built with a custom `CosmosLinqSerializer` that implements `IJsonSerializerOptionsProvider`. +- **Layout conformance rung** (new in §13): for each supported variant, slim and fat instances of the target types (`SlimGen`/`FatGen` of §22.6, drawn with the seeded `Sample` members) are serialised with the real serializer, parsed to `JsonNode`, and every path and encoding the `IJsonLayout` reports is asserted against the produced JSON. The application's serialization tests assert only deserialisation, run with options that differ from the Cosmos client's, and therefore do not pin the stored layout. The rung also pins the absence encodings the test model relies on *(verified with FSharp.SystemTextJson 1.4.36)*: on a **class** property `Skip` is written as `null` – with plain `JsonFSharpConverter ()`, with `DefaultIgnoreCondition = WhenWritingNull` and with `JsonFSharpOptions.Default ().ToJsonSerializerOptions ()` – and so is `ValueNone` under the plain converter; a `Skippable<'T>` property is omitted only when it carries `[]`, while `WhenWritingNull` on it throws `InvalidOperationException` because `Skippable` is a struct; a **record** field holding `Skip` is omitted without any attribute. The model has skippable members in both forms (§22.6), so the rung fixes per member whether a skipped value is missing (`IS_DEFINED` false) or `null` in the stored document. Tuples are asserted for both kinds at arities 2, 3 and 8: `[1,"a"]` under FSharp.SystemTextJson, the production profile included; for the plain-STJ layout `{"Item1":…}` for reference tuples and `{}` for struct tuples with `IncludeFields` off, `{"Item1":…}` for both with it on; a struct-tuple projection that the layout reports as not serialised must be refused, never round-trip to default values. + +### 22.6 Test data + +The maintainer's generator and seeding conventions (below: the generator conventions and the seeding conventions, as of 2026-09-24) are authoritative for generators and scenarios and are applied here rule by rule, except where the maintainer's answers of 2026-09-28 override them: the variants Slim, Fat and Default (GenConfig records) and property execution through the MSTest adapter (Property execution). Project, namespace, type and container names are proposals; departures from the conventions and additions that neither the conventions nor the application have are marked. + +**Projects, files and namespaces** *(names proposed)* + +- Generator projects – two, the maintainer's answer of 2026-09-28 – follow the application's `.Hedgehog` convention and its two layers (one library for helpers and shared leaf generators, one for the GenConfigs of the persistence model): `tests/Cosmos.Sql.Hedgehog` holds the helper modules and the spec-model generators, `tests/Cosmos.Quotations.Tests.Models.Hedgehog` the generators of the test model in `tests/Cosmos.Quotations.Tests.Models` (§5). Both are libraries with `GenerateDocumentationFile`, reference Hedgehog 2.0.4 and, of the test packages, only `MSTest.TestFramework`, and share the namespace root `FSharp.Azure.Cosmos.Tests.Models`, mirroring the application's own `.Tests.Models` root. Generators local to one test project follow the same pattern in that project's generators module, as the generator conventions require and the application does. +- One helper module per file, a top-level `[] module FSharp.Azure.Cosmos.Tests.Models.` named like the file, no `[]`, in compile order: `Sample.fs` (the application's counterpart holds a primitives module with the Bogus `faker`, then `module Sample`; without Bogus the file takes the name of its one module), `Gen.fs`, `CollectionGen.fs` (`immutableArray`, `immutableHashSet`, `immutableDictionary`, `immutableSortedSet range`, `set`, `map`, named after the BCL and F# types, with the application's ranges 0–5 and 0–3, each with a balanced emptiness twin `Balanced` for Default (GenConfig records); its `flatList`/`hashSet`/`hashMap`/`sortedSet` are named after aliases of FSharp.Collections.Immutable, which the library does not reference), `TextGen.fs`, `DateOnlyGen.fs` plus the additions `DateTimeOffsetGen.fs`/`DateTimeGen.fs` for the model's other date members (the temporal-scope type that the generator conventions list and `DateOnlyGen.byScope`, neither called by the application, are not ported), `NumberGen.fs` (members named per purpose, ranges from the model's limits; the application's domain or TODO generators `square`/`double` are not copied). +- Generator files open `Hedgehog` (`Gen<_>`, `Range<_>`, `Seed`, `PropertyConfig`, `Report`) and `Hedgehog.FSharp` (modules `Gen`, `Range`, `Property`, builders `gen`/`property`), like every application helper file, never the C# API `Hedgehog.Linq`; with `Hedgehog` alone `Gen.constant` does not compile, with `Hedgehog.FSharp` alone `Gen<_>` and `Seed` do not *(verified)*. Test and scenario files open `Hedgehog.FSharp` and the config namespace, as the generator conventions require, `Hedgehog.MSTest` when they declare properties, plus `Hedgehog` when they name `Gen<_>`, `Seed`, `IAutoGenConfig`, the `AutoGenConfig` module (`AutoGenConfig.defaults` does not resolve with `Hedgehog.FSharp` alone *(verified)*) or the `tests`/`shrinks` measures of `[)>]`; `PropertyConfig` and `Report` stay inside the adapter. The project's `Gen` module and Hedgehog's resolve side by side *(verified)*. Library rule: helper parameters are `source`/`element`, never `gen`, which shadows the builder in a `gen { }` body (FS0708) *(verified)*; the application's helpers use `gen` in bodies without the builder, which compiles. +- Leaf generators are `Gen` values annotated `Gen<'T>` in `.Generators.fs` files (module `Generators`): `Sql.Generators.fs` (`Gen`, scalar trees recursive through `Gen.sized`) in the first project, `Model.Generators.fs` (ids, wrappers, names, amounts, dates) in the second. Config fields reference leaf generators, never inline expressions; a missing one is added to its `.Generators.fs`. +- The test model is organised by test container like the application's persistence model: `DocumentsContainer` (the polymorphic class hierarchy with the two-level partition key of §22.5) and `TargetsContainer` (the records of the differential suites, §13 rung 6). `/Gen.fs` holds the GenConfig of one type (namespace `FSharp.Azure.Cosmos.Tests.Models.`), `.Generators.fs` the top-level generators (module `FSharp.Azure.Cosmos.Tests.Models.Generators`). Compile order: `Model.Generators.fs`; per container the base config, the derived configs, `.Generators.fs`; then `Quotation.Generators.fs`; a sub-generator a config needs moves to an earlier file. Not copied *(verified)*: the application's order inversion (a container's top-level generators file compiled before one of that container's config files), its files with two configs, its module names unlike their file names. +- Plain generators without a Slim/Fat/Default meaning follow the leaf rules: the quotation generators of the differential suites (`Quotation.Generators.fs`, module `QuotationGenerators`, e.g. `predicateGen`) and `heterogeneousDocumentGen : Gen`, which builds fresh `JsonNode`s per sample from a Fat instance by removing, nulling or retyping members. + +**Sampling and determinism** + +- `Sample.single`/`Sample.multiple` keep the application's semantics – random size 1–100, `Gen.sample`'s random seed, nothing logged *(verified)* – for hand-written emulator tests and scenarios whose data need not be replayed; `multiple` is kept although the application never calls it, the equally unused `largeSingle`/`largeMultiple` are dropped. Added, in neither the conventions nor the application: the seeded `singleWith seed size` and `multipleWith seed count` (sketch below), where value i gets the seed `seed + i` and the size `1 + i % 100`, so determinism does not bring back the fixed small size that collapses ranges and duplicates ids *(verified deterministic)*; `Gen.sampleFrom` also draws a random seed *(verified)*. Differential suites, shared datasets and data-driven rows use only the seeded members, with one `[]` seed per suite in a `Seeds` module (`Seeds.Differential = 2026UL`), written to `TestContext` at class initialisation and passed by properties to the adapter's `Seed` setting (Property execution); for shared datasets this replaces the `Sample.single` of the seeding conventions, because they must be replayable. +- Every seeded draw of one dataset has its own seed (`multipleWith seed n` occupies `seed` to `seed + n - 1`): the same seed repeats the same `Gen.guid` ids in structurally equal generators, even with different pins and sizes *(verified)*. +- Raw `Gen.sample` appears neither in tests (the application calls `Gen.sample 1 n`, size 1, in four scenario factories *(verified)*) nor inside a generator. `DynamicData` rows are constants or come from the seeded members: a static member that samples with `Sample.single` is evaluated again on each access, so discovery and execution see different data *(verified)*. +- Uniqueness is by construction: ids and keys come from `Gen.guid`, which follows the seed *(verified)*, or derive from the element index and are pinned through the config; sets collapse duplicates, so size assertions use the generated set's `Count`. Inside a property this does not isolate invocations: `Gen.guid` shrinks byte-wise towards `Guid.Empty`, so shrink candidates repeat ids and a counterexample's id is `00000000-0000-0000-0000-000000000000` *(verified: a failing seed-42 property of 124 invocations saw 80 distinct ids; a greedy shrink walk at seed 2026, size 50, took 18 invocations, one of which reused an id already written)*; property tests never write (maintainer decision 2026-09-28), so repeated ids cannot collide in a container; they matter only to assertions that expect distinct ids. `Gen.distinct`/`Gen.distinctBy`, which the generator conventions recommend, are not adopted: the application's implementations call `Gen.sample` inside `Gen.sized`, returned different values under the same property seed and never shrink *(verified)*; the only verified pure replacement needs `Gen.filter`. +- No Bogus, so the rule of the generator conventions for the shared Bogus faker has no counterpart (the application's faker is seeded from `Random.Shared`, outside the Hedgehog seed *(verified)*) and no clock (its `DateOnlyGen.past`/`future` read `DateTime.Today` on every sample *(verified)*). Text comes from `Gen.alphaNum`/`Gen.string`/`Gen.item` (`TextGen.nameString` and `notes` are copied; `capitalize` uses `Char.ToUpperInvariant` where the application calls the culture-sensitive `ToUpper ()`; the Bogus-based `lorem*` have no counterpart), dates from fixed bounds around one reference-date constant (`DateOnlyGen.between` over `DayNumber`). Foq, BlackFox.MasterOfFoo, Bogus and Microsoft.Extensions.Hosting, used by the application's tests, are not needed. + +The seeded `Sample` members (the sketches of this section are cut from code compiled and run with `dotnet fsi --langversion:10.0 --checknulls+ --warnon:3390` against Hedgehog 2.0.4, FSharp.SystemTextJson 1.4.36, MSTest.TestFramework 4.2.3 and FSharp.Azure.Cosmos 1.0.1 *(verified)*): + +```fsharp +/// Samples one value at the given seed and size; the same pair always gives the same value. +let singleWith (seed : Seed) (size : int) (source : Gen<'a>) : 'a = + source |> Gen.toRandom |> Random.run seed size |> Tree.outcome + +/// Samples count values; value i uses the seed seed + i and the size 1 + i % 100, so the values cover all sizes. +let multipleWith (seed : uint64) (count : int) (source : Gen<'a>) : 'a array = + Array.init count (fun i -> singleWith (Seed.from (seed + uint64 i)) (1 + i % 100) source) +``` + +**GenConfig records** + +- Every test type with optional members has a `GenConfig`: one `Gen<'T>` field per member, `'T` copying the member type (`option`, `voption` and `Skippable` stay as they are); `static let slim` listing every field; `static let fat = { slim with … }` and `static let defaultValues = { slim with … }` changing only the optional and collection fields, repeating no unchanged one (`static let default` does not compile, FS0010, `default` being a keyword *(verified)*, hence the name `defaultValues` from the generator conventions); `static member Slim`/`Fat`/`Default`; `member config.CreateGen () : Gen<'T>` with one `let!` per field in field order (a derived config binds its own fields, then calls the nested base) that builds a class with the required values as constructor arguments and the optional ones as named initialisers, never by assignment after construction. Types without optional members – scalar wrappers, unions used as ids, fieldless unions, nested records without optionals – keep a plain leaf generator without Slim/Fat/Default. +- **Slim** (maintainer 2026-09-28: all optionals empty): required values only; optionals `Gen.constant ValueNone`/`None`/`Skip`; collections empty (`Gen.constant ImmutableArray<_>.Empty`, `Set.empty`, `Map.empty`, `[]`); optional members of a non-option type at the member default (`""`, `0`). **Fat** (maintainer 2026-09-28: all optionals filled, no empty value at all): optionals `leafGen |> Gen.map ValueSome` (`Some`, `Include`; no application config has a `Skippable` field *(verified)*, so `Skip`/`Include` extend the `voption` form); optional members of a non-option type get a value other than the member default from a leaf generator whose range excludes it (text of length 1 or more, a non-zero number), and text inside `ValueSome`/`Some`/`Include` is non-empty too, because the maintainer's Fat has no empty value *(the plan's reading of "no empty"; Default gives such members the default or another value with equal probability)*; collections non-empty through a bare `Gen.list (Range.linear 1 3)` or a range-taking helper such as `CollectionGen.immutableSortedSet (Range.linear 1 3)` (the application's `CollectionGen.sortedSet (Range.linear 1 3)`), never a 0-bounded `CollectionGen` helper: `Range.linear 0 5` gives an empty collection in about 45 % of `Sample.single` draws *(verified)*, which leaves several of the application's Fat collections possibly empty. Collections of unrestricted size in leaf generators pass the element generator straight to a `CollectionGen` function instead of `Gen.list (Range.linear …) |> Gen.map …`; a collection type it does not cover gets a new function there. +- **Default** (maintainer 2026-09-28: total random): every optional member independently present or absent, every collection independently empty or not, every value from its leaf generator's full range. This supersedes the older pair of the generator conventions – `Default` for bounded values pinned to a constant, `Random` for full ranges, both added only when a field is pinned – so there is no `Random` variant, and a constant pin goes into one record update of `Slim`, `Fat` or `Default` at the call site or into a named variant (next bullet); no application config has `Default` or `Random` *(verified)*. The bounds the translator needs (fixed date bounds, integers within ±2^53, decimal precision, document size) are ranges of the leaf generators and of `CollectionGen`, and Default uses them in full. +- A named variant several tests reuse is `static let = { with … }` with a documented `static member ` next to `Slim`/`Fat`/`Default` and a documented top-level generator (the application has exactly one: a role configuration's permissions variant with its matching top-level generator); a variant one test uses stays a config at its call site. +- **How Default keeps absence and emptiness common** *(the plan's choice)*. `static let defaultValues = { slim with … }` fills every optional field with a balanced helper, `Gen.voptionBalanced`/`Gen.skippableBalanced`/`Gen.optionBalanced` (`Gen.frequency [ 1, Gen.constant ValueNone; 1, Gen.map ValueSome source ]` per carrier: absent with probability about 0.5 at every size, shrinking first to the absent case *(verified for all three)*), every collection with the balanced emptiness twin of its `CollectionGen` function, `CollectionGen.Balanced` (empty or `Range.linear 1 n` with equal weight: empty in 50 % of draws at every size *(verified over sizes 1–100)*), every owned child with the child config's `Default` and a nested base with `GenConfig.Default`. Top-level names follow the pattern of the conventions' `SlimGen`/`FatGen`: `DefaultGen`, and `getDefaultGen ` only when several tests pin the same values; the conventions' `defaultGen` named the superseded pinned variant. Sized helpers would make absence and emptiness rare: the conventions' "Optionals: `Gen.voption`, `Gen.skippable`" (`Gen.sized (fun n -> Gen.frequency [ 2, Gen.constant ValueNone; 1 + n, Gen.map ValueSome source ])`) and Hedgehog's `Gen.option` are absent with probability 2/(3+size), in about 7 of 100 tests over sizes 1–99 (6.2 % measured: 18 % at size ≤ 20, 2.9 % above 50), and a 0-bounded range ties emptiness to size (`Range.linear 0 5`: 97 % empty at size ≤ 20, 25 % above 50) *(verified)* – too few absent values to exercise null-versus-undefined translation beyond small sizes. The sized helpers are ported verbatim for parity; the application's configs do not use them either (Slim and Fat are constant, `Gen.voption` has one caller, at a call site *(verified)*). The balanced helpers keep their rates when a property pins `Size` (Property execution). Hedgehog's auto-generation is no substitute: `Gen.auto` draws a `voption` case uniformly but an `option` through the sized `Gen.option`, and list emptiness depends on size *(verified)*. +- **Base class configs.** `GenConfig` holds every member of the abstract base; its `CreateGen<'T when 'T :> > (create : -> 'T)` generates the base values, calls `create` and assigns the settable base properties through the upcast – the one assignment the pattern cannot avoid. A derived config nests it in a field named after the base (`Document`, as the application names such fields after their bases), takes its `Slim`/`Fat`/`Default` in the matching variant, binds its own values first and passes them through the class initialiser inside `create`, as the application's derived configs do. The generator conventions' own base-class example and the application config it is taken from assign after construction; not copying them is an adaptation to the repository's class-initialiser rule. Base values are pinned by nested updates (`{ config with Document.TenantId = … }`); a base config whose derived class adds nothing exposes `CreateGen ()`, as the application does; each schema version of a discriminated type is its own derived class with its own config (§21.1). A polymorphic dataset draws from `anyGen`, a `Gen.choice` over every `JsonDerivedType` registration upcast to the base, in each variant; the application's counterpart uses Slim only and omits one unregistered type. + +Sketch of `DocumentsContainer/DocumentBaseGen.fs` and `DocumentsContainer/PersonDocumentGen.fs` (types reduced from the sample model, leaf generators from `ModelGenerators`): + +```fsharp +/// Generator configuration for the values every DocumentBase has; nested into the configurations of the derived types. +type DocumentBaseGenConfig = { + Id : Gen + TenantId : Gen + Note : Gen +} with + /// Only the required values; every optional value is empty or keeps its entity default. + static let slim = { Id = documentIdGen; TenantId = tenantIdGen; Note = Gen.constant ValueNone } + /// The slim configuration with every optional value filled. + static let fat = { slim with Note = noteGen |> Gen.map ValueSome } + /// The slim configuration with every optional value empty or filled, chosen per sample with equal probability. + static let defaultValues = { slim with Note = Gen.voptionBalanced noteGen } + /// Only required values; all optional properties are empty. + static member Slim = slim + // static member Fat and Default alike, each documented + + /// Generates a document: create receives the constructor values, then the base properties are assigned. + member config.CreateGen<'T when 'T :> DocumentBase> (create : string -> string -> 'T) : Gen<'T> = gen { + let! id = config.Id + let! tenantId = config.TenantId + let! note = config.Note + let document = create id tenantId + let documentBase = document :> DocumentBase + documentBase.Note <- note + return document + } + +/// Generator configuration for PersonDocument. +type PersonDocumentGenConfig = { + Document : DocumentBaseGenConfig + Name : Gen + Nick : Gen + Tags : Gen> + Rating : Gen> +} with + // static let slim and defaultValues (Document = DocumentBaseGenConfig.Default, Nick = Gen.voptionBalanced nickGen, + // Tags = CollectionGen.immutableArrayBalanced tagGen, Rating = Gen.skippableBalanced ratingGen), + // static member Slim, Fat and Default as described above + + /// The slim configuration with every optional value filled. + static let fat = { + slim with + Document = DocumentBaseGenConfig.Fat + Nick = nickGen |> Gen.map ValueSome + Tags = tagGen |> Gen.list (Range.linear 1 3) |> Gen.map ImmutableArray.CreateRange + Rating = ratingGen |> Gen.map Include + } + + /// Generates a PersonDocument; its own values go through the class initialiser inside create. + member config.CreateGen () : Gen = gen { + let! name = config.Name + let! nick = config.Nick + let! tags = config.Tags + let! rating = config.Rating + return! + config.Document.CreateGen (fun id tenantId -> + PersonDocument (id, tenantId, name, Nick = nick, Tags = tags, Rating = rating) + ) + } + + /// Generates a PersonDocument in the specified partition. + member config.CreateGenForTenantId (tenantId : string) : Gen = + { config with Document.TenantId = Gen.constant tenantId }.CreateGen () +``` + +- Records are returned as `({ … } : )`, because their labels equal the config labels. A `[]` record and its reference twin share one config through a second method (`CreateGen ()`/`CreateStructGen ()`), as one of the application's configs does for a record and its input twin, so "struct translates exactly like reference" (§20.11) runs on identical values. An owned nested record with optional members is a `Gen<_>` field filled from its own config's `CreateGen ()` in the same variant (the application fills an owned location record from `Slim.CreateGen ()` of its config in Slim and from `Fat` in Fat; `Default` in Default); only a base class is nested as a config record. +- Values the document computes or the server assigns – `_ts`, `_etag`, the `$type` discriminator written from `JsonDerivedType`, computed properties, keys derived from other members – are not config fields. A composed id keeps its parts as fields and combines them in `CreateGen`. A member the type defaults without an option type (the §21.2 wrapper over a nullable backing field, `MemberDefaults`) gets a `Gen<'T voption>` field, documented as assigned only on `ValueSome`, so Slim leaves the stored member absent and exercises `(IS_DEFINED(p) ? p : @default)`; Fat always assigns it and Default assigns it with probability 0.5. Members the translator supports only in a later phase, or the vNext emulator cannot store, are left out of the test model until that phase instead of staying empty in Fat, which the maintainer's Fat definition rules out *(the plan's choice; the generator conventions keep such values empty in Fat; open in §18 item 12)*. +- Mutable values (`JsonNode`, which has a parent and cannot be shared, `ResizeArray`, `Dictionary`, arrays) are generated per sample with `gen { return … }`, never with `Gen.constant`, which shares one instance; immutable empties may use `Gen.constant`. +- Pinning helpers are config methods, never free functions: `For` returns a config copy when several fields derive from domain values or a value is wrapped into the field type, and nests into other configs; `CreateGenFor` returns `{ config with … }.CreateGen ()` and backs the top-level `get…Gen` functions. No `For…` method only sets one field to a constant. Partition keys are not fields; the stored members a key is made of are (the application's `TenantId`), pinned by `CreateGenForTenantId`-style methods. +- Every generator `let`, private ones included, and every `static let` has a `///` doc comment, as the generator conventions require; every config type, static member, `CreateGen…`, `For…` and `CreateGenFor…` method is documented too (the repository's rule), plain or explicit XML; the application's undocumented config types and its two plain comments that carry `` are not copied. +- `.Generators.fs` holds `SlimGen` (`= GenConfig.Slim.CreateGen ()`), `FatGen`, `getSlimGen ` (`= GenConfig.Slim.CreateGenFor… values`), `getFatGen `, `DefaultGen`, `getDefaultGen ` and the generators of named variants, each documented; one-off variants (`getGenWith`) are configs at the call site. + +**Call sites** + +- A test that needs fields pinned builds one record update from `Slim`, `Fat` or `Default` and calls `CreateGen ()`: it never mutates a sampled document, copies it with `{ x with … }` or rebuilds one from a sampled one, never chains single-field methods and never combines an update with `CreateGenFor…` (those values go into the same update). A config or generator used more than once is bound once with `let`. Mutating a sampled document is right only when the mutation is what the test exercises: replace, upsert and patch tests and the `PatchWhereAsync`/`DeleteWhereAsync` tests (§20.13). +- Values are valid by construction (`Gen.map TypedString`, `Gen.map Id` over ranges taken from the type's own limits), never through `Gen.filter`, in generators and at call sites alike (the application filters at two call sites *(verified)*); a value that must differ from a pinned one comes from `Gen.item` over the other cases. +- **Every hand-written emulator test runs with the slim and the fat instance**, in one shape, the library's own: `[]` on a `withOptionalFields : bool` parameter, then the conventions' line `let config = if withOptionalFields then GenConfig.Fat else GenConfig.Slim`, the pins in one update, `CreateGen ()` and `Sample.single` inside the method (e.g. ``Create must round-trip the document when optional members are absent or present`` *(verified: compiled and run with a stubbed round trip)*). Each variant is a separate, named result with its own database (§22.3 hashes the row). The application never puts Slim and Fat into rows (its `withOptionalFields` is a parameter of a private helper in one CRUD test file *(verified)*), and its shapes are not used: Slim then Fat in one method (the round-trip tests) stops at the first failure and hides the Fat result, two methods duplicate the body, `DynamicData` rows of sampled entities change on every access. Shared datasets seed slim and fat documents side by side instead; the differential datasets draw from `Default` (§13 rung 6). A `[]` does not take these rows: its generators choose the variant (Property execution). + +**Generator facts the translator tests depend on** *(verified)* + +- `Gen.dateTimeOffset` yields arbitrary offsets such as `-06:49`, whose ISO strings do not sort in instant order: date leaf generators map to UTC (`Gen.map _.ToUniversalTime()`). +- `Gen.decimal` yields up to 15 significant digits – up to 11 fractional digits from 1 000 upwards, up to 15 below 1 – and Cosmos DB stores numbers as doubles: decimal leaf generators round to the precision the member declares (the sample: two places over 0–10 000, `Math.Round (d, 2)`). +- The model holds `Skippable` members in both forms that §22.5 distinguishes (a skipped value written as `null` or omitted), and the layout rung pins which is which. +- A single-case union with a private case serialises unwrapped with the default `JsonFSharpConverter` and round-trips, over `string`, `Guid`, `int`, `DateTimeOffset` and `DateOnly` alike, generic phantom-typed wrappers included *(verified with FSharp.SystemTextJson 1.4.36)*. +- Document sizes are bounded through `CollectionGen` ranges and `TextGen` length limits: oversized generated documents made the SDK retry into a stack overflow in the application. + +**The test model** + +- The target type set contains records **and a class hierarchy** (abstract base, derived classes, settable voption properties, `JsonPropertyName`), phantom-typed private-case wrappers over Guid, string, int and dates, multi-case unions used as ids, `ImmutableArray`, `Set`, maps, struct records, struct tuples and reference tuples (the reference forms stay accepted and the forced-reference forms of §4 need coverage), struct and reference anonymous records. +- The hierarchy copies the application's container file structure: a constants module with `PartitionKeys` (`Level0`, `Level1`, …) and `Discriminators`, `JsonDerivedType` attribute classes, an abstract root, concrete types, and one partition-key builder per container (the application's `PartitionKey(…).Build()`), used by seeding, `withPartitionKey` tests and the partition-key-lifting goldens. The Marten corpus stand-ins (§20.14) live in the same project. + +**Property execution** + +- **Adapter, never in-body checks** (maintainer 2026-09-28). Every property is a test method marked `[]` from `tests/Hedgehog.MSTest` (adapter below); no test calls `Property.check*` or `Property.recheck*` or builds `property { }`. Generated values are the method's parameters – `[)>] member this.``Nick equality must return the same documents in F# and on the emulator when the nickname is empty or filled`` (predicate : Expr bool>, nick : string voption) : Task = task { … }` *(sketch)* – and configuration is attribute-level: `Tests`, `Shrinks`, `Size` and `AutoGenConfig` on `[]` or on the test class through `[]` – the class MSTest runs, read through `MethodInfo.ReflectedType`, so each derived `[]` over an abstract suite base supplies its own defaults (adapter below) – where the method wins and `AutoGenConfig`s merge per type as upstream's `AutoGenConfig.merge` does, plus the adapter's `Seed`, a `uint64` fed from the `Seeds` literals, for the suite seed. Inside the adapter the configuration is `PropertyConfig.defaults |> PropertyConfig.withTests … |> PropertyConfig.withSeed …`: the same seed gives the same report (seed 42 gave the same recheck data on repeated runs and on MSTest 4.3.2), and `PropertyConfig.Default` and `PropertyConfig.defaultConfig` do not compile against 2.0.4 *(verified)*. +- **Signatures.** A property returns `unit`, `Task` or `ValueTask` and fails by throwing (`Assert.*` with a message, Unquote for complex facts); MSTest awaits the returned task within each invocation *(verified for `Task` and `ValueTask`)*. The `bool`, `Task`, `Result`, `Async` and `Property<_>` returns that `Hedgehog.NUnit` and `Hedgehog.Xunit` accept cannot exist under MSTest 4: discovery rejects them with UTA007 for the whole assembly (§22.1) *(verified for `bool`, `Task`, `Task` and `Property`; `Result` and `Async` by the same return-type check, `MethodInfoExtensions.cs:92-96`, read in the source, not run)*. Asynchronous properties are declared `) : Task = task {`. Properties are instance members of `[]` types: a static `[]` fails discovery of the whole assembly with UTA007 *(verified)*, so the module-level `let` properties of the upstream suites have no counterpart. The `TestMethodAttribute` XML docs require non-generic test methods, but MSTest 4.2.3 discovers a generic one – its validator checks accessibility, `abstract`, `static` and the return type only *(verified)* – so the adapter rejects it with a failed result naming the rule, because arguments cannot be generated for an open type parameter (in the prototype the exception escaped `ExecuteAsync`). +- **Choosing Slim, Fat or Default.** Generators choose the variant; auto-generation of test-model types is never relied on, because Hedgehog's `Gen.auto` has its own presence rates (GenConfig records). The default route *(proposed)*: one `AutoGenConfig` type per container and variant in the generator project, whose single static member registers every test-model type of that container – `static member Config = AutoGenConfig.defaults |> AutoGenConfig.addGenerator personDocumentDefaultGen |> …` (unannotated, it infers exactly `IAutoGenConfig`, the return type upstream's lookup requires *(verified)*) – named on the class (`[)>]`) or on one method (`[, 200)>]`); it needs only Hedgehog, so the `.Hedgehog` projects keep their references (§22.1). A single parameter that departs takes a `GenAttribute<'T>` subclass declared in the test project – `type FatPersonAttribute () = inherit GenAttribute (); override _.Generator = personDocumentFatGen` *(sketch)* – which wins over every config. The spec-model generators of `Cosmos.Sql.Hedgehog` (`Gen`, scalar trees; §13 rung 2) and the quotation and captured-argument generators are registered the same way, in variant-less `AutoGenConfig` types. Both routes ran in the prototypes: Slim and Fat `AutoGenConfig` types built with `AutoGenConfig.addGenerator`, named on the method and on the class, and a `GenAttribute` subclass on a parameter winning over the class config *(verified)*; a variant named on a derived `[]` reaches a property inherited from an abstract suite base only when the adapter reads the reflected test class (adapter below) *(verified)*. +- **Failure and reproduction.** A failing property is one failed MSTest result per data row whose message is Hedgehog's report – `*** Failed! Falsifiable (after N tests and M shrinks)`, `You can reproduce this failure with the following Recheck Seed:` with the quoted `size_seed_gamma_path` data, `Test parameters:` with `name = value` lines, `Actual exception:` – followed by a paste-ready `[")>]` to add next to the method's `[]`, which keeps the property's own settings such as its `AutoGenConfig`, so that the recheck data replays the same case (#43); the original exception is the inner exception and the output of the failing invocation is attached *(verified)*. `[">]` on the method replays exactly one case at the recorded size and seed and follows the shrink path, overriding `Size` and `Seed` *(verified: one invocation, failing with the bug and passing after the fix)*; malformed data gives an `Error` result, never an unhandled exception from the attribute *(verified)*. Inside the adapter a `Report` is inspected by matching `Report.Status` (`OK | GaveUp | Failed data`): `Status.IsOK` is not visible to F#, so the repository's `IsCaseName` rule cannot apply *(verified)*. +- **Lifecycle per case.** MSTest runs the constructor, `TestContext` injection, `[]`, `[]` and `Dispose` for **every** generated case and every shrink step, unlike the upstream adapters, which reuse one instance per property *(verified: a seed-42 property that failed after 34 tests and made 53 invocations with its shrinks got 53 constructions and 53 `[]`, `[]` and `Dispose` calls on 4.2.3 and 4.3.2; upstream's xUnit and NUnit adapters constructed their class once for five cases)*. Emulator resources therefore come from the class-scoped fixtures of §22.2 (`[]`, `[]`), and a property never lives in a per-test-database class. A differential property over a shared dataset generates only the quotation and its captured arguments; the documents are the scenario's. There are no writing properties (maintainer decision 2026-09-28): round trips stay hand-written slim/fat tests with per-test databases (§22.2), so the per-case lifecycle never creates, fills or deletes a database. +- **Cancellation, time and parallelism.** The body reads the token of its invocation from its own `TestContext` (`TestBase.CancellationToken`, §22.2), which MSTest injects per case *(verified)*. The adapter reaches the context only through `TestContext.Current`, marked `[Experimental("MSTESTEXP")]` in 4.2.3 and 4.3.2 (FS0057) *(verified)*, read in one helper with a scoped `#nowarn "57"`/`#warnon "57"` and re-read per invocation, because MSTest replaces the `CancellationTokenSource` before each `TestCleanup` (read in the source, `TestMethodInfo.cs:620`); a cancelled token stops the run without shrinking *(verified)*; the adapter reads the token before every invocation and checks it again after it, whatever the outcome, because MSTest reports a test method that returns without observing its cancelled token as passed *(verified)*. `[]` applies to each invocation, not to the property *(verified: five 150 ms invocations passed under a 400 ms timeout)*; after a cooperative timeout the invocation's `TestResult.Outcome` is `Failed` on 4.2.3 and `Timeout` on 4.3.2 *(verified)*, so the adapter keys on the cancelled token, never on the outcome, and emulator properties budget through `Tests` and `Shrinks`. One property is one unit of MSTest parallelism with sequential invocations *(verified)*; `[]` re-runs the whole property *(verified: after a failed first run MSTest called `ExecuteAsync` again, which ran five fresh invocations)*. +- The application has only two property tests, both pure, in one client test file with the default configuration *(verified)*; the adapter and the emulator property shape are the library's own. + +**Hedgehog MSTest adapter** (`tests/Hedgehog.MSTest`, §5; delivered in Phase 0, §16; prototypes were built, the extended one also run on MSTest 4.3.2) + +- **Mechanism** *(verified)*. `PropertyAttribute` inherits `TestMethodAttribute`, forwards `[]`/`[]` optional parameters to the base constructor as `STATestMethodAttribute` does (F# fills them for attribute constructors, upstream's six constructor overloads included), and overrides `ExecuteAsync (testMethod : ITestMethod) : Task`, which MSTest calls instead of its own single invocation. Hedgehog generates the arguments, each case is one `testMethod.InvokeAsync arguments` – never `MethodInfo.Invoke`, which the MSTest docs rule out – and the run folds into one `TestResult`. A test method with parameters and no data source is accepted at discovery. MSTest has no counterpart to xUnit's discoverer, test case and runner chain or to NUnit's `ISimpleTestBuilder`/`IWrapTestMethod`, so the framework layer is this one attribute. +- **Parity** with `Hedgehog.NUnit` 1.0.0 and `Hedgehog.Xunit.v3` 2.0.0 *(upstream behaviour verified by building and running its suites)*: `[]` with upstream's constructor overloads `()`, `(tests)`, `(tests, shrinks)`, `(autoGenConfig)`, `(autoGenConfig, tests)`, `(autoGenConfig, tests, shrinks)` and the named settings `AutoGenConfig : Type`, `AutoGenConfigArgs : obj[]`, `Tests : int`, `Shrinks : int`, `Size : Size`; class-level `[]` with the same settings; `[]`; abstract `GenAttribute<'T>` and upstream's 18 built-in parameter attributes (`Int`, `Odd`, `Even`, `PositiveInt`, `NonNegativeInt`, `NonZeroInt`, `Identifier`, `LatinName`, `SnakeCase`, `KebabCase`, `DomainName`, `Email`, `DateTime`, `DateTimeOffset`, `AlphaNumString`, `UnicodeString`, `Ipv4Address`, `Ipv6Address`), and `DateOnly` and `TimeOnly`, which upstream gets through hedgehogqa/fsharp-hedgehog#488 (§16); `AutoGenConfig` types with exactly one public static member returning `IAutoGenConfig`, optionally taking `AutoGenConfigArgs`, and a clear error otherwise; every other parameter from `Gen.autoWith` with the merged configuration; parameters journalled by name in Hedgehog's `Journal`; `IDisposable` arguments disposed after each invocation; precedence `GenAttribute` > `[]` > `[]` > `AutoGenConfig.defaults` (in this repository with the generators of `DateOnly` and `TimeOnly` until Hedgehog has them, §16); a run that gives up after Hedgehog's fixed 100 discards fails (NUnit sets `Failure`, xUnit raises through `Report.tryRaise`; the prototype reported it as inconclusive, which the runner shows as skipped). +- **Additions beyond upstream** *(the plan's choice)*: `Seed`, a `uint64` fed from a `[]` such as `Seeds.Differential` *(verified)*; data rows – MSTest unfolds each `[]`/`[]` row into its own test and passes it as `ITestMethod.Arguments`, a `TestDataRow` unpacked into its items, which the adapter keeps as the leading arguments while generating the rest *(verified for `DataRow`, `DynamicData` and `TestDataRow`)*; `Assert.Inconclusive` in an invocation is a Hedgehog discard, and a failure while rechecking *(both verified)*; an invocation that MSTest could not run as a test – an outcome other than `Passed`, `Failed`, `Inconclusive` and `Timeout`, or an exception of `InvokeAsync` – stops the run without shrinking, and the property reports that outcome and exception *(verified with invokers that return `Error` and `NotFound` and one that throws)*. One result per property or row, because several results from one `ExecuteAsync` appear as separate results under one test id and inflate the totals *(verified)*. +- **Deliberate departures from upstream** *(the plan's choice; upstream behaviour measured)*: class-level settings come from the test class MSTest runs, `testMethod.MethodInfo.ReflectedType`, not from `DeclaringType` as in both upstream adapters and the prototype – MSTest runs a property inherited from an abstract suite base once per derived `[]`, whose type is the reflected one while the declaring type is the base, so the prototype ignored a derived class's `[]` and ran 100 auto-generated cases *(verified)*; along the class chain a derived class's `[]` wins over its base's (upstream: the base wins); a `GenAttribute` is found through the whole inheritance chain via a non-generic base attribute (upstream checks only the direct base type and silently auto-generates for an attribute derived from a built-in one); generic property methods, which MSTest 4.2.3 discovers and upstream closes over `obj`, get a failed result naming the rule before any generator is built; `TargetInvocationException` is unwrapped; each shrink-tree node's invocation is memoised in a lazy task, because Hedgehog 2.0.4 re-runs an asynchronous test case whenever it is unwrapped and reads the final counterexample's `Journal` from a second invocation – a doubled database side effect and a report that can contradict the failure *(verified)*; `Property.reportTaskWith` runs without blocking, `Property.reportRecheckWith`, synchronous in 2.0.4 and ignoring its configuration argument, runs on `Task.Run`; any exception, generator construction included, becomes an `Error` result instead of escaping `ExecuteAsync` (the prototype built generators outside its `try`, so a generic property method surfaced as an unhandled exception of the attribute *(verified)*); `Odd` and `Even` generate from the odd or even values inside their range and reject a range without one (upstream sets or clears the lowest bit of any value of the range, so `Odd(-1, 0)` gives 1 *(verified)*). +- **Code.** Upstream's nine framework-neutral files under the namespace `Hedgehog.MSTest` (§14), plus `PropertyAttribute`, `PropertiesAttribute` and an invoker entry point in `InternalLogic` (a function from arguments to `Task`, so the suite can drive the runner without MSTest), in upstream's file layout. Upstream's return-value wrapping (`bool`, `Property<_>`, `Result`, `Async`, `Task`) is dropped: MSTest awaits `Task` and `ValueTask` itself, and the other return types never pass discovery (§22.1). The repository's rules apply to the port – `voption`, struct tuples, XML docs, Fantomas, no `.AsTask ()` (upstream converts `ValueTask` that way), no nullness warnings (the nine upstream files give 23 under the repository's `Nullable=enable` *(verified)*) – and every copied file keeps its Apache-2.0 attribution; after the review of #43 the port follows them, Fantomas and nullness checking included (§18 item 12). `InternalsVisibleTo` exposes the runner to `Hedgehog.MSTest.Tests` only. +- **Suite** (`tests/Hedgehog.MSTest.Tests`): upstream's F# xUnit tests (126, covering recheck, size, shrinks, `AutoGenConfigArgs`, `IDisposable` arguments) and NUnit tests (52) rewritten as `[]` types, without the return-value cases MSTest cannot discover; tests for every addition and departure above; failing properties asserted through the invoker entry point instead of failing the suite; at least one plain `[]`. The `PropertyTests.fs` of the earlier MSTest rename of the old `hedgehogqa/fsharp-hedgehog-xunit` repository (783 lines) serves as a checklist (§14). + +**Scenarios and seeding** (the seeding conventions) + +- A scenario lives next to its tests in `tests/Cosmos.Quotations.Tests/Scenarios/.fs`, registered in the fsproj before the test files that use it, not in `Cosmos.Tests.Infrastructure`: that project is shared with `tests/Cosmos.Tests`, which must not depend on the Quotations test model, so it keeps only the base factory and the fixtures (§22.2). Each scenario file declares its own namespace `FSharp.Azure.Cosmos.Quotations.Tests.` (the application likewise gives each scenario its own namespace), so every scenario names its types `TestData` and `TestDatabaseFactory` (the seeding conventions name the factory after the application host). A factory that already seeds what a test needs is extended, not duplicated. The canonical scenario of the seeding conventions mutates a generated document and calls `Gen.distinct`; neither is copied. +- Shape: a `[] TestData` record and a factory that derives from the base factory, generates everything in its constructor (`let` bindings) and exposes `member val TestData : TestData = { … }`. `override this.SeedDataAsync () : Task` only persists what the constructor generated, never samples, through the library's own operations (`container.ExecuteAsync (create { item x; partitionKey pk }, cancellationToken)`, the key from the container's partition-key builder as the seeding conventions require for every item, the token from the factory's `TestContext`), and asserts `CosmosAssert.IsOk` on every response – stricter than the seeding conventions, whose sample discards the responses – so seeding uses the same serializer as the translator's parameters; heterogeneous documents (missing properties, wrong types) are written as streams. The factory implements the base's static abstract `Create` – `interface IDatabaseTestFactory with static member Create (ctx : TestContext) = new TestDatabaseFactory (ctx)` with `/// `, the counterpart of the factory-interface implementation in the seeding conventions, generic in the application's code (§22.2) – through which the fixtures of §22.2 create it. +- `TestData` exposes only what tests read. Documents that exist only to be excluded by a predicate stay private `let`s; no field implies a document the factory does not seed, and a value deliberately not seeded is named so (`MissingId`). Filter anchors are `Known` fields pinned in the factory with one config update, so every golden predicate has a guaranteed match; `Other` values cover cross-partition cases. Field names match the variant they hold (one of the application's fields named for Slim holds a Fat sample *(verified)*). Example *(verified: compiled with stubs for the base factory and the partition-key builder)*: a persons scenario draws `tenantId` with `Sample.singleWith (Seed.from Seeds.Persons) 50 tenantIdGen`, the `KnownNick` anchor document from `{ PersonDocumentGenConfig.Fat with Document.TenantId = Gen.constant tenantId; Nick = Gen.constant (ValueSome knownNick) }` at the seed `Seeds.Persons + 1UL`, the other persons with `Sample.multipleWith (Seeds.Persons + 2UL) 50 (getPersonDocumentSlimGen tenantId)`, and seeds `[| nickedPerson; yield! otherPersons |]`. +- Test methods deconstruct only the fields they use – `let { KnownNick = knownNick; NickedPerson = nickedPerson } = this.TestData`, never `let td = this.TestData` – and never sample documents of the dataset; they may sample documents they write themselves (create, replace, upsert and patch tests; the mutation inputs of the seeding conventions) and captured query arguments (the library's addition). +- Lifecycle (§22.2): write tests create the factory per test, so their data is generated per test; read-only suites create it once per class, keyed by the class name, so the shared dataset is generated and seeded once. + +### 22.7 Assertions and style + +- Assertion helpers are sealed static classes marked `DebuggerNonUserCode` with paired members: `Want*` returns the unwrapped value or fails, `Is*` discards it, the optional message comes last. `CosmosAssert` and `Assert` move to the shared infrastructure; a `TranslationAssert` is added in the same style (`WantOk`, `WantError`, `WantErrorCode`, `SqlEquals`). Defects of the application's helpers are fixed on the way (`WantConflict` returns the function `ignore`; several helpers fail with a null message). Assertion functions that a test file declares for itself are Pascal case as well (`QueryAssert.Returns`, `QueryAssert.RejectsWith`), and data that the tests of a class share is an `ImmutableArray` built from a sequence expression, `seq { … } |> ImmutableArray.CreateRange` (review of #44, 2026-10-10). +- Whole-entity comparison uses CompareNETObjects through one shared `EntityCompareLogic` with custom comparers for option, voption, Skippable, sets, collections and `JsonElement`. It is used for projected class instances and whole documents; id sets and scalar values are compared with structural equality, JSON shapes with `JsonNode.DeepEquals`. The package is test-only and accepted (maintainer decision 2026-09-27, §18 item 11): CompareNETObjects 4.84.0, the application's version, licensed Ms-PL, referenced only by `Cosmos.Tests.Infrastructure`; it brings `System.Configuration.ConfigurationManager` 8.0.0 and `System.Drawing.Common` 8.0.0 transitively and sets `requireLicenseAcceptance` *(verified in the 4.84.0 nuspec)*. The repository's written rules win over the application's habits: no file-wide `#nowarn`, Unquote for complex facts only, a message on every `Assert.*` call. +- Server-assigned metadata never enters a whole-document comparison, and generated values stay untouched (maintainer answer 2026-09-28, §18 item 12): `EntityCompareLogic` lists the members mapped to `_etag` and `_ts` in `ComparisonConfig.MembersToIgnore` *(API verified in CompareNETObjects 4.84.0)*, and a custom extension-data comparer, to be written with the others, skips underscore-prefixed keys. This departs from the application, which copies the response's ETag onto the generated entity before `CompareLogic.Assert` (57 assignments in 9 files *(verified)*). The generator conventions do not address that idiom; the library does not copy it because on the records of the differential suites (§22.6) it would need the `{ x with … }` copy of a generated record that the conventions' rule on overriding fields rules out. A test whose subject is the ETag (optimistic concurrency) asserts it explicitly. +- **Exact assertions.** Counts and lists are asserted exactly; the application's lower-bound and upper-bound assertions would pass for a wrong query. Translation assertions are full SQL plus parameter baselines, or result sets. +- Test names are double-backtick sentences of the form ` must when `. Asynchronous tests are declared `) : Task = task {`; IcedTasks expressions are for library code, not test bodies. +- Data-driven cases use `[]` over a static member whose rows carry a readable case name, so each case is a separate test result and failures print names. Rows are constants or come from the seeded `Sample` members, never from `Sample.single` (§22.6). The one exception to `[]` is the slim/fat pair of a hand-written emulator test: two constant `DataRow`s with `DisplayName` (§22.6); a `[]` chooses its variant through its generators and takes rows – `DataRow` constants or `DynamicData`/`TestDataRow` rows from constants or seeded members – only ahead of its generated parameters (§22.6; all three row forms ran with the adapter prototype *(verified)*). Typed rows are `TestDataRow` with `DisplayName`, never reference-tuple rows: the MSTest 4.2.3 XML docs allow a `Tuple` or a `ValueTuple` value for methods with several parameters; MSTest 4.2.3 unpacks a `TestDataRow` row into two parameters at run time and keeps its `DisplayName` *(verified through the Hedgehog adapter prototype, whose `ITestMethod.Arguments` held the two items ahead of a generated third parameter)*; a plain `[]` and larger arities are checked in Phase 0. Each truth-table case lists accepted and rejected inputs. +- Failure messages state what was expected and include the rendered quotation fragment and the error code. +- Unsupported programs are asserted with `Assert.Throws` followed by a check of the error code; for `QueryAsync` the sequence is enumerated inside the lambda. +- Every execution member has one test that cancels the token and asserts `OperationCanceledException`. +- Predicate combinators are tested for three properties, as the application does for its predicate combinators: the result parameter type, the absence of untranslatable nodes after combination, and semantic equivalence by evaluating the combined predicate on sample values.