Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and Advanced Tasks 1-15. The design assumes 19 Stable + 12 Advanced Gradle projects under modules/mongodb*. This repository's fail-closed registry declares exactly 19 leaf identities, so those modules become package boundaries inside the registered leaf :adapter:outbound:persistence-mongo, with the design's module dependency table enforced by ten ArchUnit rules. The mapping and every deviation are recorded in docs/mongodb/repository-adaptation.md. Contract highlights, all enforced by tests rather than convention: - Transaction body retry and commit retry are separate loops. A new session per body attempt; commit-only retry on an unknown commit. The body is never replayed after a commit ambiguity, so a failover cannot become a duplicate. - MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and failure, and MongoFailureContext records only the design-permitted fields. - Failure classification reads server error labels before numeric codes. - BSON representations come from a pinned manifest, never a library default, and a golden type-signature gate fails on any drift. - Index and validator changes go through the manifest and the admin plane; metadata ownership gates every drop. - Every Advanced capability refuses construction unless its flag is enabled. Verified against real servers, not only unit tests. Running the lanes for the first time exposed four defects that a green `check` had hidden: - Four release lanes passed while executing zero tests; the gate now counts executed tests per lane and fails on zero. - The "single replica set" fixture was a standalone, because Testcontainers 2.x needs withReplicaSet(); its test only asserted a connection string. - The three-node fixture was three independent clusters, so no election could occur, and awaitNewPrimary() compared against the post-stop primary. - The migration lease checked modifiedCount, so a same-millisecond refresh read as a lost lease. scripts/verify-mongodb-platform.sh now reports: 9 lanes, 0 skipped, 0 failed, every evidence category produced. scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology evidence (real sharded cluster, real KMS, real target deployment) is unobtainable here, so it is named rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
59 lines
3.3 KiB
Markdown
59 lines
3.3 KiB
Markdown
# ADR-MONGO-002 — BSON representation is a pinned manifest
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-13
|
|
- **Design source:** design §10, decision D-06
|
|
|
|
## Context
|
|
|
|
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
|
|
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
|
|
changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID`
|
|
can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`.
|
|
|
|
The consequences are asymmetric. A representation change is invisible in a value-equality test —
|
|
`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds
|
|
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
|
|
Java representation byte-swaps two halves of the UUID, so a document written under one representation
|
|
and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong
|
|
record.
|
|
|
|
## Decision
|
|
|
|
`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and
|
|
`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library
|
|
default.
|
|
|
|
| Java | BSON | Rationale |
|
|
|---|---|---|
|
|
| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
|
|
| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. |
|
|
| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. |
|
|
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. |
|
|
| `LocalDate` | declared per field | A calendar day is not an instant. |
|
|
| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. |
|
|
| `enum` | `String` name | Ordinals renumber when someone inserts a constant. |
|
|
|
|
Type metadata follows `MongoTypeMetadataPolicy` — `NONE`, `ALIAS` or `CLASS_NAME`. A
|
|
`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes
|
|
a package rename a data migration.
|
|
|
|
The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document,
|
|
preserving BSON types and keeping missing distinct from null, and
|
|
`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry
|
|
pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default
|
|
is the exact drift the gate exists to catch.
|
|
|
|
## Consequences
|
|
|
|
**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact.
|
|
UUIDs read back as themselves. Class moves stay refactors.
|
|
|
|
**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a
|
|
migration. A new mapped type needs a manifest entry before it can be used. This is the intended
|
|
friction: the alternative is discovering the change in production.
|
|
|
|
**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate
|
|
protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as
|
|
hex, and missing and null both disappear.
|