Skip to content

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

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

xperiandri wants to merge 29 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 and others added 9 commits October 11, 2026 12:43
Records the agreed architecture of the FSharp.Azure.Cosmos.Quotations package
family as an Architecture Decision Record under a new documentation category,
followed by the full design document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a Delivery row to the decision table: Phase 0 lands as one pull
request per step from main, and this record lives in docsSrc/ADRs and
is updated in the same pull request as the decision it records.

Strike the "branch in flight" risk: the maintainer decided on
2026-10-02 that origin/copilot/support-throughput-bucket-operation-again
is not needed, so Phase 0 starts from main and the extraction of
tests/Cosmos.Tests.Infrastructure has nothing to wait for.

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

The record gated IcedTasks on a Phase 0 Debug-build probe and left the
partition cost of a container as an open probe. Both have now run, and
the record has to say what they found so that later steps do not rely
on the old assumptions.

The IcedTasks probe fails in Debug: with SDK 10.0.401 a Debug build
compiles no resumable state machine, and in the dynamic path
cancellableTask, cancellableValueTask and coldTask share one state
machine between all starts of a value. valueTask and valueTaskUnit pass
everywhere, and Release passes everything. Library code therefore keeps
task, and the two follow-up questions (scoping the gate per builder,
reporting the defect upstream) are added as open items with defaults.
The IcedTasks package facts are also corrected from the 0.11.9 nuspec:
net9.0 and both netstandard targets, FSharp.Core 6.0.4 (9.0.300 for the
net9.0 build), and Microsoft.Bcl.AsyncInterfaces for netstandard2.0.

On the Windows emulator a container costs one partition and a database
none, an emulator can hold fewer containers than the default budget,
and the budget and the leftover sweep are per process, with the sweep
limited to old fsac-test- databases so concurrent runs stay safe.
Whether the default budget should drop below 25 is added as an open
item.

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

Microsoft's ValueTask rules restrict one instance; the probes start the
cancellable function value twice and await each returned instance once,
and the failure reproduces with the Task-returning cancellableTask and
coldTask. IcedTasks itself documents these builders as multi-start.
Also record the answers on reporting it upstream and on the emulator
partition budget.

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

The maintainer scoped the IcedTasks gate per builder: valueTask and
valueTaskUnit pass every probe in Debug and Release and replace the
feed iterator's hand-rolled ValueTask wrapper first, while the cold and
cancellable builders stay out of library code until their probes pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FSharp.Collections.ParallelSeq wraps Parallel LINQ in an F# API. It fits
bulk, CPU-bound work over many independent items (the precompilation
tool, plan-cache warm-up, test tooling), not the per-query hot path,
and each use has to be justified by a benchmark first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#39 moved the emulator fixture into tests/Cosmos.Tests.Infrastructure
and added the partition budget, the database identifier and the
leftover sweep, so the ADR's paths into tests/Cosmos.Tests and its list
of what the repository still lacked were out of date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Our TheAngryByrd/IcedTasks#67 fixed the multi-start defect the Phase 0
probes found and shipped in IcedTasks 0.11.10-beta001. With it every
probe passes in Debug and Release, so the gate now covers valueTask,
valueTaskUnit, cancellableTask and cancellableValueTask, and the risk
is resolved except for the prerelease dependency, recorded as an open
point: a stable release of this library waits for IcedTasks 0.11.10.

The execution section no longer says that Phase 0 did not reach the
gate: it names the builders the gate allows and records that #41
converted both hand-rolled wrappers. The Phase 0 result describes the
0.11.9 outcome in the past tense and counts the 30 probes over the four
builders they cover; there are no coldTask probes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#44 asks the Windows and the vNext emulator how they evaluate the
constructs the translator relies on. The record now states their
answers where it waited for them: the comparison table behind guard
elimination, ?? keeping null, INTDIV and INTMOD truncating toward zero,
a returned infinity failing the query, a parameter as case flag,
predicate and indexer, STRINGEQUALS over non-strings, case folding per
emulator, object equality without property order, and ORDER BY over
missing values. A Phase 0 result paragraph lists what is settled and
what is still open.

Two rules change. The re-nesting table is written from the results:
TOP, ORDER BY and OFFSET LIMIT are rejected in every subquery, while a
filter outside a GROUP BY subquery stands in for HAVING, so a where
over the projected groups is no longer rejected. The ObjectToArray
lowering for map members used kv IN ObjectToArray(...), which is a
syntax error, and now iterates a subquery instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri and others added 5 commits October 11, 2026 12:43
On the Windows emulator the spatial functions accept GeoJSON parameters,
the form planned for captured geometry values, and ST_DISTANCE measures
in metres on the WGS-84 ellipsoid. The vNext emulator has no spatial
functions: one in the projection fails with 500, and a spatial filter
gets an answer the SDK cannot read, so spatial tests need the Windows
lane. With that, no query-semantics probe of Phase 0 is left open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch 4 times, most recently from 8965bd9 to d98e2ad Compare October 11, 2026 18:18
xperiandri and others added 2 commits October 11, 2026 21:37
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>
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from d98e2ad to 6ace353 Compare October 11, 2026 19:40
xperiandri and others added 3 commits October 11, 2026 23:06
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>
@xperiandri
xperiandri force-pushed the docs/quotations-architecture-adr branch from 6ace353 to 354ef05 Compare October 11, 2026 21:06
xperiandri and others added 3 commits October 11, 2026 23:30
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>

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