Skip to content

docs: add ADR 0001 for the FSharp.Azure.Cosmos.Quotations architecture - #34

Open
xperiandri wants to merge 42 commits into
mainfrom
docs/quotations-architecture-adr
Open

xperiandri wants to merge 42 commits into
mainfrom
docs/quotations-architecture-adr

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Proposed Changes

Adds the agreed architecture of the planned FSharp.Azure.Cosmos.Quotations package family as an Architecture Decision Record: docsSrc/ADRs/0001-Quotations-Architecture.md, published by fsdocs under a new "Architecture Decision Records" category.

The record opens with the usual ADR header (status, context, decision, consequences), followed by the full design: translating native F# quotations into Cosmos DB NoSQL SQL over the public QueryDefinition pipeline, an own SQL AST with validator and printer, the null/undefined and optional-value semantics, the pluggable translator model, System.Text.Json + FSharp.SystemTextJson only, the test architecture and the phased roadmap. Implementation follows in one pull request per Phase 0 step; each later pull request updates this record together with the decision it changes.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Documentation only.

Checklist

  • Build and tests pass locally (build.cmd BuildDocs with fsdocs --strict passes)
  • I have added tests that prove my fix is effective or that my feature works (if appropriate)
  • I have added necessary documentation (if appropriate)

Further comments

Examples taken from a private application were replaced by neutral names and descriptions.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 2, 2026 21:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot wasn't able to review any files in this pull request. Check if the Files changed in this pull request are included in default exclusions.

@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from e1f35eb to 8d9ffe6 Compare October 3, 2026 01:38
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from ff19ea3 to 9767e66 Compare October 10, 2026 00:06
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch 2 times, most recently from 7557194 to 6820cc6 Compare October 10, 2026 08:26
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch 5 times, most recently from 7b47902 to 3182e04 Compare October 11, 2026 09:03
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from 3182e04 to 176dffd Compare October 11, 2026 11:03
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch 3 times, most recently from 2b5ba34 to 8965bd9 Compare October 11, 2026 17:52
xperiandri and others added 29 commits October 19, 2026 01:01
The maintainer decided on 2026-10-10 that neither FSharp.Azure.Cosmos
nor the Quotations package declares Newtonsoft.Json: the build check of
the SDK already asks every consumer to reference it and leaves the
version to the consumer. The package table and the package list lose
the dependency, and open question 16 is answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hedgehog MSTest adapter of #43 is proposed upstream as
hedgehogqa/fsharp-hedgehog#487. The record notes how the proposal
differs from the in-repository copy (net8.0 and F# 8, upstream's
documentation in the shared files, VSTest), that MSTest 4.2 is the
adapter's lowest version, and that one test of #43 stops compiling on
MSTest 4.5.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maintainer's reviews of 2026-10-10 changed several things the record
describes, and it now says so where each belongs.

The Hedgehog MSTest adapter (#43): the in-repository copy follows the
coding guidelines of this repository, while the upstream pull request
keeps upstream's style in a copy of its own, so a change of behaviour
goes into both; an invocation that MSTest could not run stops the run
instead of being shrunk; the cancellation token is checked after every
invocation, whatever its outcome, which the automatic review of #50
asked for; Odd and Even stay inside their range; DateOnly and TimeOnly
get generators, upstream through hedgehogqa/fsharp-hedgehog#488. The
open question of the adapter's code style is answered, formatting and
nullness checking included: the two projects are formatted with Fantomas
and build with nullness checking on, and only the verbatim port keeps
upstream's settings.

Collections (section 4): the shared collection functions of #47 that
return voption and struct tuples, tested with properties in #50, and the
rule of one module per pipeline of #49.

Build (section 5): why FAKE's DotNet.test cannot run the test
applications, and fsprojects/FAKE#2903, which adds DotNet.testMTP.

The spec model (sections 6 and 16): EquatableArray and the ImmutableArray
patterns are #48 and the model itself #42; the validator returns an
ImmutableArray of struct errors.

Golden baselines (section 13): a literal type provider was evaluated on
the question asked in #46 and rejected, with the reasons.

Tests (section 22): test category attributes sorted by name, in #51
with an instruction scoped to those files, Pascal-case assertion
functions and ImmutableArray for shared test data.

Two more answers of the same day: the Windows emulator lane of #45 runs
weekly instead of nightly (section 2), and the shared collection
functions of #47 are in the namespace FSharp.Azure.Cosmos instead of the
global one (section 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Section 4 said that an F# record or union holding an ImmutableArray
loses structural equality. It does not: the equality the compiler
generates goes through IStructuralEquatable, which the array implements,
and compares the elements. What is true is narrower. The array's own
Equals, which EqualityComparer.Default calls, compares references, so a
struct tuple, a HashSet with the default comparer and a C# caller see
two equal arrays as different. The structural path boxes every element,
hashes only the last eight, throws on compare for different lengths,
and a default array never equals an empty one.

The section now gives these as the reasons for EquatableArray, as the
documentation comment of the type in #48 already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The benchmarks of #56 give the cost model of section 11 its first
numbers. A quotation literal costs 24 to 75 µs per evaluation inside
FSharp.Core, which unpickles the tree every time; the shape key, the
plan lookup and the QueryDefinition together cost about 1 µs; a value
costs a fraction of a microsecond by reflection and hundreds of times
more compiled; a request to the emulator takes 1.4 to 2.1 ms.

The record states what follows for the design: the plan cache does not
remove the cost of an inline quotation, compiled queries do, and the
precompilation tool of Phase 5 stays undecided until the translation
itself can be measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#57 makes the SDK's parser the judge of the printer. The record states
how it departs from the plan and why: the SDK's generated sources are
copied instead of generated again, so no Java is needed; the conversion
is F# and sits in a project that never references the SDK, because the
SDK assembly has internal types of the same names as the generated
parser; and the runtime package stays below 4.10.

It also keeps what the parser taught: a sign directly before a digit
belongs to the number, and three distinctions of a tree are not in its
text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#58 is the first stage of the translator: it brings a quotation into
its normal form. The record states where the implementation departs
from the design and why: one walk instead of rounds, with recursion
found exactly and two limits; substitution with care for a variable
that is bound at two nested places, which Expr.Substitute gets wrong;
the mutable copy of a struct that the compiler binds; what stays
although it has a reflected definition; and the passes that wait for
the registry.

It also records what the stage costs and where the time goes in
FSharp.Core, with the proposal that follows for the plan cache, and how
the tests are built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#59 computes the parts of a quotation that read no variable. The record
states what the step found: 37 operators of FSharp.Core that reflection
cannot call and the functions that stand in for them, the checked
operators whose witnesses are unchecked, how enumeration values are
computed, why the precomputed forms of the FSharp.Reflection functions
are used, and how an exception of the caller's code is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result of the third step of phase 1 (#60): which values of a
quotation are parameters, what its shape key holds, and where the
implementation departs from section 11. A literal is a part of the
key, a value that the quotation builds is not computed, a branch that
is not taken is not computed, and the key is the key of the normal
form.

Also what the review of #59 changed in the two steps before: the
normalizer does not read the branch that a constant condition does not
choose, and the evaluator computes the members of a nullable, raises
NullReferenceException for a function that is null and computes a
coercion as the cast it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cost model says that a quotation literal is unpickled on every
evaluation. That is reported to dotnet/fsharp as issue 20764 now, so
the ADR names the issue where it states the cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fourth step of phase 1 (#61) has the first two members of
IJsonLayout and one implementation, JsonLayout.ofSerializerOptions.
The record says where it departs from section 10: the serializer is
asked for its contract instead of having its configuration read again,
so one function describes System.Text.Json with and without
FSharp.SystemTextJson; plain System.Text.Json options are described,
not refused; and what the two serializers were found to write for a
member without a value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the reviews of #58 to #60 on the evening of 2026-10-11 changed:

- the normalizer inlines an inline member of a type, whose definition
  the compiler keeps for the member itself and not for its form with
  witnesses, and applies a definition only to arguments it takes;
- the evaluator computes a node once, for one part and for a caller
  that keeps what was computed;
- the parameterizer tells a function by a class made of the type of
  functions too, and Query.constant takes only a value that a literal
  can be.

The counts of tests and of planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ADR 0001

Hedgehog 2.0.4 cannot generate the two types, and
hedgehogqa/fsharp-hedgehog#488 adds the generators upstream. Until a
release has them, the adapter of this repository registers the same two
for the parameters of a property (#43). The record of the adapter says
so, and when the stand-in goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The normalizer reports the outermost construct that a query cannot
express also when the rewriting fails inside it, and an inner binding
of a variable hides an assignment from the outer one (#58). The layout
reads the obsolete setting that ignores null, which both serializers
still follow (#61). The counts of tests and planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call of a function with a reflected definition gets nodes of its
own (#58). The type that a value is declared with decides whether it
is a literal, so a nullable and an option are none (#60). In the
layout an override has no condition of the declaration it overrides, a
property of a record that is not public is not written, and a value
whose converter writes null itself is not described (#61). The counts
of tests and planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A receiver that is null is reported after the arguments of the call
are computed (#59). A record whose converter an attribute of the
application makes is not described, and a read-only member with a rule
that code has set is not taken to be left out (#61). One node at two
places stays one value and one slot: the record says why, and what
would change it.

Found while the next step was built, and fixed in #61: a read-only
member is a collection, which the serializer writes all the same, by
the converter that writes it, and that can be one the member names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A call of a function that calls itself stays a call and is an error
only when it is left in the normal form and reads the document, so
code that never runs may hold one however the constant gets to its
condition (#58). The layout is asked about a member together with the
type it is read from, because a quotation names an inherited property
as its declaring type has it, and a union with a case that is null is
left out where a null is left out (#61).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The default value of a struct is its zeroed value, for which no
constructor runs, also when a struct of another language declares a
constructor without parameters (#59).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result of the step that describes how enumerations, tuples,
collections and maps are written (#62), and where it departs from the
sketch of section 10: every question takes the member, one case says
that the layout does not know, an enumeration is written to see how it
is written, and a struct tuple that is written as an empty object is
an object whose items have no names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record of the layout step gains what the seventh review of it
found: the extension data has no absence that the layout describes, a
member of a record that is not written has its declared name and is
null or left out, and a rule that code sets to null cannot be told
from no rule, which stays a known limit.

The eighth review, of the values, found three things, two of them in
the layout step: only the factory of FSharp.SystemTextJson itself
shows the options of a record, every question is answered once also
when several threads ask at a time, and an enumeration is asked for
every value that has a name, so that a converter which writes them in
two ways is not described.

A planted defect that no test failed for any more led to two more rules
about a resolver of the application, which can make a contract from any
converter: a record is described only when the serializer made its
contract, and the contract of a member with a converter of its own is
asked by the rules of the serializer, which writes such a member with
its converter whatever the resolver makes for the type.

The counts of tests and planted defects of both steps follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result of the sixth step of phase 1 (#63): IJsonLayout answers how
a case of a union is written, as a mark that tells the case and a
place for each of its fields, both made of steps from the value of the
union. The record says where this departs from the sketch of sections
10 and 20.9, what FSharp.SystemTextJson really does, what is not
described, and how the step is tested.

The ninth review found two things. A node that a quotation comes with
at several places, a quotation that is spliced twice, was one node in
the normal form only as long as nothing was rewritten in it or around
it; the normalizer keeps it one node now and walks the quotation once
for such nodes, which is measured. And a reference or a nullable of a
member that names a converter is described as any other member: the
serializer leaves its null out, or writes it, before it asks the
converter.

The counts of tests and planted defects follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the normalizer (#58): a function that is applied to the document is a
  captured compiled one whenever it reads no variable of the quotation,
  however the quotation gets to it, and a quotation that is nested too
  deeply stays the error inside a construct whose own error would print
  it;
- the parameterizer (#60): the null and the default value of a function
  type are refused as a function is;
- the layout (#61): the layout asks the resolver that the serializer
  asks, and why its modifiers are not copied;
- the values of the layout (#62): the encoding of an enumeration is a
  kind of JSON value, and what stands for one value in a query is a
  requirement on the translator step;
- the unions of the layout (#63): why the converter of the unions
  without fields never writes a case that is null.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the parameterizer (#60): the null of a union type is a case of the
  union, which the compiler writes as a case, so as a value it is data
  and no null literal;
- the layout (#61): what is written for a null is asked of the
  serializer, because three converters of FSharp.SystemTextJson ask
  for the null and fail on it; a null in a field of a record is left
  out by the rule of the library, which a union that is written as the
  value it wraps follows too; and a type without a contract is not
  described;
- the values of the layout (#62): a converter of an enumeration that
  refuses a value with either exception leaves it not described;
- the unions of the layout (#63): a record that a case unwraps is
  described only when the converter of the union makes the converter
  of the record.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the normalizer (#58, raised on #60): the default value of a boolean,
  which the compiler writes for bool (), is the constant false, so the
  branch that it does not choose is gone from the normal form and the
  parameterizer computes nothing of it;
- the values of the layout (#62): a converter of tuples is the one of
  FSharp.SystemTextJson only when its class is the library's, because
  an application can derive a converter from the public base class;
- the unions of the layout (#63): no case of a union is described when
  one of its cases is written with the name of the tag twice, for the
  fields or for one field next to an internal tag.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maintainer decided to build the JSON 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
FSharp.SystemTextJson (#61, #62, #63) is shelved, and what those steps
verified stays in the record under a heading of its own.

- the decision table (section 2) and the key decisions get the scope of
  the layout;
- section 10 says that the first layout is narrowed, and section 16 has
  the result of the narrow one (#65): the set-ups that it supports and
  refuses, the answers that it gives, and where it departs from the
  design;
- the record of the application's own converter of records, which the
  layout does not know, and the two ways to describe its records;
- the paragraph on dotnet test through FAKE says that FAKE 6.2.0 has
  DotNet.testMTP and that #64 replaces the hand-written call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What the review of the open pull requests of phase 1 changed, with the
pull request each point belongs to:

- the parameterizer (#60): a number that is not finite is no literal,
  because the text of a query has no NaN and no infinity, so such a
  number that code builds into a quotation is a slot and Query.constant
  of one is reported;
- the layout (#65): a record that writes two of its members under one
  name is not described, nor is a union whose cases share a tag, and
  next to an internal tag a case that writes the members of a record
  that the layout does not describe is taken to write the name of the
  tag twice.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
What the second review of the open pull requests of phase 1 changed,
with the pull request each point belongs to:

- the parameterizer (#60): the default value of a type is a literal
  only where the type has one, as the zero of a number has; the default
  value of a date, a guid, a nullable, a union or a struct is data, so
  it is a slot and the translator is never asked to write it into the
  text of a query. The type tells that, and no value is made to ask it,
  because a struct can have a constructor that a default value never
  runs;
- the layout (#65): the values of an enumeration that have no name are
  asked of the serializer too, the smallest number that no constant has
  and a combination of two constants that is no constant, so a
  converter that writes them in a way of its own makes the enumeration
  not described; options with a policy for the keys of dictionaries are
  refused, because the policy spells the property that a key is written
  as, which the layout does not say; and a case with two fields that a
  naming policy spells alike is not described, as the document holds one
  property for both; options that write the numbers that are not finite
  as texts are refused too, and the reasons for a reference handler name
  both of its kinds. That the layout does not tell a scalar that a
  converter of the application writes is recorded as a requirement on
  the translator step; and a collection or a dictionary with derived
  types is not described, because a value written as one of them is an
  object with the discriminator.

The counts of tests and planted defects follow the branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
hedgehogqa/fsharp-hedgehog#487 is merged and Hedgehog.MSTest 1.0.0 is on
NuGet, so the repository does not carry a copy of the adapter: the pull
request that ported it (#43) is closed, the two adapter projects are not
added, and the test projects reference the package. The record says
which commits of #43 live on in #50 and #54, what the property tests of
the stack run on now, and that Hedgehog 2.0.4 still cannot generate
DateOnly and TimeOnly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Section 23 is new. It designs normalized members: a query compares a
member through a computed property, and the translator reads the
formula of that property from the container. The section keeps the
decisions of the maintainer (the container holds the formula, all
operations by default), the container schema, the translation table,
the scenarios, how to change a definition and the emulator results of
2026-10-17. The details of the application are left out.

The other sections change where the new facts contradict them:

- The container members have no `Query` prefix (§12, §15).
- Property tests run through the released Hedgehog.MSTest package, and
  the text says that no property writes to the database (§2, §14, §17,
  §18, §19, §21.7, §22).
- SDK 10.0.402 does not contain the compiler fix for
  dotnet/fsharp#20466 (§2, §17, §21.7).
- Section 21.5 records the two C# projects of the application and the
  FSharp.Core fixes that make them unnecessary.
- The tuple gaps link their upstream issues, among them
  dotnet/fsharp#20800 and dotnet/fsharp#20801, and the build tool
  links #68.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from 0f7a831 to 077ceb5 Compare October 18, 2026 23:13

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants