[3.2.0] — 2026-08-26 — Ledger Wire Contract (repairs 3.0.0 / 3.1.0)
Upgrade if you use OpenSearch or MongoDB. Two defects shipped in 3.0.0 and persisted
through 3.1.0, both from the same root cause: the ledger's identity on the wire — which
index a document lives in, what its fields are named — was allowed to depend on
configuration the library does not own.
- OpenSearch was broken outright. Every migration run that discovered at least one
migration failed before contacting the cluster, including through the shipped runner and
CLI. Not AWS-specific. - MongoDB failed silently. Squash reconciliation matched nothing, so squashed
migrations re-ran on every invocation with no error to notice.
Both are fixed with no consumer action and no data migration. Couchbase carried a latent
form of the same coupling and is now pinned. The release also adds a ConnectionSettings
escape hatch on the OpenSearch client factories, which is purely additive — source- and
binary-compatible with 3.1.x.
Added
-
OpenSearch:
configureSettingsescape hatch on both client factories.
AddOpenSearchClientandAddOpenSearchAwsClient(and bothIConfigurationoverloads)
gain anAction<ConnectionSettings>parameter, applied last — after the endpoint
and authentication wiring — so a consumer can override anything the library set.Purely additive: source- and binary-compatible with 3.1.x. This ships as four new
overloads rather than as an appended optional parameter. Appending one would have been
source-compatible but not binary-compatible: it changes the existing method's signature,
so the 3.1.x entry point stops existing and any assembly compiled against it throws
MissingMethodExceptionuntil recompiled. A minor release must not do that. The four
3.1.x signatures are preserved exactly and forward to the new ones, and a reflection
test pins all eight so the guarantee cannot regress silently.OpenSearch is the only provider whose client the library constructs; the other four
resolve a consumer-registered client and already have full control. Until now the only
way to reachConnectionSettings— forRequestTimeout,MaximumRetries,
EnableHttpCompression, a proxy,ServerCertificateValidationCallbackon a
self-signed development cluster,DisableDirectStreamingwhile debugging, or a
DefaultMappingForover your own document types — was to stop calling the factory
and hand-roll the registration, forking the auth-mode switch, the AWS-endpoint
loud-fail, and the mutual-exclusion guard along with it.Because the hook makes it reachable, registration now validates one ledger property
through the configured inferrer and fails with a pointed message if field-name
inference is no longer camelCase — the ledger index carries astrictcamelCase
mapping that such a change would otherwise break at first write with an opaque
strict_dynamic_mapping_exception. Validation runs only when a hook is supplied.This is not the remedy for the OpenSearch
_mgetdefect below; see ADR-0029.
Fixed
-
OpenSearch: every migration run failed on a stock client (regression, v3.0.0–v3.1.0).
IntersectWithAppliedAsyncissues one_mget, which carries an index in the URL and
in each body entry. The URL index was set explicitly from
OpenSearchMigrationOptions.LedgerIndex, but the body entries were left to resolve via
IndexName.From<OpenSearchMigrationRecord>()— CLR-type inference that reads
ConnectionSettings.DefaultMappingFor<T>()/DefaultIndex(). Neither
AddOpenSearchClientnorAddOpenSearchAwsClientconfigures either, so request
serialization threwIndex name is null for the given type and no default index is set
before any byte reached the wire.MigrationRunner.RunAsynccalls
IntersectWithAppliedAsyncunconditionally whenever at least one migration is
discovered, so this broke every OpenSearch run — including through the library's own
Hyperbee.MigrationRunner.OpenSearchand the CLI. The_mgetnow sets the ledger index
per operation. No API change; no consumer action required. Consumers who worked around
this by declaring their ownDefaultMappingFor<OpenSearchMigrationRecord>or by forking
a client factory can remove both.Affects
Hyperbee.Migrations.Providers.OpenSearchand
Hyperbee.Migrations.Providers.OpenSearch.Aws3.0.0 and 3.1.0. Not AWS-specific. -
MongoDB: squash reconciliation silently covered nothing (regression, v3.0.0–v3.1.0).
IntersectWithSquashedAsyncbuilt its filter half typed and half literal — a typed
expression forKind(which renders through the BSON class map, producingKind) and
a raw string for"replaces"(which renders verbatim). The driver's default element
name is the member name, so the writer storesReplaces; the rendered filter
{ "Kind": 1, "replaces": { "$in": [...] } }could never match. The method returned an
empty set for every input, so a squash was never recognized as covering its replaced
versions and those migrations re-ran. The failure was silent because an empty set
is also the correct answer whenever nothing is squashed. Both terms are now typed.No wire change: the fix corrects the query to match what the writer already produces.
Deliberately not fixed by pinning element names with[BsonElement]or a registered
class map — pinning would orphan any deployment whose consumer registered a global
naming convention. -
Couchbase: ledger documents now serialize through a pinned serializer.
IntersectWithSquashedAsyncmust name ledger fields as text (m.kind,m.replaces)
because N1QL has no typed field reference, but ledger documents serialized through
ClusterOptions.Serializer— consumer-owned configuration. A consumer registering a
System.Text.Json serializer, or a Newtonsoft one without the camelCase resolver, wrote
Kind/Replacesand the squash query silently matched nothing, with the same
re-run consequence as the MongoDB defect above. Ledger KV reads and writes now use a
library-ownedDefaultSerializerin its default configuration.Behavior note: this is byte-for-byte identical for consumers on the stock Couchbase
serializer, which is the default. Consumers who set a customClusterOptions.Serializer
will see new ledger rows written in the canonical camelCase shape. Existing rows stay
readable by key (ExistsAsync,IntersectWithAppliedAsyncare key-based and unaffected);
ReadAsyncon a row written under a custom shape may return nullRunOn/Checksum,
which can cause one extra cron evaluation. Squash reconciliation, which was broken for
this configuration, starts working.
CI
-
Integration tests gate PRs again. Since
7ff3808(twelve days before 3.1.0) the
repository has had zero integration coverage in CI, and no working way to restore
it. Tagging every container-spinning class[TestCategory("LocalOnly")]left the CI
job's negative filter (TestCategory!=LocalOnly) matching nothing;dotnet testfails
on a no-match, so the whole job was gated behind repo variable
RUN_HEAVY_INTEGRATION— which was never created, and whose documented "re-enable with
no code change" path provably does not work (the filter still excludesLocalOnly, so
flipping the variable un-skips a matrix that matches zero tests and fails). Both defects
fixed in this release shipped through that gap.Every integration class was then measured against explicit criteria rather than
assumed heavy. Every integration test in the repository now runs automatically:-
115 tests on every PR (~2 min) — a
Gatingtier selected positively on
TestCategory=Gating, across OpenSearch (88), MongoDB (10), Postgres (9),
Aerospike (6) and multi-provider (2). No Docker image builds in the PR path, so
the MCR/Azure-Front-Door pull-throttle failure class is removed there rather than
mitigated. A guard step fails the build by name if a cell matches zero tests, so
the trap cannot silently recur. -
12 tests on manual dispatch — the
LocalOnlyremainder: the provider runner
suites plusCliBinaryEndToEndTests(Docker image builds) and 3 Couchbase squash
classes. A further 6 multi-node tests stay on their existing manual workflow. -
3 tests quarantined as
[TestCategory("Flaky")]. The
Should_Fail_WhenMigrationHasLocktest in the Aerospike, MongoDB and Couchbase
runner suites asserts on a race — it starts concurrent runner containers and
requires one to observe lock contention, which does not happen on a fast host.
Aerospike and MongoDB were measured failing with no defect present; the Postgres
equivalent had already been commented out by an earlier author for the same reason.
Excluded from automation so the post-merge signal stays trustworthy, and recorded
as debt rather than quietly disabled.All 136 integration tests are accounted for; none are orphaned. Couchbase is the only exclusion on speed: its six
tests pass but take 5–6.5 minutes, five to six times every other cell combined,
becauseIsolatedCouchbaseContainerwaits out the GSI indexer's initial rebalance.
RUN_HEAVY_INTEGRATIONis removed, replaced by a Heavy Integration Tests workflow
that runs on demand for any provider and framework — a trigger that, unlike the repo
variable, actually works. A push-to-maintrigger was built and then removed before
shipping: measuring the suite first showed it is not dependably green (an intermittent
Aerospike runner test, plus the quarantined race above), and automating an unreliable
suite produces a signal people learn to ignore. Enabling it is three commented lines
once those are fixed; tracked in ADR-0031. -
Decisions
- ADR-0029 — the ledger's
wire contract is library-owned, never inherited from consumer-configured client
inference. Rule 1: ledger requests carry their target explicitly. Rule 2: every
reference to a ledger field routes through the same serialization path as the writer.
Rule 3: a wire-test tier between mock-tier and container-tier. - ADR-0030 —
ConnectionSettings
escape hatch on the OpenSearch client factories - ADR-0031 — three test tiers
(unit / wire / integration), positive CI selection onTestCategory=Gating, and the
LocalOnlysplit; amends ADR-0010
Tests
- New wire-shape test tier for the OpenSearch record store
(OpenSearchLedgerWireTests): the real client and real serializer over an
InMemoryConnection, so request construction and serialization actually execute with
only the socket faked. This is the tier that was missing — the existing provider unit
tests substituteIOpenSearchClient(a substitute never serializes) and the
container-backed tests are compile-gated and excluded from CI. Includes a generalized
probe asserting that no ledger operation depends on type→index inference, which
guards the next regression rather than only this one. - Two OpenSearch record-store integration tests for
IntersectWithAppliedAsync, one of
which drives the client produced byservices.AddOpenSearchClient(...)— previously
nothing exercised the shipped registration path end-to-end. MongoDBLedgerWireTestsandCouchbaseLedgerWireTests— render the real queries the
record stores issue and compare them against the real serializer output, with no mock
and no container. The MongoDB assertions are convention-independent on purpose: they
assert that the field a query asks for is the field the writer wrote, not that a field
has a particular casing, so they still pass for a consumer who registered a camelCase
convention. Pinning a literal casing would have made the tests agree with the bug.MongoDBRecordStoreIntegrationTests— squash and applied reconciliation against a real
MongoDB.IntersectWithSquashedAsyncpreviously had no coverage at any tier.OpenSearchConnectionSettingsHookTests— the hook reaches the resolved client on both
registration paths, runs after auth wiring (a consumer override wins), stays optional,
and loud-fails on a ledger-breaking field-name inferrer. Includes an ADR-0029
cross-check that a consumerDefaultIndexdoes not become the ledger's index.