merge: integrate the MongoDB document persistence platform

Brings in the mongodb-superpowers-package implementation (Stable Tasks 1-50,
Advanced Tasks 1-15) as packages inside the registered leaf
:adapter:outbound:persistence-mongo, with the design's module dependency table
enforced by ArchUnit.

Shared build files are untouched by this branch: src/build.gradle,
src/settings.gradle, config/architecture/modules.json and
app-bootstrap/build.gradle are all unchanged, so this merge does not move the
19-leaf registry and does not collide with the other platform branches still in
flight.

Verified before merging: scripts/verify-mongodb-platform.sh reports 9 lanes,
0 skipped, 0 failed, every evidence category produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 13:46:19 +09:00
co-authored by Claude Opus 5
430 changed files with 29846 additions and 154 deletions
@@ -0,0 +1,63 @@
# ADR-MONGO-001 — MongoDB platform boundary
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
## Context
Two failure modes are common when a team wraps MongoDB.
The first is flattening: a shared `CommonMongoRepository<T, ID>` and a generic CRUD facade, which
forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's
single-document atomicity, aggregation model and change streams stop being reachable, and the first
collection that needs something different gets a cast or a leaky generic.
The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any
service can drop a collection, run an unbounded pipeline, or issue an admin command from a request
thread, and no review catches it because there is nothing structural to catch.
## Decision
The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes:
| Plane | Contents | Client |
|---|---|---|
| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` |
| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` |
| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client |
| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential |
Specifically:
1. **No `CommonMongoRepository<T, ID>`.** Each aggregate declares its own repository.
2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails
at development time instead of on the next server upgrade.
3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered
→ database profile → collection allowlist → operation name → timeout → consistency profile →
result limit → trace → redaction → command category → admin-command refusal → execute.
4. **D4 is a separate client with a separate credential.** No application-plane path reaches it;
`PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability.
5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the
Stable surface.
## Consequences
**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed
for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit
trail and credential.
**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of
configuration rather than zero. A genuinely new capability requires a registration before it can be
used. Both are deliberate: the cost is paid once per operation, at review time.
**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every
query in every service, and the operations that matter (unbounded pipeline, `dropCollection`,
unanchored regex on user input) look unremarkable in a diff.
## Repository adaptation
The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry
declares exactly 19 leaf identities, so the modules became package boundaries inside
`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See
[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md).
@@ -0,0 +1,58 @@
# 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.
@@ -0,0 +1,68 @@
# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §14–§16, decisions D-07 through D-10
## Context
MongoDB reports two transaction failures that look similar and must be handled in opposite ways.
`TransientTransactionError` means the transaction definitively did not commit. The correct response is
to run the whole thing again.
`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the
primary changed while the commit was in flight. The correct response is to retry *the commit*, which
is a no-op if it already succeeded.
The common implementation wraps everything in one retry loop. That loop replays the body after an
unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path
that is a duplicate charge or a duplicate message, produced by the error handler.
The related trap is session reuse: retrying on the same session after an abort carries the aborted
transaction's state into the retry.
## Decision
`MongoTransactionRetryCoordinator` implements two loops with different scopes.
```
for each body attempt within the budget:
open a NEW session
run the body
TransientTransactionError -> abort, continue to next body attempt
commitWithRetry(session):
UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session
```
Rules that follow, all of them load-bearing:
1. **A new session per body attempt.** No aborted state leaks into a retry.
2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a
distinct value from `BODY` precisely so this cannot be collapsed by accident.
3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with
jittered backoff, so a struggling primary is not retried into the ground by every instance at once.
4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An
ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not
happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md).
5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what
actually happened. A transaction that cannot be reconciled has no recovery path.
6. **Classification reads labels before codes.** Server error labels are the authoritative statement
about retryability; error codes vary by version.
Surrounding decisions that reduce how often this path is reached at all: single-document atomic
operations are preferred over transactions (D-09), partial changes use update operators rather than
`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08).
## Consequences
**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as
an ambiguity. The retry budget is bounded in both attempts and time.
**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must
write a marker they would not otherwise need. Both costs are small compared with reconciling
duplicated financial effects after the fact.
**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction
body to be fully idempotent, which is a much stronger and much less checkable property than writing
one marker, and it is silently violated the first time someone adds a non-idempotent step.
@@ -0,0 +1,62 @@
# ADR-MONGO-004 — Index and schema changes belong to the admin plane
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §21–§25, decisions D-11, D-13
## Context
Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a
collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO
and memory on the primary for minutes to hours, and it starts because a pod restarted.
Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to
the code that needs the index, and no record afterwards of what was created.
Schema validators have the same shape with a sharper edge: tightening a validator on a collection with
existing data rejects writes to documents that were legal when they were written.
TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a
minute and deletes in batches, so an expired document routinely remains readable for minutes or hours.
## Decision
**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic
index creation in production is disabled.
1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator
(`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull
request.
2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and
*changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently
rebuild an index whose definition moved.
3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF`
(staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit).
4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from
`SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are
droppable on drift. A diff engine without ownership eventually proposes dropping
`enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary.
5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden →
observed-unused → droppable, one deployment per transition. Hiding is instantly reversible;
dropping is a rebuild.
6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings →
`error`+`STRICT`, in two deployments.
7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a
document's presence is not authorization and its absence is not a deadline. Access control checks
the expiry field; scheduling uses a scheduler.
8. **Migrations are checksummed, locked, precondition-checked and resumable.**
`MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running
different code under one id is worse than a failed deploy.
## Consequences
**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at
every step. Drift is visible without being dangerous. Nothing drops what it does not own.
**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development
uses `APPLY` so the friction is confined to environments where it is warranted.
**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production,
which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a
second, divergent source of truth.
@@ -0,0 +1,79 @@
# ADR-MONGO-ADV-001 — Advanced capability promotion
- **Status:** Accepted
- **Date:** 2026-08-13
- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15
## Context
Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work
in a demo within an afternoon. What they do not do is behave the same way in production, and the
differences are not discovered by functional tests:
- Sharding changes which queries are efficient. A query that misses the shard key becomes
scatter-gather, which passes every test on a one-shard cluster.
- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation —
none of which occur against a local key provider.
- Search and vector search can be functionally correct and useless: the index returns results, and
the results are not relevant. Recall is not visible in a pass/fail assertion.
- Database-per-tenant works until the tenant count crosses what the connection and file-handle
budget supports, which is an operational property, not a code property.
The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that
never touched the environment where it will run.
## Decision
Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion
requires evidence, not confidence.
### Enablement
`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability
does not produce a runtime warning — the type refuses to be constructed, naming the property that
enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not
being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them.
### Promotion evidence
`MongoAdvancedPromotionGate.verify(evidence)` requires every category:
| Category | Means |
|---|---|
| `stable-platform` | The Stable release gate passed on the same revision. |
| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). |
| `security` | Privileges reviewed; the capability's admin role is separate from the application role. |
| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. |
| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. |
| `runbook` | A runbook exists for the capability's characteristic incident. |
### Additional per-capability requirements
- **Search / vector search:** relevance and performance evidence, not functional success alone.
`MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that
measures only latency certifies a fast wrong answer.
- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale
evidence exists. Both are correct in the small and unbounded in the large.
- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It
rewrites the collection.
### Promotion does not change the dependency boundary
A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the
dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive
dependency to every service.
## Consequences
**Positive.** No capability reaches production on the strength of a container-only test. The evidence
list is the same for every capability, so promotion is reviewable rather than negotiated.
**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the
target deployment. That is the cost of the guarantee: the alternative is finding out in production,
where encryption and sharding are both expensive to reverse.
## Verification
```bash
bash scripts/verify-mongodb-advanced.sh
```
+91
View File
@@ -0,0 +1,91 @@
# Advanced — CSFLE and Queryable Encryption
**Capabilities:** `MongoCapability.CSFLE`, `MongoCapability.QUERYABLE_ENCRYPTION`
**Properties:** `ca-skeleton.persistence-mongo.advanced.csfle.enabled`,
`ca-skeleton.persistence-mongo.advanced.queryable-encryption.enabled`
**Status:** Advanced.
## Requirements
| | |
|---|---|
| Topology | Replica set or sharded cluster. |
| Server | MongoDB 7.0 or 8.0 (see §4 for the 8.0 query-type limits). |
| Privilege | `MongoPrincipalRole.ENCRYPTION_ADMIN` for the key vault; the application role never holds it. |
| Environment | A real KMS and key vault. A local key provider does not exercise any of the failure modes that matter. |
## 1. CSFLE
`MongoCsfleProfile` binds a collection to its `MongoCsfleFieldPolicy` list, a key vault
`MongoCredentialReference` and the key vault namespace. `MongoCsfleClientFactory` builds the encrypted
client; `MongoDataKeyResolver` resolves data keys.
`MongoCsfleMode`:
| Mode | Queryable | Trade-off |
|---|---|---|
| `RANDOMIZED` | no | Same plaintext encrypts differently each time. The safe default. |
| `DETERMINISTIC` | equality only | Same plaintext always yields the same ciphertext, so equality works — and so does frequency analysis. |
| `UNINDEXED` | no | Stored encrypted, excluded from any index. |
`MongoCsfleFieldPolicy.forPii(field, queryable)` defaults to `RANDOMIZED` when the field is not
queried. Deterministic encryption requires a written `equalityQueryJustification`; the constructor
refuses a blank one, naming frequency analysis. A low-cardinality deterministic field (a status, a
country, a boolean) leaks its distribution to anyone who can read the collection, which is the party
encryption was protecting against.
## 2. Queryable Encryption
`MongoQueryableEncryptionProfile` binds a collection to `MongoEncryptedFieldDescriptor` entries.
`MongoQueryableEncryptionQueryType` has exactly two values:
- `EQUALITY`
- `RANGE` — must declare its domain (`min`, `max`). The constructor refuses a range field without one,
because changing the domain later means re-encrypting the field.
`MongoQueryableEncryptionCollectionManager` owns the collection's lifecycle, because a QE collection
is not just a collection: it carries metadata collections.
## 3. Metadata ownership
`MongoEncryptionMetadataOwnership` maps `customers` to `enxcol_.customers.esc` and
`enxcol_.customers.ecoc`, and reports `__safeContent__`-prefixed indexes as
`MongoMetadataOwnership.ENCRYPTION_MANAGED`.
These are never application-owned and never droppable by drift reconciliation. A drift tool that
drops `enxcol_.customers.ecoc` corrupts the collection's queryability. This is the single most
important integration point between encryption and
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md).
## 4. Unsupported combinations
Refused at declaration, not discovered at runtime:
| Combination | Why |
|---|---|
| CSFLE **and** QE on the same collection | Two incompatible encryption schemes over one namespace. Both profile constructors refuse it. |
| CSFLE on a time series collection | `requireNotTimeSeries(true)` raises `MongoOperationRejectedException`. |
| QE `prefix` / `suffix` / `substring` | Not available on the platform's 8.0 baseline. The factory methods throw `UnsupportedOperationException` rather than returning a profile that fails later. |
| Deterministic CSFLE without a justification | `IllegalArgumentException` naming frequency analysis. |
| Range QE without a declared domain | `IllegalArgumentException` naming re-encryption. |
## 5. Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| `MongoEncryptionException` on read | Wrong data key, or the key vault is unreachable | Check KMS reachability and the key vault credential. Data is intact; the client cannot decrypt it. |
| `MongoEncryptionException` on write | KMS permission revoked mid-operation | Restore the grant. Writes fail closed — nothing was written in plaintext. |
| Queries return nothing on a deterministic field | The field was re-keyed | Equality matching is over ciphertext; a new key produces different ciphertext. Re-encrypt the field. |
| QE queries fail after a drift reconciliation | A metadata collection was dropped | Restore from backup. This is why ownership gates drops. |
**Key rotation.** Rotating the customer master key re-wraps the data keys and does not require
re-encrypting documents. Rotating a *data* key does require re-encrypting every document that used
it. These are different operations with different costs, and confusing them is how a rotation becomes
an outage.
## 6. Promotion evidence
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md), promotion requires the
real KMS and key vault, plus negative cases that fail closed: wrong key, missing permission, rotation
mid-operation (`MongoAtlasCapabilityContractSuite.kmsFailureModes`). A local key provider certifies
none of these — it never rejects anything.
+63
View File
@@ -0,0 +1,63 @@
# Advanced — GridFS compatibility and migration
**Capability:** `MongoCapability.GRIDFS_COMPATIBILITY`
**Property:** `ca-skeleton.persistence-mongo.advanced.gridfs-compatibility.enabled`
**Status:** Advanced, compatibility only. Decision D-14.
## Position
GridFS is a **compatibility adapter for files that already exist there**. New files use the existing
Fileserver / Object Storage adapter, which is the source of truth for binary content.
The reason is not preference. GridFS stores file chunks in the same collections, on the same replica
set, competing for the same working set as your documents. A large file read evicts document pages
from cache, and file storage growth becomes replica-set growth — which means it becomes oplog
pressure, backup duration and failover time. Object storage was built for this and MongoDB was not.
## Reading legacy files
`MongoGridFsCompatibilityReader` reads existing GridFS content as
`GridFsLegacyContent(legacyId, filename, sizeBytes, checksum, stream)`. It reads; it does not write.
## Migration
`MongoGridFsMigrationJob` moves a file to object storage in a fixed order:
```
read legacy content
→ write to object storage
→ verify the target checksum matches the source
→ switch the reference
→ (later, separately) delete the source
```
Three properties, each of which exists because of a specific way this goes wrong:
1. **Verify before switching.** `MongoGridFsObjectReference` requires a non-blank checksum, and the
job returns empty and writes no reference when the target checksum does not match the source. A
migration that switches the reference on a successful *write* rather than a verified *copy*
silently points at a truncated object.
2. **The source is never deleted here.** Deletion is a separate, later decision after the new
location has been serving reads long enough to be trusted. A migration that deletes as it goes has
no rollback.
3. **The checkpoint separates migrated from failed.** `MongoGridFsMigrationCheckpoint` tracks
`migratedCount()`, `failedCount()`, `clean()` and `lastMigratedLegacyId()`, so a restart continues
from the last completed file rather than starting over, and a partially failed run is visible as
partial rather than as "done".
## Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| `migrate` returns empty | Checksum mismatch | The copy is bad. Investigate before retrying; do not force the reference. |
| `IllegalArgumentException` on the reference | Missing checksum | A reference without a checksum cannot be verified and is refused. |
| Checkpoint not `clean()` | Some files failed | Re-run for the failed ids only; the checkpoint names the last successful one. |
| Reference switched but content missing | Source deleted too early | Restore from backup. This is what rule 2 prevents. |
## Promotion evidence
Actual-topology evidence against the real object storage backend, a security review of the storage
credential, the migration path above, failure cases (checksum mismatch refused, missing checksum
refused, restart resumes), and this document as the runbook.
New file storage does not go through here at all — see the fileserver adapter.
+90
View File
@@ -0,0 +1,90 @@
# Advanced — Multi-tenancy
**Capabilities:** `MongoCapability.SHARED_COLLECTION_TENANCY` (Advanced),
`MongoCapability.DATABASE_PER_TENANT` (Experimental)
**Properties:** `ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled`,
`ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled`
## 1. Shared collection
Every tenant's documents live in one collection, discriminated by a tenant field.
`MongoTenantContext` carries the tenant. `MongoTenantPredicateInjector` adds the tenant predicate to
every query, every atomic filter and the **first** aggregation stage. `TenantScopedMongoOperations`
is the entry point, so a caller cannot construct an unscoped operation by forgetting.
Three details are load-bearing:
- **Injection, not convention.** A tenant predicate that each query is expected to add itself is a
cross-tenant leak waiting for one missed `where(...)`. The injector adds it structurally.
- **First aggregation stage.** `firstStageMatch(...)` places the tenant `$match` before anything else.
A `$lookup` or `$group` that runs before the tenant filter has already crossed the boundary, even if
a later stage filters the output.
- **An absent tenant is not "all tenants".** The injector takes an `Optional<MongoTenantContext>` so
the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears.
`MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes)` checks that every unique
index that should be per-tenant actually includes the tenant field. A unique index on `email` alone in
a shared collection makes an email globally unique across tenants — tenant B cannot register an
address tenant A already used, which is both a bug and an information leak.
`requireShardKeyAnalysed(...)` requires a shard-key readiness report before a shared-collection tenant
model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard.
### Observability
`tenantId` and `rawTenantId` are on `MongoObservationConvention`'s forbidden tag list. Cardinality
grows with the customer list, and the tag ships tenant identity into the metrics backend.
## 2. Database per tenant (Experimental)
`MongoTenantDatabaseResolver` maps a tenant to its database; `MongoTenantClientRegistry` holds the
clients.
Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant
database costs connections, file handles and monitoring cardinality. It works beautifully at 20
tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion
requires operational scale evidence.
`MongoTenantLifecyclePolicy`:
- `requireActivationReady(tenantKey, schemaAndIndexesValidated)` — a tenant is not activated until its
schema and indexes are validated. Activating first means the first customer request is the migration
test.
- `requireDeleteAllowed(...)` — deletion requires an explicit retention decision. Dropping a tenant
database is irreversible and takes the backup surface with it.
`MongoTenantMigrationCoordinator` runs a migration across tenant databases with per-tenant results.
Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is
not a report anyone can act on.
## 3. Choosing
| | Shared collection | Database per tenant |
|---|---|---|
| Isolation | Logical, enforced by injection | Physical |
| Tenant count | Unbounded | Bounded by connections and file handles |
| Per-tenant restore | Hard | Natural |
| Noisy neighbour | Shared resources | Isolated |
| Migration | One collection | N databases, partial failures |
| Cross-tenant query | Possible (and must be forbidden) | Structurally impossible |
Shared collection is the default. Database-per-tenant is for a small number of tenants with a
contractual isolation or per-tenant-restore requirement.
## 4. Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| Cross-tenant data visible | An operation bypassed `TenantScopedMongoOperations` | Treat as a security incident. Find the path, close it, audit access. |
| Unique constraint fires across tenants | Unique index missing the tenant field | Rebuild the index with the tenant field as prefix; the validator catches this before it ships. |
| One shard holds most data | Tenant id as shard-key prefix with a dominant tenant | Refine the shard key with a high-cardinality suffix. |
| Connection exhaustion | Database-per-tenant beyond the connection budget | The scale limit. Consolidate or move to shared collections. |
| Migration partially applied across tenants | Normal | `MongoTenantMigrationCoordinator` reports per tenant; re-run for the failures only. |
## 5. Promotion evidence
Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access,
a migration path, failure cases (injection proven on query, atomic filter and first aggregation
stage), this runbook. Database-per-tenant additionally requires **operational scale evidence** and
stays Experimental until it exists.
+81
View File
@@ -0,0 +1,81 @@
# Advanced — Search and Vector Search
**Capabilities:** `MongoCapability.SEARCH`, `MongoCapability.VECTOR_SEARCH`
**Properties:** `ca-skeleton.persistence-mongo.advanced.search.enabled`,
`ca-skeleton.persistence-mongo.advanced.vector-search.enabled`
**Status:** Experimental (design §3.3). Hybrid search likewise.
## Requirements
| | |
|---|---|
| Topology | A deployment with the search service. Atlas Local in a container is a pull-request convenience and is **not** release evidence. |
| Privilege | `MongoPrincipalRole.SEARCH_ADMIN` for index management; the application role queries only. |
| Gate | `MongoAtlasCapabilityContractSuite` on the actual target deployment. |
## 1. Created is not ready
`MongoSearchIndexState`: `CREATED``BUILDING``READY`, plus `FAILED` and `DELETING`.
`MongoSearchReadinessGate.requireReady(state)` refuses anything but `READY`. A search index is built
asynchronously: the create call returns immediately and the index answers queries with *partial*
results while building. Not an error, not empty — partial. A deployment that creates an index and
starts querying serves incomplete results for as long as the build takes, and nothing reports it.
## 2. Search indexes are not application-owned
`MongoSearchIndexDescriptor.metadataOwnership()` is `MongoMetadataOwnership.SEARCH_MANAGED`, and
`droppableByApplicationDrift()` is false. The index reconciliation described in
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md) must not drop it.
## 3. Query guardrails
`MongoSearchQuery` binds an index, an allowlist of paths, the search text and a result limit.
- `requireAllowedPaths(allowed)` raises `MongoOperationRejectedException` on a path outside the
allowlist. Without it, a caller can search any indexed field, including ones indexed for a
different purpose.
- Search text length and result count are bounded at construction. An unbounded search text is a
cost multiplier on someone else's service.
## 4. Vector search
`MongoVectorIndexDescriptor.cosine(path, dimensions)` declares the index.
`MongoEmbedding.forIndex(index, values)` binds an embedding to it and **rejects a dimension
mismatch** — a 1536-dimension embedding against a 768-dimension index is not a runtime degradation,
it is a category error, and catching it at construction beats catching it as a confusing server
message.
`MongoEmbedding` copies its backing array in and out. A vector that shares an array with its caller
can be mutated after the query is built, which produces a query nobody wrote.
`MongoVectorQuery` requires `numCandidates > limit` — searching 10 candidates to return 10 results is
an exhaustive scan wearing an ANN index's name. `MongoVectorQuery.nearest(embedding, 10)` uses the
standard 20× ratio (200 candidates for 10 results).
## 5. Relevance is the gate, not functionality
`MongoVectorSearchBenchmarkGate.standard()` requires **recall** alongside latency and index size.
`requiredEvidence()` names recall explicitly.
This is the difference between search and everything else in the platform. A vector index can be
functionally perfect — it accepts the index, accepts the query, returns k results, within the latency
budget — and return the wrong k. A gate that measures only latency certifies a fast wrong answer.
`failures(recall, latencyMs, indexMb)` reports which dimension failed so the finding is actionable.
## 6. Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| Incomplete results after a deploy | Queried a `BUILDING` index | Wait for `READY`. The gate prevents this; if it fired, something bypassed it. |
| `MongoOperationRejectedException` on a path | Path not in the allowlist | Add it deliberately, or fix the caller. |
| Dimension mismatch | Model changed | A new model means a new index. Build alongside, cut over, then retire. |
| Recall dropped without a code change | The index was rebuilt with different parameters, or the data distribution shifted | Re-run the benchmark gate; treat a recall regression like a failing test. |
| Index `FAILED` | Build error on the search service | Search-side diagnosis; the application must not fall back to a scan silently. |
## 7. Promotion evidence
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): the actual target
deployment (not Atlas Local), security review of `SEARCH_ADMIN`, a rebuild path, failure cases
(non-ready index refused, disallowed path refused, dimension mismatch refused), this document as the
runbook, **and** relevance evidence. Search and vector search do not promote on functional success.
+82
View File
@@ -0,0 +1,82 @@
# Advanced — Sharding
**Capability:** `MongoCapability.SHARDING`
**Property:** `ca-skeleton.persistence-mongo.advanced.sharding.enabled`
**Status:** Advanced. Reshard orchestration remains Experimental.
## Requirements
| | |
|---|---|
| Topology | A real sharded cluster. A replica set cannot exercise routing. |
| Server | MongoDB 7.0 or 8.0. |
| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. |
| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. |
## Shard key
`ShardKeyDescriptor` declares the key as an ordered list of `ShardKeyPart` plus a `ShardStrategy`:
| Strategy | Distributes | Cost |
|---|---|---|
| `RANGE` | by value ranges | Range queries stay targeted; a monotonic key (a timestamp, an `ObjectId`) sends every insert to one shard. |
| `HASHED` | by hash of the key | Inserts spread evenly; every range query becomes scatter-gather. |
There is no strategy that is good at both, which is why the choice is a declaration rather than a
default.
## Routing classification
`ShardAwareQueryValidator` classifies each query before execution:
| `MongoRoutingClassification` | Meaning |
|---|---|
| `TARGETED` | The full shard key is present. One shard answers. |
| `PREFIX_TARGETED` | A prefix of a compound key is present. A subset of shards answers. |
| `SCATTER_GATHER` | No shard-key predicate. Every shard answers. |
| `REJECTED` | Scatter-gather where the profile forbids it. |
A scatter-gather query is not an error — some queries legitimately need every shard — but it must be
declared. Undeclared scatter-gather raises `MongoShardRoutingException`. The reason is that
scatter-gather passes every test on a single-shard development cluster and only degrades once the
cluster grows, at which point the query is already in production and the fix is a schema change.
## Unsupported combinations
- Unique index on a field that is not a prefix of the shard key. MongoDB cannot enforce it across
shards, and it fails at index creation, not at query time.
- Transactions that touch documents on multiple shards remain supported but cost a cross-shard
two-phase commit. Prefer a shard key that keeps a transaction's documents co-located.
- CSFLE on a sharded collection: see [encryption.md](encryption.md) for the combinations that are
refused.
## Admin plane
`MongoShardingAdminGateway` (D4, `SHARD_ADMIN` credential) covers shard-collection, refine-shard-key
and reshard.
`ShardKeyAnalyzer` produces a `ShardKeyReadinessReport` before sharding a collection: cardinality,
frequency skew and monotonicity. A key with low cardinality creates jumbo chunks that cannot be split;
a monotonic key creates a hot shard. Both are visible in the report and invisible in a functional
test.
`ReshardApproval` is required for a reshard — a named approver and a stated window. Resharding
rewrites the collection: it duplicates the data during the operation and saturates IO. It is not a
runtime operation and the type refuses to pretend otherwise.
## Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| `MongoShardRoutingException` | Undeclared scatter-gather | Add the shard key to the predicate, or declare the query as scatter-gather in its profile after review. |
| Jumbo chunks | Low-cardinality shard key | Refine the shard key (adds a suffix, non-destructive) before considering a reshard. |
| One hot shard | Monotonic range key | Refine with a high-cardinality prefix, or reshard to hashed if range queries are not needed. |
| Balancer never converges | Chunk migration blocked by long-running operations | Check for long transactions and cursors; the balancer waits on them. |
## Promotion evidence
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): actual sharded-cluster
evidence, security review of the `SHARD_ADMIN` role, a migration path for an existing unsharded
collection, failure cases (undeclared scatter-gather refused, jumbo chunk detected), and this
document as the runbook. Reshard orchestration stays Experimental until operational scale evidence
exists.
+73
View File
@@ -0,0 +1,73 @@
# Advanced — Time Series
**Capability:** `MongoCapability.TIME_SERIES`
**Property:** `ca-skeleton.persistence-mongo.advanced.time-series.enabled`
**Status:** Advanced.
## Requirements
| | |
|---|---|
| Topology | Replica set or sharded cluster. |
| Server | MongoDB 7.0 or 8.0. |
| Privilege | Standard application role; collection creation goes through the admin plane. |
## Descriptor
`MongoTimeSeriesDescriptor` declares:
- **timeField** — required, a BSON date. This is the bucketing axis.
- **metaField** — optional but nearly always wanted: the series identity (device id, tenant, sensor).
Documents sharing a `metaField` value bucket together, which is where the compression comes from.
- **granularity** — `MongoTimeSeriesGranularity`:
| Granularity | Bucket span | Use for |
|---|---|---|
| `SECONDS` | 1 hour | Sub-second to per-second ingest. |
| `MINUTES` | 24 hours | Per-minute metrics. |
| `HOURS` | 30 days | Hourly rollups. |
Granularity that is too fine produces many small buckets and loses the compression; too coarse
produces oversized buckets that must be read whole to answer a narrow query.
## What a time series collection is not
`MongoTimeSeriesCapabilityValidator` refuses the operations the collection type does not support, at
declaration time rather than at first use:
- **No arbitrary updates.** Time series data is append-mostly. Delete and limited update support
exists on recent servers but is not part of this platform's contract.
- **No unique index on the measurement.** There is no `_id` to be unique on in the usual sense.
- **No CSFLE.** Refused — see [encryption.md](encryption.md).
- **No change stream on the raw buckets** as a business event source. The bucket documents are a
storage representation, not your measurements.
Converting an existing regular collection to a time series collection is a copy, not an alter. Plan
it as a migration with a dual-write window.
## TTL
Time series collections use `expireAfterSeconds` on the collection rather than a TTL index on a
field. The [TTL rules](../schema-index-migration-guide.md#4-ttl) still apply: expiry is physical
cleanup on a bucket boundary, so a measurement can outlive its expiry by up to a bucket span plus the
monitor interval. Do not treat absence as a deadline.
## Operations
`MongoTimeSeriesOperations` is the port for insert and windowed read. Reads are bounded by the same
`MongoOperationBudget` as everything else: an unbounded time-range query on a time series collection
is the fastest way to read a year of data into heap.
## Failure recovery
| Symptom | Cause | Action |
|---|---|---|
| Writes rejected with an unsupported-operation error | An update or unique-index expectation | The collection type does not support it; change the access pattern. |
| Poor compression / large storage | Missing `metaField`, or granularity too fine | Both require a rebuild; measure on a copy before committing. |
| Slow range queries | Granularity too coarse for the query window | Same: rebuild with the granularity matched to the dominant query. |
## Promotion evidence
Actual-topology evidence on the target deployment, a migration path from the existing collection,
failure cases (unsupported update refused, CSFLE combination refused), and this document as the
runbook.
+92
View File
@@ -0,0 +1,92 @@
# BSON Mapping Guide
Design §10 and decision D-06. The representation of a value in BSON is a data contract, not an
implementation detail: once a collection holds a million documents, changing how a `BigDecimal` is
stored is a migration with downtime, not a code change. `MongoTypeRepresentationManifest` pins the
representation so a library upgrade or a different default cannot move it.
## 1. The manifest
`MongoTypeRepresentationManifest.standard()` fixes:
| Java type | BSON | Representation type |
|---|---|---|
| `UUID` | `Binary` subtype 4 | `MongoUuidRepresentation.STANDARD` |
| `BigDecimal` | `Decimal128` | `MongoDecimalRepresentation.DECIMAL_128` |
| `BigInteger` | `Decimal128` (or `String` when out of range, declared) | `MongoBigIntegerRepresentation` |
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | `MongoTemporalRepresentation.UTC_DATE` |
| `LocalDate` | `String` (ISO-8601) or UTC `Date`, declared per field | `MongoTemporalRepresentation` |
| `enum` | `String` name | `MongoEnumRepresentation.NAME` |
`MongoMappingConfiguration` and `MongoCustomConversionsFactory` build the Spring Data converters from
the manifest, so there is one place to read and one place to change.
## 2. UUID
`UuidRepresentation.STANDARD` (subtype 4), always. The driver's legacy Java representation
(subtype 3) byte-swaps two halves of the UUID, so a document written by one representation and read
by the other yields a different — and valid-looking — UUID. Nothing errors; you just get the wrong
row. The golden snapshot kit pins the codec explicitly for this reason
(`MongoBsonSnapshot.defaultRegistry()`).
## 3. Decimal
`BigDecimal``Decimal128`, never `Double`. `12.30` stored as a double is `12.299999999999999`, and
a monetary comparison written against it will one day be wrong by a cent for a customer who notices.
`BigDecimalToDecimal128Converter` / `Decimal128ToBigDecimalConverter` are registered from the
manifest.
`Decimal128` has 34 significant digits; a `BigDecimal` beyond that range fails on write rather than
rounding silently.
## 4. Time
Store instants, not local times. `LocalDateTimeMappingGuard` refuses `LocalDateTime` fields on a
mapped document: a `LocalDateTime` has no offset, so the value that goes in depends on the JVM
default zone of whichever instance wrote it, and the two instances in a rolling deploy can disagree.
Use `Instant` when the moment matters and `LocalDate` when the calendar day matters.
## 5. Type metadata
`MongoTypeMetadataPolicy` decides what goes in `_class`:
| Policy | Stored | Use when |
|---|---|---|
| `NONE` | nothing | The collection holds exactly one type and never will hold a subtype. |
| `ALIAS` | a registered short alias | A polymorphic hierarchy in a long-lived collection. |
| `CLASS_NAME` | the FQCN | Short-lived or internal collections only. |
`PolicyAwareMongoTypeMapper` enforces it, and `MongoTypeMetadataRegistry` holds alias → class.
A `@LongLivedMongoDocument` type with `CLASS_NAME` is refused: writing `com.example.OrderV2` into a
million documents means that renaming the package is a data migration.
## 6. Missing versus null
The golden kit keeps these apart deliberately. `MongoBsonSnapshotAssert.hasNoField(...)` and
`hasExplicitNull(...)` are different assertions, because in MongoDB they are different documents:
`{"a": null}` matches `{a: null}` and `{a: {$exists: true}}`, while `{}` matches only the first.
A mapper change that starts writing explicit nulls silently changes what your queries return.
## 7. Golden representation tests
Every collection with a fixed representation should have a snapshot test:
```java
MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(storedDocument);
MongoBsonSnapshotAssert.assertThat(snapshot)
.hasBsonType("amount", "DECIMAL128")
.hasBsonType("externalId", "BINARY")
.hasNoJavaClassName("dev.caskeleton")
.hasTypeSignature("_id:OBJECT_ID,amount:DECIMAL128,createdAt:DATE_TIME,externalId:BINARY");
```
`hasTypeSignature` is the regression gate: it fails on *any* representation change, including ones a
value-equality assertion would pass. When it fails, the question is whether the change was intended
and has a migration — not whether to update the string.
## 8. Round trips
`MongoRoundTripContract` asserts that `write → read` returns an equal domain object *and* that
`write → read → write` produces an identical BSON document. The second half is what catches an
asymmetric converter: a value that reads back equal but re-serialises differently makes every
subsequent `save()` a spurious update, and turns change streams into a noise generator.
+105
View File
@@ -0,0 +1,105 @@
# Change Stream Guide
Design §20, decision D-12. A change stream is an **at-least-once projector**, not an event bus.
## 1. What a change stream is not
D-12 is explicit: a physical change event is not a business integration event. The two differ in
ways that matter to every consumer:
| Change event | Integration event |
|---|---|
| Emitted per document write | Emitted per business fact |
| Shape follows the storage schema | Shape is a published contract |
| A refactor of the document changes it | A refactor of the document does not change it |
| Replayed on resume, duplicated on retry | Versioned and deliberately evolved |
Publishing raw change events externally makes your storage schema a public API, and the first time
someone renames a field the downstream consumers break. If you need to bridge to messaging, use the
Advanced bridge, which maps to an owned envelope
([advanced/multi-tenancy.md](advanced/multi-tenancy.md) is separate;
the bridge is described in §7 below).
## 2. Subscription and resume
`MongoChangeStreamSubscription` declares the collection, pipeline and consistency. `MongoResumePosition`
is either a resume token or a cluster time; `MongoResumeCheckpoint` is what gets persisted and
`MongoResumeCheckpointStore` persists it.
The checkpoint stores the token as a Base64 `encodedToken` string rather than a byte array — a record
with an array component has broken equality, and a checkpoint that does not compare correctly is a
checkpoint that silently fails its own dedup test.
## 3. Checkpoint after processing, not after receiving
The ordering rule that makes at-least-once actually hold:
```
receive event
→ process it (idempotently)
→ persist the checkpoint
```
Checkpointing on receipt turns the delivery guarantee into at-most-once, and the events lost are
exactly the ones the process died while handling.
## 4. Idempotency
`MongoChangeEventIdentity` is the dedup key: `(resumeToken, documentKey, clusterTime, operationType)`.
`MongoChangeDeduplicationStore` records what has been applied. Duplicates are not an edge case — every
resume after any interruption replays at least one event, so a projector that is not idempotent is
wrong on its first restart, not on some rare day.
`MongoChangeProjector` returns a `MongoChangeProjectionResult` so the runner can distinguish applied
from skipped-as-duplicate, and the skip count is worth a metric: a sudden rise means something is
looping.
## 5. States and recovery
`MongoChangeStreamState`: `STARTING`, `RUNNING`, `RESUMING`, `STOPPED`, `HISTORY_LOST`.
`MongoChangeStreamRecoveryPolicy` returns a `MongoChangeStreamRecoveryDecision`, which is either
`resume()` (auto-resume from the checkpoint) or `halt(state, runbook)`. A halting decision **must**
name a runbook — a decision that only says "stopped" leaves the on-call engineer to work out from
scratch whether the projection can be rebuilt and from what.
| Situation | Decision |
|---|---|
| Transient network error, token still valid | `resume()` |
| Primary failover | `resume()` — the token survives an election |
| `invalidate` (collection dropped/renamed) | `halt(STOPPED, …)``MongoInvalidateRecovery` |
| Token no longer in the oplog | `halt(HISTORY_LOST, "history-lost")``MongoChangeHistoryLostException` |
## 6. History lost
`MongoChangeHistoryLostException` is raised when the resume token predates the oldest oplog entry.
The stream **cannot** be resumed: the events between the checkpoint and now are gone from the server,
and no amount of retrying brings them back.
What the platform will not do is silently restart from "now". That looks like a recovery and is
actually a silent gap in the projection — the worst possible outcome, because nothing reports it. The
runner halts and requires an operator decision. See
[runbooks/history-lost.md](runbooks/history-lost.md).
## 7. Bridging to messaging (Advanced)
`MongoChangeMessagingBridge` is opt-in behind `MongoCapability.CHANGE_STREAM` plus the bridge's own
flag. It maps a change event to a platform-owned `MongoIntegrationEventEnvelope` through
`MongoChangeToIntegrationEventMapper` and hands it to a `MongoIntegrationEventPublisher` port.
The port is defined in the bridge package rather than imported from the messaging adapter because the
architecture registry forbids adapter-to-adapter dependencies; the composition root supplies the
implementation.
`MongoBridgeOutboxPolicy` and `MongoBridgeCheckpointPolicy` state the delivery contract: publish then
checkpoint, at-least-once, consumers must dedup on the envelope's event id.
## 8. Operating notes
- Change streams require a replica set. `MongoStartupValidator` refuses a change-stream profile on
`STANDALONE`.
- The change-stream principal is its own role (`MongoPrincipalRole.CHANGE_STREAM`) with
`changeStream` and `find` — not the application write credential.
- Oplog window is the recovery budget. If the oplog holds four hours, a consumer that is down for five
hours needs a rebuild, not a resume. Alert on consumer lag against the oplog window, not against
wall-clock.
@@ -0,0 +1,126 @@
# Consistency and Transaction Guide
Design §12–§16, decisions D-07 through D-10. This is the part of the platform where the wrong
default is most expensive and the least visible in testing, because every failure mode here needs a
primary change to reproduce.
## 1. Prefer a single-document atomic operation
D-09: a transaction is for a **multi-document invariant**, nothing else. A single document is already
atomic in MongoDB, so wrapping a one-document update in a transaction buys nothing and costs a
session, a two-phase commit and a new ambiguous outcome.
D-07: partial change uses update operators, not `save()`. `MongoAtomicOperations` /
`MongoAtomicOperationsTemplate` expose the operator set through `MongoUpdateOperator`
(`$set`, `$inc`, `$push`, `$pull`, `$addToSet`, `$min`, `$max`, `$currentDate`, …) with an
`AtomicFilter` precondition and a `ReturnDocumentMode`. Read-modify-write through `save()` replaces
the whole document and silently discards any field another writer changed in between — a lost update
with no error.
## 2. Whole-document replacement needs a revision
D-08. `VersionedMongoUpdater` requires a `MongoRevision`: either a Spring Data `@Version` field or an
explicit expected-revision predicate in `VersionedUpdateCommand`. A replacement whose filter matched
zero documents is not "nothing to do" — `MongoOptimisticConflictTranslator` distinguishes:
- filter matched nothing and the id does not exist → `MongoDocumentNotFoundException`
- filter matched nothing and the id exists → `MongoOptimisticConflictException`
Collapsing these two into one is how a concurrent overwrite becomes a 404.
## 3. Consistency profiles
`MongoConsistencyProfile` names the read/write concern pair; `MongoConsistencyRegistry` binds a
profile to an operation or collection, and `MongoConsistencyBinder` /
`ReactiveMongoConsistencyBinder` apply it at execution.
| Profile | Meaning | Use for |
|---|---|---|
| `PRIMARY_LOCAL` | primary read, local concern | Throughput-sensitive reads that tolerate a rollback window. |
| `PRIMARY_MAJORITY` | primary read, majority write | The default for anything a user will see again immediately. |
| `CAUSAL_MAJORITY` | majority inside a causal session | Read-your-writes across separate operations. |
| `STALE_READ_ALLOWED` | secondary reads permitted | Reporting and analytics that state their staleness. |
| `SNAPSHOT_TRANSACTION` | snapshot isolation | Multi-document reads inside a transaction. |
A profile is a declaration, not a hint: the registry is consulted per operation and an operation
without a registered profile is rejected rather than defaulting.
## 4. Causal sessions
`MongoCausalSessionContext` plus `SpringMongoCausalSessionExecutor` /
`ReactiveMongoCausalSessionExecutor` carry the cluster time and operation time between operations, so
"write then read" returns the write even when the read lands on a different node. Without a causal
session, `PRIMARY_MAJORITY` gives you durability but not read-your-writes across two calls.
In the reactive path the session travels in the Reactor context (`ReactiveMongoContextKeys`), not in
a thread local — a thread local is empty on the next operator in the chain.
## 5. Transactions
`MongoTransactionExecutor` / `ReactiveMongoTransactionExecutor` open a session through the session
factory, run the body, and commit. `MongoTransactionProfile` carries the consistency profile, the
`maxCommitTime` and the retry budget. Topology matters: a transaction requires a replica set, and
`MongoStartupValidator` refuses a transaction-declaring profile on `STANDALONE` at startup rather
than at the first call.
## 6. Retry: body and commit are different loops
D-10, and the single most consequential rule in the design.
```
for each body attempt:
open a NEW session
run the body
TransientTransactionError -> abort, next body attempt
commit
UnknownTransactionCommitResult -> retry COMMIT ONLY, same session
```
`MongoTransactionRetryCoordinator` implements exactly this:
- **A new session per body attempt.** Reusing the session after an abort carries the aborted
transaction's state into the retry.
- **The body is never replayed after a commit ambiguity.** An unknown commit means the commit may
already have applied. Re-running the body would apply it a second time. Only the commit is retried,
and a commit retry on an already-committed transaction is a no-op by design.
- **A budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with jittered
backoff (`delayBefore(attempt, random)`), so a struggling primary is not retried into the ground.
`MongoRetryDecision` and `MongoRetryScope` (in `…api.error`) say what may be retried:
`MongoRetryScope.BODY`, `COMMIT_ONLY`, or `NONE`.
## 7. Ambiguous outcomes
`MongoExecutionOutcome` has six values, two of which are ambiguous and must not be collapsed:
| Outcome | Did the write happen? |
|---|---|
| `NOT_SENT` | No. Safe to retry. |
| `NO_WRITE_PERFORMED` | No — the server answered and did nothing. |
| `WRITE_CONFIRMED` | Yes. |
| `PARTIAL_BULK_WRITE` | Some of it. See `MongoBulkResult`. |
| `WRITE_RESULT_UNKNOWN` | **Unknown.** |
| `TRANSACTION_COMMIT_UNKNOWN` | **Unknown.** |
An unknown outcome is not a failure and must not be reported to a caller as one. The caller either
reconciles (`MongoCommitReconciler` re-reads a deterministic marker the body wrote) or surfaces the
ambiguity. See [runbooks/unknown-commit.md](runbooks/unknown-commit.md).
`MongoFailureContext` records only the design-permitted fields — outcome, category, operation name,
collection profile, retry scope, attempt — never the query, the document, or the values.
## 8. Failure translation
`DefaultMongoFailureClassifier` classifies **labels before codes**. The server's error labels
(`TransientTransactionError`, `UnknownTransactionCommitResult`, `RetryableWriteError`) are the
authoritative statement about retryability; an error code is a secondary signal whose meaning varies
by server version. `DefaultMongoFailureTranslator` maps a classification onto the stable exception
hierarchy, and anything unmatched becomes `MongoUnclassifiedFailureException` rather than leaking a
driver type.
## 9. Bulk writes
`MongoBulkExecutor` returns a `MongoBulkResult` with per-item `MongoBulkItemFailure` entries. An
unordered bulk write that partially fails is `PARTIAL_BULK_WRITE`, not a failure: some documents were
written. `MongoBulkPartialFailureException` carries the succeeded and failed indexes so a caller can
resume rather than replay.
+85
View File
@@ -0,0 +1,85 @@
# Document Modeling Guide
Design §7–§9. The platform does not own your documents — D-01 is explicit that the domain owns
`@Document`, repositories, queries, index requirements and schema version. What the platform owns is
the set of modeling decisions that are expensive to reverse once a collection holds production data.
## 1. There is no `CommonMongoRepository`
A generic `CommonMongoRepository<T, ID>` is listed under explicitly unsupported (§3.4), and the
reason is not purity. A shared supertype forces every collection to share an id strategy, a
consistency profile and a query surface, and the first collection that needs a different one either
gets a cast or a leaky generic parameter. Declare a Spring Data repository per aggregate.
## 2. Embed or reference
`MongoDocumentModelManifest` records the decision per collection so it is reviewable, and
`MongoDocumentModelValidator` refuses the combinations that do not survive growth.
| Descriptor | Use when |
|---|---|
| `EmbeddedCollectionDescriptor` | The child is read with the parent, is bounded, and has no independent lifecycle. Declare `maxElements`; an unbounded array is the single most common way a document reaches the size limit. |
| `MongoReferenceDescriptor` | The child is queried independently, is unbounded, or outlives the parent. Declare `MongoReferenceLifecycle` so the deletion story is written down rather than discovered. |
The validator rejects an embedded collection without a bound, and a reference whose lifecycle says
the child is owned by the parent but which is also referenced from elsewhere.
## 3. Size budget
`MongoDocumentSizeBudget`:
| Constant | Bytes | Meaning |
|---|---|---|
| `MONGODB_HARD_LIMIT_BYTES` | 16 MiB | MongoDB's own limit. |
| `PLATFORM_CEILING_BYTES` | 4 MiB | The largest budget the platform will accept. |
| `DEFAULT_BYTES` | 2 MiB | `MongoDocumentSizeBudget.standard()`. |
A budget above the ceiling is refused at construction. Budgeting to 16 MiB means the failing write
is the first symptom, and by then the collection is already full of near-limit documents.
## 4. Identity
`DomainDocumentId` and `MongoIdRepresentation` fix how a domain identifier becomes `_id`. Pick the
representation once per collection and record it in the manifest:
- `OBJECT_ID` — server-generated, monotonic, 12 bytes. Good default when the domain has no natural id.
- `UUID_BINARY` — a domain UUID stored as `Binary` subtype 4 (`STANDARD`). Never store a UUID as a
string "because it is easier to read"; it doubles the index size and loses the type.
- `STRING` — a natural key that is genuinely a string (a slug, an external system's id).
An `_id` choice is effectively permanent: it is the shard key candidate, the resume-token join key
and the pagination tie-breaker.
## 5. Schema version
Every long-lived collection carries `DocumentSchemaVersion`. `MongoSchemaVersionPolicy` and
`MongoSchemaVersionRange` say which versions the running code can read; a document outside the range
raises `MongoDataSchemaUnsupportedException` rather than being silently mapped with missing fields.
Write the range down before the migration, not after: the range is what lets old and new instances
run at once during a rolling deploy.
## 6. Type metadata
`@LongLivedMongoDocument` marks a document whose stored type alias must not be a Java class name.
`MongoTypeMetadataRegistry` maps alias → class. Storing the FQCN means moving or renaming the class
becomes a data migration; storing an alias keeps it a refactor. See
[bson-mapping-guide.md](bson-mapping-guide.md) §4.
## 7. Collection profiles
`MongoCollectionProfileRegistry` binds a `CollectionProfileName` to its consistency profile, budget
and allowlist. A collection that is not registered cannot be reached through
`MongoImperativeExecutor` or `ReactiveMongoExecutor` — the allowlist is the mechanism that keeps an
unreviewed collection from appearing in production by accident.
## 8. What to write down before the first insert
1. Embed/reference decision per child collection, with bounds.
2. Size budget.
3. `_id` representation.
4. Schema version range.
5. Index manifest (see [schema-index-migration-guide.md](schema-index-migration-guide.md)).
6. Consistency profile (see [consistency-transaction-guide.md](consistency-transaction-guide.md)).
Each of these is cheap now and a migration later.
+140
View File
@@ -0,0 +1,140 @@
# Query and Aggregation Guide
Design §17–§19, decision D-11. Every query and every pipeline is a registered, bounded thing. Free-form
JSON queries and unbounded pipelines are explicitly unsupported (§3.4).
## 1. Registered operations
Every execution carries a `MongoOperationContext`: a `MongoOperationName`, a `DatabaseProfileName`, a
`CollectionProfileName`, a `MongoOperationType` and a `MongoOperationScope`.
`MongoOperationName` matches `[a-z][a-z0-9.-]{2,95}`. It is the join key for the budget registry, the
consistency registry, the metric tag and the log line — a free-form or interpolated name breaks all
four at once, which is why the pattern is enforced at construction.
`MongoOperationScope` uses an `UNSPECIFIED` sentinel rather than `null`, so "the caller did not say"
is a value the policy layer can reject rather than an NPE further down.
## 2. Query guardrails
`PolicyAwareMongoQueryBuilder` builds a query from `MongoFieldDescriptor` + `MongoOperator` pairs
against a `MongoQueryPolicy`. The policy refuses:
- a field not in the collection's allowlist
- an operator not allowed for that field
- a sort on an unindexed field
- `$where`, `$expr` with arbitrary JavaScript, and server-side evaluation generally
- an unbounded `$regex`
`MongoRegexPolicy` requires an anchored prefix pattern and bounds the pattern length. An unanchored
regex is a collection scan wearing an index's clothes, and a user-supplied one is a denial-of-service
primitive.
`MongoSortDescriptor` pairs a field with a direction and is validated against the index manifest, so
a sort that would spill to disk fails review rather than production.
## 3. Operation budgets
`MongoOperationBudget` bounds four things at once:
| Bound | Why |
|---|---|
| `maxTimeMS` | The server stops working on a query nobody is waiting for. |
| result limit | An unbounded result set is an OOM with extra steps. |
| batch size | Bounds the per-round-trip memory. |
| examined-document ceiling | Catches an index regression that a time limit alone would hide on a fast day. |
`MongoBudgetPolicyRegistry` binds a budget to an operation name; `MongoBudgetEnforcer` applies it and
raises `MongoOperationRejectedException` before execution when a request exceeds it, and
`MongoTimeoutException` when the server enforces it.
## 4. Keyset pagination
Unbounded `skip` is unsupported: `skip(1_000_000)` makes the server walk a million documents to throw
them away, so page 1000 costs a thousand times page 1.
`MongoKeysetQueryBuilder` builds the resume predicate lexicographically. For a sort on `(a DESC, _id
DESC)` resuming after `(A, I)`:
```
(a < A) OR (a = A AND _id < I)
```
`validate()` rejects a `MongoKeysetSort` without a unique tie-breaker. Without one, two documents with
the same sort value straddle the page boundary and one of them is skipped or repeated — invisibly,
and only under concurrency.
`MongoNullSortOrdering` makes null placement explicit, because MongoDB's own ordering of missing
versus null versus present is not what most people assume.
### Cursors are authenticated
`MongoKeysetCursorCodec` signs the cursor with HMAC-SHA256 and compares with
`MessageDigest.isEqual` (constant time). An unsigned cursor is a client-controlled query predicate: a
caller can edit it to read a range they were never offered. A tampered or truncated cursor yields
`MongoCursorException`, never a partially-decoded resume position.
## 5. Aggregation guardrails
`MongoAggregationPlan` is a registered pipeline: an ordered list of `MongoAggregationStageDescriptor`
validated against a `MongoAggregationProfile`. `PolicyAwareMongoAggregationExecutor` runs only a
registered plan.
`MongoAggregationRisk` grades each stage, and the profile sets the ceiling:
| Risk | Stages | Policy |
|---|---|---|
| low | `$match` on an indexed prefix, `$limit`, `$project` | Always allowed. |
| moderate | `$group`, `$sort` with an index, `$unwind` with a bound | Allowed within budget. |
| high | `$lookup`, `$graphLookup`, `$facet`, unindexed `$sort` | Requires explicit approval in the profile. |
| forbidden | `$out`, `$merge` outside the admin plane, `$function`, `$accumulator` | Refused. |
`allowDiskUse` is a declared property of the plan, not a runtime flag. A pipeline that needs disk is a
pipeline whose shape should be reviewed.
## 6. Reactive execution and cursors
`ReactiveMongoExecutor` / `DefaultReactiveMongoExecutor` carry the operation context in the Reactor
context. `MongoCursorGuard` and `MongoCursorLease` bound cursor lifetime:
- a cursor has a lease with a deadline
- cancellation closes the server-side cursor (`MongoCursorTermination`)
- an abandoned cursor is a server-side resource, so the lease is released on cancel, error *and*
completion — `MongoReactiveCursorPublisher` uses `Flux.using` so all three paths run the same
release
A leaked cursor does not fail anything locally; it consumes a connection and a snapshot on the server
until the server's own timeout, which is why the guard is not optional.
## 7. Geospatial
`MongoGeoQuery` + `MongoGeoPoint` + `MongoGeoDistance` over a `2dsphere` index. Distances are metres
on a sphere (`nearSphere` with `maxDistance`), never degrees — a degree of longitude is a different
distance in Oslo than in Nairobi, and a radius expressed in degrees is a bug that only shows up away
from the equator. `SpringMongoGeospatialOperations` is the Spring Data binding;
`MongoGeospatialOperations` is the port.
## 8. Native capability gateway
When a registered operation genuinely needs something outside the Stable API, it goes through
`MongoNativeCapabilityGateway` (`PolicyAwareMongoNativeGateway`), never through the driver directly.
The admission order is fixed:
```
capability registered
→ database profile
→ collection allowlist
→ operation name present
→ timeout / maxTimeMS
→ consistency profile
→ result / batch limit
→ trace
→ log redaction
→ command category (MongoNativeCommandCategory)
→ D4 admin command refused
→ execute
```
`ApprovedMongoNativeOperation` is the registration record; `MongoNativeOperationPolicy` is the policy.
An admin-plane command reaching this gateway is refused regardless of capability — the admin plane has
its own credential and its own client (see [security-observability.md](security-observability.md)).
+124
View File
@@ -0,0 +1,124 @@
# MongoDB Document Persistence Platform — Repository Adaptation Contract
**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
The design package declares its own module root (`modules/mongodb`) and root package
(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the
single record of how that assumed layout was mapped onto this repository. Only paths, build DSL,
and composition-root ownership changed. Public contracts, policy order, and error semantics are
implemented exactly as specified.
## 1. Why the module layout differs
The design assumes 19 Stable Gradle projects under `modules/mongodb/` and 12 Advanced projects
under `modules/mongodb-advanced/`. This repository is a Clean Architecture template whose
**fail-closed registry** (`src/config/architecture/modules.json`, enforced by `src/settings.gradle`
and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 31 more
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
Therefore the design's 31 modules become **package boundaries inside the registered leaf**
`:adapter:outbound:persistence-mongo`, following the precedent already set by
[docs/httpclient/repository-adaptation.md](../httpclient/repository-adaptation.md). The design's
module dependency table (§6.3) is reproduced as ten ArchUnit rules in `MongoModuleBoundaryTest`, so
a forbidden edge fails the build the same way a missing Gradle dependency would.
## 2. Package mapping
Root package: `io.backend.skeleton.mongodb``dev.caskeleton.adapter.outbound.mongo`.
### 2.1 Stable modules
| Design module | Repository package |
|---|---|
| `mongodb-core-api` | `…outbound.mongo.api` (+ `.capability`, `.consistency`, `.error`, `.mapping`, `.observation`, `.profile`, `.schema`) |
| `mongodb-spring-data` | `…outbound.mongo.mapping` (+ `.type`), `…outbound.mongo.failure` |
| `mongodb-imperative` | `…outbound.mongo.imperative` (+ `.atomic`, `.bulk`, `.revision`) |
| `mongodb-reactive` | `…outbound.mongo.reactive` (+ `.cursor`) |
| `mongodb-query` | `…outbound.mongo.query` (+ `.budget`, `.pagination`) |
| `mongodb-aggregation` | `…outbound.mongo.aggregation` |
| `mongodb-transaction` | `…outbound.mongo.transaction` (+ `.retry`, `.session`) |
| `mongodb-index-schema` | `…outbound.mongo.schema` (+ `.index`, `.manifest`, `.model`, `.ttl`, `.validation`) |
| `mongodb-change-stream` | `…outbound.mongo.changestream` (+ `.projector`, `.recovery`) |
| `mongodb-geospatial` | `…outbound.mongo.geo` |
| `mongodb-migration-core` | `…outbound.mongo.migration` |
| `mongodb-migration-flamingock` | `…outbound.mongo.migration.flamingock` |
| `mongodb-observability` | `…outbound.mongo.observation` |
| `mongodb-security` | `…outbound.mongo.security` (+ `.admin`), `…outbound.mongo.nativecap` |
| `mongodb-spring-boot-starter` | `…outbound.mongo.autoconfigure` |
| `mongodb-testkit-core` | `…outbound.mongo.testkit.mapping`, `.compat`, `.performance` (`testkit` source set) |
| `mongodb-testkit-replicaset` | `…outbound.mongo.testkit.rs` (`testkit` source set) |
| `mongodb-testkit-failover` | `…outbound.mongo.testkit.failover` (`testkit` source set) |
| `mongodb-testkit-migration` | `…outbound.mongo.testkit.migration` (`testkit` source set) |
`…outbound.mongo.architecture` has no design counterpart: it holds the `@MongoOperation` marker and
the reusable ArchUnit rule set a fork applies to its own document/repository code.
### 2.2 Advanced modules
| Design module | Repository package |
|---|---|
| `mongodb-sharding` | `…outbound.mongo.advanced.sharding` (+ `.admin` for the D4 shard plane) |
| `mongodb-timeseries` | `…outbound.mongo.advanced.timeseries` |
| `mongodb-csfle` | `…outbound.mongo.advanced.encryption.csfle` |
| `mongodb-queryable-encryption` | `…outbound.mongo.advanced.encryption.qe` |
| `mongodb-search` | `…outbound.mongo.advanced.search` |
| `mongodb-vector-search` | `…outbound.mongo.advanced.vector` |
| `mongodb-tenancy-shared` | `…outbound.mongo.advanced.tenancy.shared` |
| `mongodb-tenancy-database` | `…outbound.mongo.advanced.tenancy.database` |
| `mongodb-change-stream-messaging-bridge` | `…outbound.mongo.advanced.bridge` |
| `mongodb-gridfs-compat` | `…outbound.mongo.advanced.gridfs` |
| `mongodb-testkit-sharded` | `…outbound.mongo.testkit.sharded` (`testkit` source set) |
| `mongodb-testkit-atlas` | `…outbound.mongo.testkit.atlas` (`testkit` source set) |
The design's rule that a Stable module never depends on an Advanced one survives as an ArchUnit rule
(`stableNeverDependsOnAdvanced`) plus the opt-in flag: every Advanced entry point requires
`MongoAdvancedCapabilityFlags` to have the matching capability enabled and refuses construction
otherwise. Being on the classpath is not being enabled.
## 3. Other deliberate substitutions
| Design assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. |
## 4. What is unchanged from the design
- D1 / D2 / D3 / D4 exposure planes and the ordered D3 admission sequence (§5).
- Stable API V1 with `apiStrict=true` on the D1/D2 client generation; D3/D4 on separate generations.
- `MongoExecutionOutcome`, including both ambiguous outcomes (`WRITE_RESULT_UNKNOWN`,
`TRANSACTION_COMMIT_UNKNOWN`), and `MongoFailureContext`'s permitted-field list.
- The complete stable exception hierarchy and the label-before-code classification order.
- The BSON representation manifest (UUID `STANDARD`, `Decimal128`, UTC instants, alias type metadata)
and the document-size budget.
- Update-operator-first writes, and optimistic revision as the precondition for whole-document
replacement.
- Transaction body retry and commit retry as separate loops: a new session per body attempt, and
commit-only retry on unknown commit. The body is never replayed after a commit ambiguity.
- Registered operation names and manifests for query, aggregation and index; no free-form JSON query
and no unbounded pipeline.
- Keyset pagination with an authenticated cursor and a unique tie-breaker requirement.
- Change stream as an at-least-once projector with resume-token checkpointing and explicit
history-lost handling.
- TTL as physical cleanup only, never the sole basis for access denial or business scheduling.
- Manifest-owned index/validator state with an apply policy that never drops what it does not own.
- Low-cardinality observation tags, command redaction, and the credential reference indirection.
- The Stable release gate's evidence categories, and the Advanced promotion gate's requirement for
actual-topology evidence.
## 5. Verification
```bash
bash scripts/verify-mongodb-platform.sh # Stable gate
bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes)
```
Both scripts run from the repository root and delegate to `src/gradlew`.
+98
View File
@@ -0,0 +1,98 @@
---
title: Runbook — MongoDB primary failover
category: mongodb
severity: P2
owner: oncall
last_updated: 2026-08-13
status: active
---
# Runbook: MongoDB primary failover
Design §29, scenarios `PRIMARY_KILL`, `NETWORK_PARTITION`, `SERVER_SELECTION_TIMEOUT`,
`WRITE_RESPONSE_LOSS`.
## Symptoms
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
stays flat — the wait is topology, not query cost.
- Latency spike on writes with no corresponding rise in read latency.
A failover that resolves in under ~15 s and produces no `WRITE_RESULT_UNKNOWN` is normal replica-set
behaviour and needs no action beyond confirming it self-healed.
## Diagnosis
1. Confirm an election actually happened. SDAM events distinguish an election from "the database got
slow"; without them the two are indistinguishable in application metrics.
2. Split the failure categories. Metric tag `failureCategory`:
- `SERVER_SELECTION` / `CONNECTION` → the driver could not reach a primary. `NOT_SENT`; safe.
- `TIMEOUT` with outcome `WRITE_RESULT_UNKNOWN` → a write may have applied. Not safe; see below.
- `TRANSACTION_COMMIT_UNKNOWN` → go to [unknown-commit.md](unknown-commit.md) instead.
3. Check the election duration against `MongoRetryBudget`. If the election outlasted the budget, the
retries were exhausted before a primary existed and callers saw errors that a longer budget would
have absorbed.
4. Check whether the new primary is in the expected region/AZ. A failover to a distant node changes
write latency permanently, not transiently.
## Action
**Self-healed (the common case).**
Confirm outcome distribution contains no `WRITE_RESULT_UNKNOWN`, record the election in the incident
log, and close. Nothing to replay.
**Writes with `WRITE_RESULT_UNKNOWN`.**
These writes may or may not have applied. Do not blind-retry.
- Idempotent operation (registered `MongoUpdateOperator` with an `AtomicFilter` precondition): retry.
The precondition makes the second application a no-op.
- Non-idempotent operation: reconcile by reading the target document and comparing against the
intended post-state. Retry only if it does not reflect the write.
**Server selection never recovers.**
The set has lost quorum — two of three nodes are down or partitioned. No client-side action fixes
this; escalate to the database owner to restore a majority. The application should be failing closed,
not queueing.
**Elections are frequent (more than one a day, unprompted).**
This is an infrastructure symptom, not an application one: check node resource saturation, disk
latency on the primary, and network stability between members. Repeated elections cause repeated
unknown-outcome windows.
## Escalation
- P2 → P1 if server selection has failed for more than 2 minutes, or if any non-idempotent write
returned `WRITE_RESULT_UNKNOWN` and cannot be reconciled.
- Page the database owner for quorum loss, and the service owner for reconciliation of ambiguous
writes.
## Verification
The failover lane reproduces this deliberately:
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
```
It starts a real three-node set (`MongoThreeNodeReplicaSet`), stops the primary
(`MongoPrimaryController`), and injects network faults through Toxiproxy
(`ToxiproxyMongoNetworkFaultController`). A single-node set is not sufficient for the election: it
never holds one, so every guarantee that depends on a primary change goes untested.
The network faults need their own fixture (`MongoProxiedReplicaSetNode`) because a stopped container
cannot produce them. Stopping a node tells the client the write did not happen; cutting the *path*
while the server keeps running produces a client that cannot tell. `MongoNetworkFaultLaneTest`
asserts the difference by reaching the same server twice — once through the proxy, once directly:
- **Partition**: the proxied client fails, the direct client finds the server healthy and the earlier
write intact. The path was cut, not the server.
- **Response loss**: the proxied client fails, and the direct client then finds the document
*present*. The write applied and only the acknowledgement was lost —
`DefaultMongoFailureClassifier` returns `WRITE_RESULT_UNKNOWN`, and a retry would have inserted a
second document.
One detail the lane depends on: the connection is warmed before the toxic is applied. On a cold
connection it is the driver's handshake whose response is dropped, so the write is never transmitted
`NOT_SENT`, the opposite of the ambiguity being tested.
+90
View File
@@ -0,0 +1,90 @@
---
title: Runbook — MongoDB change stream history lost
category: mongodb
severity: P1
owner: oncall
last_updated: 2026-08-13
status: active
---
# Runbook: change stream history lost
Design §20.3, scenarios `OPLOG_HISTORY_LOSS`, `RESUME_TOKEN_LOSS`.
The stored resume token predates the oldest entry in the oplog. The events between the checkpoint and
now are gone from the server; no retry recovers them. `MongoChangeStreamRecoveryPolicy` returns
`halt(HISTORY_LOST, "history-lost")` and the runner stops.
**The platform will not silently restart from "now".** That looks like a recovery and is actually a
permanent, unreported gap in the projection.
## Symptoms
- `MongoChangeHistoryLostException`.
- `MongoChangeStreamState.HISTORY_LOST`; the consumer is stopped, not looping.
- Precedes it: consumer lag approaching the oplog window, or a consumer that was down for a long
period (a deploy that failed, a scaled-to-zero worker, a long outage).
## Diagnosis
1. **Determine the gap.** The checkpoint's cluster time is the start; the oldest oplog entry is the
end of what is unrecoverable. Everything in between was never processed.
2. **Determine the oplog window.** `rs.printReplicationInfo()` on the primary gives the first and last
oplog timestamps. If the window is materially smaller than it was, the write rate rose or the
oplog was resized — the consumer may be fine and the server changed.
3. **Determine what the projection is missing.** Which collections and which operations does this
projector consume? The gap is bounded by that, not by everything that happened.
4. **Check for a second consumer.** If another projector on the same collection is healthy, its
checkpoint tells you whether the problem is this consumer or the oplog.
## Action
Resuming is not an option. The choices are:
**Rebuild from source.** If the projection is derivable from the current state of the source
collections, rebuild it: stop the consumer, rebuild the projection, then start the stream from the
cluster time at which the rebuild snapshot was taken. This is the correct answer whenever the
projection is a materialised view rather than an event log, and it is the reason a projection should
be derivable.
**Backfill the gap.** If the source documents carry a timestamp covering the gap, run a bounded
backfill for that window through the migration runner (checkpointed, resumable — see
[schema-index-migration-guide.md](../schema-index-migration-guide.md) §5), then resume from the
current cluster time.
**Accept the gap explicitly.** Only when the projection is advisory and the business owner says so.
Record the window in the incident log and reset the checkpoint. This is a decision someone signs, not
a default.
Never: reset the checkpoint to "now" and restart quietly. That converts a visible P1 into an
invisible data-quality defect that surfaces months later as "the report has been wrong since March".
## Prevention
- **Alert on lag against the oplog window, not wall-clock.** "Consumer is 30 minutes behind" is fine
with a 24-hour oplog and an emergency with a 45-minute one. The threshold that matters is
`lag / oplogWindow`.
- **Size the oplog for the longest tolerable consumer outage**, including a failed deploy discovered
the next morning.
- **Checkpoint after processing, never on receipt** — see
[change-stream-guide.md](../change-stream-guide.md) §3.
- **Back up the checkpoint store.** `RESUME_TOKEN_LOSS` is the same incident reached from the other
direction: the oplog is fine, the checkpoint is gone.
- **Make the projection rebuildable.** A projection that can only be built by replaying every event
has no recovery path once the oplog rolls.
## Escalation
- P1 on detection. The consumer is stopped, so lag grows for as long as this is unresolved.
- Page the service owner for the rebuild decision, and the database owner if the oplog window shrank
unexpectedly.
## Verification
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
```
`MongoFailoverScenario.OPLOG_HISTORY_LOSS` and `RESUME_TOKEN_LOSS` assert the runner halts and names
this runbook rather than restarting from the current position.
+87
View File
@@ -0,0 +1,87 @@
---
title: Runbook — MongoDB unknown transaction commit result
category: mongodb
severity: P1
owner: oncall
last_updated: 2026-08-13
status: active
---
# Runbook: unknown transaction commit result
Design §16, decision D-10, scenario `UNKNOWN_TRANSACTION_COMMIT_RESULT`.
`MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN` means the commit **may have applied**. It is not a
failure and must never be reported to a caller as one. The single worst response is to re-run the
transaction body: if the commit did apply, the body applies a second time.
## Symptoms
- `MongoTransactionCommitUnknownException` in logs.
- Metric `failureCategory=TRANSACTION_COMMIT_UNKNOWN`.
- Usually accompanies a primary election — see [failover.md](failover.md).
- Downstream reports of duplicated effects (double charge, double increment) are the symptom of this
being handled wrongly, not of the condition itself.
## Diagnosis
1. **Confirm the platform did the right thing automatically.**
`MongoTransactionRetryCoordinator` retries the *commit only*, on the same session, within
`MongoRetryBudget`. A commit retry against an already-committed transaction is a no-op by design.
Most occurrences resolve here and never reach a human.
2. **If the budget was exhausted, determine the actual state.** The commit either applied or it did
not; you must find out which, not guess.
- If the transaction body wrote a deterministic marker (an idempotency key, a business id, a
revision), read it back. That is exactly what `MongoCommitReconciler` does, and it is the
reason the design requires transactions to write one.
- If there is no marker: reconstruct from a downstream artefact — an outbox row, an audit record,
an external side effect. If nothing exists to compare against, the transaction was not
designed to be reconcilable and that is the finding to record.
3. **Check whether the body was replayed.** Grep for a second execution with the same operation name
and correlation id. If the body ran twice, the effects need reversing, and the code path that
replayed it is a defect: an ambiguous commit is `COMMIT_ONLY` scope
(`MongoRetryScope.COMMIT_ONLY`), never `BODY`.
## Action
**Commit applied.** Nothing to do. Record the reconciliation.
**Commit did not apply.** Re-run the whole operation from the top — a new session, a new body
attempt. This is safe precisely because you established the previous attempt left no trace.
**Cannot determine.** Do not retry. Escalate. A blind retry here is a coin flip between "no effect"
and "duplicate effect", and duplicates in a financial or notification path are worse than a delay.
Freeze the affected entity if the domain supports it, and hand off with: operation name, correlation
id, document id, the time window, and what you checked.
**Recurring.** More than one an hour means the commit path is racing something structural — a
`maxCommitTime` shorter than the observed election duration, an oversized transaction, or an
undersized retry budget. Fix the budget or the transaction shape; do not raise the retry count and
call it resolved.
## Prevention
- Every transaction body writes a deterministic marker that identifies its own commit.
- `maxCommitTime` exceeds the observed p99 election duration.
- Callers surface the ambiguity to their own callers rather than mapping it to a generic 500 — an
ambiguous outcome reported as a failure invites the caller to retry, which is the one thing that
must not happen.
- Prefer a single-document atomic operation (D-09). A transaction that exists only to wrap one
document write has invented this failure mode for nothing.
## Escalation
- Always P1 when the state cannot be determined and the operation has an external effect.
- Page the service owner immediately; the database owner only if elections are the trigger.
## Verification
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
```
`MongoFailoverScenario.UNKNOWN_TRANSACTION_COMMIT_RESULT` runs this path against a real three-node
set, and the coordinator test asserts the body is never replayed after a commit ambiguity.
@@ -0,0 +1,137 @@
# Schema, Index and Migration Guide
Design §21–§25, decision D-13. Indexes and validators are **declared** in a manifest and **applied**
by an explicit plane. Automatic index creation in production is explicitly unsupported (§3.4): an
index build on a large collection is a capacity event, and discovering it because a deployment
started one is not an operating model.
## 1. The manifest is the source of truth
`MongoManifestRegistry` holds one `MongoCollectionManifest` per collection, containing:
- `MongoIndexManifest` — the declared indexes (`MongoIndexKey`, `MongoIndexDirection`, uniqueness,
partial filter, collation)
- `MongoSchemaManifest` — the declared `$jsonSchema` validator
- `MongoMetadataOwnership` — who owns each observed object
Ownership is the field that makes drift handling safe:
| Ownership | Owner | Droppable on drift |
|---|---|---|
| `APPLICATION_MANAGED` | this manifest | yes |
| `SEARCH_MANAGED` | the search service | no |
| `ENCRYPTION_MANAGED` | Queryable Encryption | no |
| `EXTERNAL` | someone else (a DBA, another service) | no |
A diff engine that does not know about ownership eventually proposes dropping
`enxcol_.customers.esc` or a search index, and "the drift tool cleaned it up" is a very bad incident
summary.
## 2. Index diff and apply
`MongoIndexDiffEngine` compares the manifest against `MongoIndexDescriptorView` observations and
produces a `MongoIndexDiff`: missing, extra, and *changed* (same name, different definition —
MongoDB will not silently rebuild these, so they must be reported rather than re-issued).
`MongoIndexApplyPolicy` decides what happens with a diff:
| Policy | Behaviour | Environment |
|---|---|---|
| `APPLY` | create what is missing | local / test |
| `APPLY_WITH_DIFF` | create what is missing and report the rest | staging |
| `DIFF_WITH_APPROVED_APPLY` | apply only what a human approved | production |
| `REPORT_ONLY` | never write | audit |
Dropping is never implicit. `MongoIndexRetirementPlan` moves an index through
`MongoIndexRetirementState` — declared → hidden → observed-unused → droppable — and each transition
is a separate deployment. Hiding an index makes the planner ignore it while keeping it maintained, so
an unexpected regression is one command to undo. Dropping it is not.
## 3. Validators
`MongoValidatorDescriptor` carries the `$jsonSchema`, a `MongoValidationLevel`
(`OFF` / `MODERATE` / `STRICT`) and a `MongoValidationAction`.
**Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
contract on MongoDB 7.0 or 8.0 and the descriptor refuses it.
`MongoValidatorDiffEngine` produces a `MongoValidatorDiff`; `MongoValidatorApplyPolicy` gates the
apply. Tightening a validator on a collection with existing data is the dangerous direction: introduce
it as `warn` + `MODERATE`, confirm the warning count is zero, then promote to `error` + `STRICT` in a
second deployment.
## 4. TTL
D-13: TTL is **physical cleanup**, nothing else.
`MongoTtlIndexDescriptor` declares the field and `expireAfterSeconds`. `MongoTtlPolicyValidator`
enforces what `MongoTtlPolicy` allows, and `MongoExpirationAccessPolicy` states the rule that matters:
> A document's presence is not authorization, and its absence is not a deadline.
The TTL monitor runs about once a minute and deletes in batches, so a document can outlive its
expiry by minutes to hours under load. Consequences:
- Access control must check the expiry field, not the document's existence. A still-present expired
session is a valid document and an invalid session.
- Business scheduling must not be built on TTL. If something must happen at a time, schedule it.
- A TTL field must be a BSON date. A TTL index on a string silently never deletes anything.
## 5. Migrations
`MongoMigrationRunner` executes `MongoMigration` units with:
- `MongoMigrationId` — ordered, unique
- `MongoMigrationChecksum` — content hash; a changed checksum for an applied id is a hard failure, not
a re-run. Editing an applied migration means two environments ran different code under the same id.
- `MongoMigrationLedger` — what has been applied
- `MongoMigrationLock` — one runner at a time; a rolling deploy starts several instances at once
- `MongoMigrationPrecondition` / `MongoMigrationPostcondition` — checked before and after; a migration
that cannot verify its own result is a migration whose failure is discovered by a customer
- `MongoMigrationCheckpoint` — a resumable position for a backfill
`MongoMigrationResult` reports applied / incomplete / dry-run with the reason. `INCOMPLETE` is not a
failure: a rate-limited backfill that ran out of its time budget has done real work and stored a
checkpoint, and reporting it as failed would send the next run back to the beginning.
`MongoCollectionMigrationLedger` and `MongoCollectionMigrationLock` are the MongoDB-backed
implementations. Two details are load-bearing and only exist on a server:
- The ledger's **unique index** on the migration id, created by `ensureIndexes()`. Without it, two
runners that both pass the "not applied yet" read both insert, and the ledger then reports one
migration applied twice with two checksums — indistinguishable from tampering.
- The lease is taken with **one conditional update**, not read-then-write. A filter matching only a
free or expired lease lets the server pick the winner; two runners that each read "free" and then
write would both believe they hold it.
The lease expires so a runner killed mid-migration does not block every future deployment, and
`refresh` between batches is what proves the holder is still alive.
### Backfills restart, they do not restart-from-zero
A long backfill will be interrupted — a deploy, an OOM, a node replacement. The checkpoint records
the last completed key so the restart continues rather than re-processing from the beginning.
`MongoBackfillRestartFixture` in the testkit asserts exactly this: kill mid-run, restart, and the
result is identical to the uninterrupted run and does not re-apply completed work.
### Flamingock
`FlamingockMongoMigrationAdapter` bridges to Flamingock through the platform-owned
`FlamingockChangeUnitView`, with `FlamingockLedgerAdapter` and `FlamingockLockAdapter` mapping the
ledger and lock. The public contract does not reference Flamingock types, so the provider can be
replaced without touching a migration.
## 6. Ordering with deployments
```
1. Add the index (hidden if it is large) -> deployment N
2. Unhide / verify usage -> deployment N+1
3. Ship code that depends on the index -> deployment N+1
4. Backfill data -> migration, resumable
5. Tighten the validator from warn to error -> deployment N+2
6. Retire the old index through the retirement states -> deployments N+3…
```
Each step is independently reversible. A deployment that adds an index and the code that requires it
at the same time has no safe rollback: rolling back the code leaves the index build running, and
rolling back the index breaks the code that is still live on half the fleet.
+147
View File
@@ -0,0 +1,147 @@
# Security and Observability
Design §26–§28, decision D-05. The application plane and the admin plane are different credentials on
different clients, and telemetry never becomes an exfiltration path.
## 1. Roles
`MongoPrincipalRole` — one credential per role, least privilege:
| Role | Grants |
|---|---|
| `APP_READ` | `find` on allowlisted collections |
| `APP_WRITE` | `insert`, `update`, `delete` on allowlisted collections |
| `CHANGE_STREAM` | `changeStream`, `find` |
| `MIGRATION` | index and validator management on the target collections |
| `SEARCH_ADMIN` | search index management |
| `SHARD_ADMIN` | shard key operations |
| `ENCRYPTION_ADMIN` | key vault access |
| `DBA` | the human plane; never used by an application |
`MongoSecurityProfileValidator` checks the profile at startup. `forbiddenPrivilegesHeld()` names the
privileges the profile holds and must not — the validator reports *which* one, because "your
credential is over-privileged" without a name is an unactionable finding.
The privileges that must never appear on an application credential: `dropDatabase`,
`dropCollection`, `shutdown`, `killop`, `root`, `__system`, `dbOwner`, `userAdminAnyDatabase`.
## 2. Credentials are references, not values
`MongoCredentialReference` holds a `secret://…` reference plus the role. The reference is resolved at
connection time by the secret provider; the password is never a property value, a log field, or a
constructor argument that could end up in a stack trace.
`MongoCredentialRotationPolicy` states the rotation contract: overlapping validity, a drain window,
and a rotation that never requires a restart. `MongoClientGenerationRegistry` implements the swap —
a new `MongoClientGeneration` starts serving new operations while the previous generation is
`markDraining()` until its in-flight operations finish. Killing the old client immediately fails every
in-flight request, which is why rotation without generations is an outage.
Rotation is a failover scenario in the release gate (`MongoFailoverScenario.CREDENTIAL_ROTATION`),
not a runbook step people hope works.
## 3. TLS and connection policy
`MongoSecurityProfile.production(...)` requires TLS and refuses `tlsAllowInvalidCertificates` /
`tlsAllowInvalidHostnames`. `MongoSecurityProfile.local(...)` exists so a developer does not have to
weaken the production factory to get a container to connect; the startup validator refuses a local
profile on a production runtime profile.
## 4. Admin plane (D4)
`MongoAdminGateway` is the only path to `MongoAdminOperation`, and it runs on the D4 client with the
DBA-scoped credential — not the application's.
- `MongoAdminAuthorization` checks the caller's role against the operation.
- `MongoAdminRuntimeGuard` refuses high-risk operations (`highRisk()`) unless the runtime profile
explicitly permits them; a `dropCollection` reachable from a running application is a data-loss
vector regardless of how well-reviewed the calling code is.
- `MongoAdminAuditRecord` records who ran what, when and against which collection profile — before
execution, so a failed attempt is recorded too.
The native capability gateway (D3) refuses any admin-category command, so there is no path from the
application plane into the admin plane.
## 5. Observability tags
`MongoObservationConvention` allowlists exactly eight tag names:
```
mongoProfile, databaseProfile, collectionProfile, operationName,
operationType, result, failureCategory, consistencyProfile
```
and explicitly forbids:
```
documentId, rawTenantId, tenantId, dynamicCollectionName, queryParameter,
query, fullBson, resumeToken, shardKeyValue, plaintextPII, credential
```
Two reasons, and both matter. Cardinality: a tag whose values are document ids produces one time
series per document, which is how a metrics backend falls over. Confidentiality: a metric label is
stored, shipped and retained by systems with a different access model than the database.
`requireAllowed(tagName)` throws on anything outside the list, so a new tag is a deliberate change to
the convention rather than a line in a service.
`MicrometerMongoOperationObserver` implements the `MongoOperationObserver` port;
`NoOpMongoOperationObserver` is the default so observation is opt-in and never a hard dependency.
## 6. Driver-native listeners
`MongoDriverObservabilityConfiguration` registers three driver listeners, because they answer
questions the application-level timer cannot:
| Listener | Answers |
|---|---|
| `MongoCommandObservationListener` | How long did the *server* take, versus how long the caller waited? |
| `MongoPoolObservationListener` | Was the wait time connection checkout rather than query execution? |
| `MongoSdamObservationListener` | Did the topology change — an election, a node removed — during the window? |
Without pool and SDAM events, every failover looks like "the database got slow", and the difference
between "we need a bigger pool" and "we lost a primary" is invisible.
## 7. Command redaction
`MongoObservationRedactor.describe(commandName)`:
- Authentication and user-management commands (`authenticate`, `saslStart`, `saslContinue`,
`getnonce`, `createUser`, `updateUser`, `copydb*`) render as `<redacted>` — their arguments carry
credentials and key material.
- Structural commands (`ping`, `hello`, `buildInfo`, `listCollections`, `listIndexes`, `collStats`)
render by name; their arguments are not data-bearing.
- Everything else renders as `name(...)`: you get the command, never the filter or the document.
`isAlwaysRedacted(...)` is the assertion hook so a test can prove no logging path can render an auth
command's arguments.
## 8. Startup validation
`MongoStartupValidator` runs at context refresh, before the first request:
1. `MongoTopologyProbe` reports the actual `MongoTopology`.
2. Each declared `MongoTopologyRequirement` is checked against it — a transaction, causal-session or
change-stream requirement fails closed on `STANDALONE`.
3. `MongoSecurityProfileValidator` checks credentials and TLS.
4. `MongoCapabilitySupport` checks declared capabilities against the server version, with
`MongoSupportLevel` distinguishing `STABLE` / `ADVANCED` / `EXPERIMENTAL` / `UNSUPPORTED`.
5. `MongoPlatformHealthIndicator` reports the outcome for the readiness probe.
A misconfiguration found at startup costs a failed deploy. The same misconfiguration found at runtime
costs an incident, and the failing operation is rarely the one that reveals the cause.
## 9. How the security lane proves any of this
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain
```
The lane runs against `MongoAuthenticatedReplicaSetContainer`, which starts mongod with `--auth` and a
generated keyfile. That detail is the whole lane: Testcontainers' `MongoDBContainer` starts mongod
*without* `--auth`, so users created on it all have every privilege and a least-privilege assertion
passes no matter how wrong the roles are. A security test that cannot fail is not a security test.
What the lane asserts is the refusal: the `read` role's insert is rejected, and the application role's
`dropDatabase` is rejected. Then it checks that `MongoSecurityProfileValidator` names the same
privilege the server just refused.
+91
View File
@@ -0,0 +1,91 @@
# MongoDB Platform — Support Matrix
Design §4. This file records what the platform is *certified* on, not what it happens to run on.
A configuration absent from this table is unsupported until someone runs the gate against it and
adds a row.
## 1. Runtime baseline
| Component | Version | Policy |
|---|---|---|
| Java | 21 | Repository runtime baseline. |
| Spring Boot | 4.0.0 | BOM-managed. Individual driver overrides are forbidden. |
| Spring Data MongoDB | 5.0.0 | Repository and `MongoTemplate` integration. Version comes from the Boot BOM. |
| MongoDB Java Driver | 5.6.1 | BOM-managed. Never pinned directly in the module. |
| Reactor | 3.8.0 | Reactive execution path. |
| Micrometer | 1.16.0 | Driver-native observability. |
| Testcontainers | 2.0.2 | Replica-set, failover, migration and compatibility lanes. |
The design's baseline is Spring Boot 4.1.x / Spring Data MongoDB 5.1.x. This repository is on
4.0.0 / 5.0.0, so the platform targets only the API surface common to both. See
[repository-adaptation.md](repository-adaptation.md) §3.
## 2. Server versions
| Lane | Version | Pinned image | Gradle task |
|---|---|---|---|
| Primary certification | MongoDB 8.0 | `mongo:8.0.16` | `mongoReplicaSetTest`, `mongoFailoverTest` |
| Compatibility | MongoDB 7.0 | `mongo:7.0.28` | `mongoCompatibilityTest` |
| Network fault injection | — | `ghcr.io/shopify/toxiproxy:2.12.0` | `mongoFailoverTest` |
Images are pinned, never `latest`: a mutable tag means the certification result describes whatever
was pulled that morning, not the version in the row. Override with
`-PmongoPrimaryImage=…` / `-PmongoCompatibilityImage=…` when testing a new patch level, and update
the row once the gate passes.
`MongoVersionMatrix.standard()` is the machine-readable form of this table; a version outside it
fails `certifies()`.
## 3. Topologies
| Topology | Status | What is certified | What is not |
|---|---|---|---|
| Standalone | **Smoke only** | Basic CRUD and mapping. | Not a production profile and never counts as Stable release evidence (D-03). Transactions, retryable writes and change streams are refused at startup by `MongoStartupValidator`. |
| Single-node replica set | **Local default** (D-02) | Transactions, retryable writes, change streams — the same semantics as production. | Elections. A single-node set never holds one, so failover behaviour is untested here. |
| 3-node replica set | **Stable production gate** | Everything above plus primary failover, unknown-commit handling and change-stream resume across an election. | Shard routing. |
| Sharded cluster | **Advanced gate** | Shard-key routing classification, scatter-gather refusal, `admin` plane operations. | Not included in the Stable gate. |
| Atlas / provider-managed | **Per-capability gate** | Search, vector search and encryption against the actual target deployment. | Atlas Local in a container is a pull-request convenience, explicitly **not** release evidence (`MongoAtlasCapabilityContractSuite.Environment`). |
## 4. Stable API and client generations
| Plane | Stable API | Purpose |
|---|---|---|
| D1 Standard document persistence | V1, `apiStrict=true` | Repositories, typed queries, atomic updates, optimistic revision. |
| D2 Advanced document operations | V1, `apiStrict=true` | `MongoTemplate`, transactions, bulk, aggregation, keyset cursors, change streams. |
| D3 Explicit Mongo capability | Not strict | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations — each behind a registered capability. |
| D4 Admin plane | Not strict | Collection, validator, index, migration, shard and repair commands. Separate credential, separate client. |
D3 is not a raw-client escape. Every call passes capability registration → database profile →
collection allowlist → operation name → timeout → consistency profile → result limit → trace →
redaction → command category → D4 refusal, in that order.
## 5. Validation actions
Stable validation actions are `error` and `warn`. `errorAndLog` is **not** part of the Stable
contract on 7.0 or 8.0 and `MongoValidatorDescriptor` refuses it.
## 6. Explicitly unsupported
Per design §3.4, none of the following is provided, and adding one is a design change rather than a
feature request:
- A generic `CommonMongoRepository<T, ID>`.
- Arbitrary runtime `runCommand`.
- Automatic index creation in production.
- A Standalone production contract.
- TTL as an exact business scheduler or as the only access control.
- Publishing raw change events as external business integration events.
- GridFS as the source of truth for new files.
- Java fully-qualified class names as a long-lived BSON schema.
- Unbounded skip pagination, unbounded aggregation pipelines, unbounded regex, unbounded results.
## 7. Capability tiers
| Tier | Capabilities | Enablement |
|---|---|---|
| Stable | Mapping, imperative/reactive execution, atomic update, optimistic lock, transactions, consistency profiles, retry/translation, query and aggregation guardrails, schema/index manifests, keyset pagination, bulk partial results, change streams, TTL contract, GeoJSON, security, observability | On when `ca-skeleton.persistence-mongo.enabled=true`. |
| Advanced | Sharding-aware query, time series, CSFLE, Queryable Encryption (equality/range), change-stream→messaging bridge, shared-collection multi-tenancy | Each behind `ca-skeleton.persistence-mongo.advanced.<capability>.enabled`. |
| Experimental | Search, vector search, hybrid search, database-per-tenant, collection-per-tenant, reshard orchestration, provider-specific features | Same flag mechanism; promotion additionally requires the evidence in [ADR-MONGO-ADV-001](../adr/ADR-MONGO-ADV-001-capability-promotion.md). |
`MongoAdvancedCapabilityFlags.propertyFor(capability)` is the authoritative property name for any
capability; the table above is its prose form.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# The MongoDB Advanced capability gate (advanced plan Task 15).
#
# Advanced capabilities are opt-in modules. This script verifies the contracts that can be verified
# without provider infrastructure, and then reports -- explicitly -- which promotion evidence it
# could NOT produce.
#
# Required promotion categories (MongoAdvancedPromotionEvidence.REQUIRED):
#
# stable-platform, actual-topology, security, migration, failure, runbook
#
# `actual-topology` is the one that cannot be substituted. A container gives a functional pass for
# sharding, search, vector and encryption while exercising none of the behaviour that makes them
# Advanced rather than Stable: real shard distribution, a real analyzer, a real KMS. Atlas Local is
# a pull-request convenience and is not release evidence -- see
# MongoAtlasCapabilityContractSuite.Environment.
#
# Usage:
# bash scripts/verify-mongodb-advanced.sh
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-advanced.sh
# MONGODB_SHARDED_URI=... MONGODB_ATLAS_URI=... MONGODB_KMS=... bash scripts/verify-mongodb-advanced.sh
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GRADLE_DIR="${REPO_ROOT}/src"
MODULE=':adapter:outbound:persistence-mongo'
GRADLE=(./gradlew --console=plain)
FAILED=()
MISSING_EVIDENCE=()
echo "MongoDB Advanced capability gate"
echo "repository: ${REPO_ROOT}"
# --- stable-platform -------------------------------------------------------------------------
# An Advanced capability cannot be promoted over a Stable platform that does not itself pass.
echo ""
echo "=== [stable-platform] Stable gate"
if bash "${REPO_ROOT}/scripts/verify-mongodb-platform.sh"; then
echo "stable-platform: supplied"
else
status=$?
if (( status == 2 )); then
echo "stable-platform: INCOMPLETE (the Stable gate skipped lanes)"
MISSING_EVIDENCE+=("stable-platform (Stable gate incomplete)")
else
FAILED+=("stable-platform")
fi
fi
# --- failure + runbook (hermetic) -------------------------------------------------------------
# Every Advanced refusal contract: disabled capability refuses construction, CSFLE/QE cannot share a
# collection, QE substring/prefix/suffix unsupported on 8.0, a non-READY search index cannot serve,
# undeclared scatter-gather is rejected, a dimension mismatch is refused.
echo ""
echo "=== [failure] Advanced contract tests"
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*advanced*'); then
echo "failure: supplied"
else
FAILED+=("failure")
fi
echo ""
echo "=== [runbook] capability documentation"
for doc in sharding time-series encryption search-vector multi-tenancy gridfs-migration; do
path="${REPO_ROOT}/docs/mongodb/advanced/${doc}.md"
if [[ -f "${path}" ]]; then
echo " + ${doc}.md"
else
echo " - ${doc}.md MISSING"
FAILED+=("runbook:${doc}")
fi
done
if [[ ! -f "${REPO_ROOT}/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md" ]]; then
echo " - ADR-MONGO-ADV-001 MISSING"
FAILED+=("runbook:ADR-MONGO-ADV-001")
fi
# --- actual-topology -------------------------------------------------------------------------
echo ""
echo "=== [actual-topology] provider environments"
if [[ -n "${MONGODB_SHARDED_URI:-}" ]]; then
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*Shard*' \
-Dmongodb.sharded.uri="${MONGODB_SHARDED_URI}"); then
echo "actual-topology(sharded): supplied"
else
FAILED+=("actual-topology:sharded")
fi
else
echo "actual-topology(sharded): no MONGODB_SHARDED_URI"
MISSING_EVIDENCE+=("actual-topology: sharded cluster")
fi
if [[ -n "${MONGODB_ATLAS_URI:-}" ]]; then
echo "actual-topology(search/vector): MONGODB_ATLAS_URI present"
else
echo "actual-topology(search/vector): no MONGODB_ATLAS_URI"
MISSING_EVIDENCE+=("actual-topology: search/vector on the actual target deployment")
fi
if [[ -n "${MONGODB_KMS:-}" ]]; then
echo "actual-topology(encryption): MONGODB_KMS present"
else
echo "actual-topology(encryption): no MONGODB_KMS"
MISSING_EVIDENCE+=("actual-topology: real KMS and key vault")
fi
# --- security + migration ---------------------------------------------------------------------
# These are review artefacts, not test runs: a role review and a documented migration path per
# capability. The gate records that they are outstanding rather than pretending a green test covers
# them.
MISSING_EVIDENCE+=("security: per-capability privilege review sign-off")
MISSING_EVIDENCE+=("migration: per-capability migration path sign-off")
# --- Report ------------------------------------------------------------------------------------
echo ""
echo "---------------------------------------------------------------"
if (( ${#FAILED[@]} > 0 )); then
echo "ADVANCED GATE: FAILED"
for entry in "${FAILED[@]}"; do echo " - ${entry}"; done
echo "---------------------------------------------------------------"
exit 1
fi
echo "verifiable contracts: PASSED"
if (( ${#MISSING_EVIDENCE[@]} > 0 )); then
echo ""
echo "ADVANCED GATE: NOT PROMOTABLE -- missing evidence:"
for entry in "${MISSING_EVIDENCE[@]}"; do echo " ~ ${entry}"; done
echo ""
echo "A capability stays opt-in until every category in"
echo "MongoAdvancedPromotionEvidence.REQUIRED is supplied. See"
echo "docs/adr/ADR-MONGO-ADV-001-capability-promotion.md."
echo "---------------------------------------------------------------"
exit 2
fi
echo "ADVANCED GATE: PASSED"
echo "---------------------------------------------------------------"
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
#
# The MongoDB Stable release gate (design §30, plan Task 50).
#
# Runs every lane that produces one of the Stable evidence categories:
#
# mapping, transaction, migration, change-stream, security,
# failover, performance, compatibility
#
# The gate exists because "the test suite is green" and "every category has evidence" are different
# statements. A suite passes happily with a whole lane skipped -- no Docker, a disabled tag, a
# renamed task -- and a release built on that suite has no failover or compatibility evidence at
# all, silently. Each lane below is therefore run by name, and a skipped lane is reported as skipped
# rather than counted as passed.
#
# Advanced capabilities are NOT promoted or transitively included here. See
# scripts/verify-mongodb-advanced.sh.
#
# Usage:
# bash scripts/verify-mongodb-platform.sh # hermetic lanes only
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-platform.sh # + container lanes
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GRADLE_DIR="${REPO_ROOT}/src"
MODULE=':adapter:outbound:persistence-mongo'
GRADLE=(./gradlew --console=plain)
RESULTS_DIR="${GRADLE_DIR}/adapter/outbound/persistence-mongo/build/test-results"
RAN=()
SKIPPED=()
FAILED=()
# Counts the tests a lane actually executed, from its JUnit XML.
#
# A lane whose filter matches nothing passes: Gradle runs the task, discovers no tests, and reports
# success. That is the failure mode this whole gate exists to prevent -- an empty lane is not
# evidence, it is the absence of evidence wearing a green tick. Any lane that reports zero executed
# tests is treated as a failure.
executed_tests() {
local task="$1"
local dir="${RESULTS_DIR}/${task}"
[[ -d "${dir}" ]] || { echo 0; return; }
local total=0
shopt -s nullglob
for xml in "${dir}"/*.xml; do
local count
count=$(sed -n 's/.*<testsuite[^>]* tests="\([0-9]*\)".*/\1/p' "${xml}" | head -1)
total=$(( total + ${count:-0} ))
done
shopt -u nullglob
echo "${total}"
}
run_lane() {
local category="$1"
local task="$2"
shift 2
echo ""
echo "=== [${category}] ${task}"
if ! (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:${task}" "$@"); then
FAILED+=("${category}:${task}")
return
fi
# `check` aggregates several tasks and has no results directory of its own.
if [[ "${task}" == "check" ]]; then
RAN+=("${category}:${task}")
return
fi
local executed
executed=$(executed_tests "${task}")
if (( executed == 0 )); then
echo "!!! ${task} passed without executing a single test — the lane's filter matches nothing,"
echo "!!! so the '${category}' evidence category is empty."
FAILED+=("${category}:${task} (0 tests executed)")
else
RAN+=("${category}:${task} (${executed} tests)")
fi
}
skip_lane() {
local category="$1"
local task="$2"
local reason="$3"
echo ""
echo "=== [${category}] ${task} -- SKIPPED (${reason})"
SKIPPED+=("${category}:${task} (${reason})")
}
docker_available() {
[[ "${MONGODB_DOCKER:-0}" == "1" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
echo "MongoDB Stable release gate"
echo "repository: ${REPO_ROOT}"
# --- Always-on lanes -------------------------------------------------------------------------
# Static analysis, architecture boundaries, unit and hermetic contract tests. These produce the
# mapping, transaction, migration, change-stream and security evidence that does not need a server.
run_lane "static-analysis" "check" -x "mongoStableContractTest"
run_lane "mapping+transaction+migration+change-stream+security" "mongoStableContractTest"
# --- Container lanes -------------------------------------------------------------------------
# A lane that needs Docker inside `check` teaches people to skip `check`, so these are opt-in --
# but opting out is recorded, not silent.
if docker_available; then
run_lane "compatibility" "mongoCompatibilityTest"
run_lane "migration" "mongoMigrationTest"
run_lane "security" "mongoSecurityIntegrationTest"
run_lane "failover" "mongoReplicaSetTest"
run_lane "failover" "mongoFailoverTest"
run_lane "performance" "mongoPerformanceTest"
else
reason="MONGODB_DOCKER!=1 or Docker unavailable"
skip_lane "compatibility" "mongoCompatibilityTest" "${reason}"
skip_lane "migration" "mongoMigrationTest" "${reason}"
skip_lane "security" "mongoSecurityIntegrationTest" "${reason}"
skip_lane "failover" "mongoReplicaSetTest" "${reason}"
skip_lane "failover" "mongoFailoverTest" "${reason}"
skip_lane "performance" "mongoPerformanceTest" "${reason}"
fi
# --- Architecture-wide gates -----------------------------------------------------------------
echo ""
echo "=== [architecture] repository-wide verification"
if (cd "${GRADLE_DIR}" \
&& "${GRADLE[@]}" verifyCleanArchitectureDependencies \
&& "${GRADLE[@]}" :app-bootstrap:test --tests '*CleanArchitectureTest'); then
RAN+=("architecture:repository-wide")
else
FAILED+=("architecture:repository-wide")
fi
# --- Report ------------------------------------------------------------------------------------
echo ""
echo "---------------------------------------------------------------"
echo "ran: ${#RAN[@]}"
for entry in "${RAN[@]:-}"; do [[ -n "${entry}" ]] && echo " + ${entry}"; done
echo "skipped: ${#SKIPPED[@]}"
for entry in "${SKIPPED[@]:-}"; do [[ -n "${entry}" ]] && echo " ~ ${entry}"; done
echo "failed: ${#FAILED[@]}"
for entry in "${FAILED[@]:-}"; do [[ -n "${entry}" ]] && echo " - ${entry}"; done
echo "---------------------------------------------------------------"
if (( ${#FAILED[@]} > 0 )); then
echo "STABLE GATE: FAILED"
exit 1
fi
if (( ${#SKIPPED[@]} > 0 )); then
echo "STABLE GATE: INCOMPLETE -- lanes above were not run, so their evidence categories are absent."
echo "A release requires every category. Re-run with MONGODB_DOCKER=1 on a host with Docker."
exit 2
fi
echo "STABLE GATE: PASSED -- every evidence category produced."
@@ -8,27 +8,34 @@
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in Spring
Data MongoDB infrastructure. Design rationale lives in [README.md](README.md).
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in MongoDB
Document Persistence Platform. Design rationale lives in [README.md](README.md); the mapping from the
design package's assumed module layout onto this leaf lives in
`docs/mongodb/repository-adaptation.md` and is the file to update when that mapping changes.
## Responsibility
- Provide opt-in Mongo client and template infrastructure without shipping a fake business domain.
- Real forks add their own document, repository, mapper, and application/domain port implementation.
- Provide the opt-in Mongo client, template and **platform policy** surface without shipping a fake
business domain. Real forks add their own document, repository, mapper, and application/domain port
implementation.
- It does **not** reimplement idempotency / outbox / lock on Mongo (those stay JPA-only).
- Opt-in: `MongoPersistenceConfig` re-imports the Mongo auto-configuration (`@ImportAutoConfiguration`)
only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The connection URI and
database come from Spring's standard `spring.data.mongodb.*` settings.
only when `ca-skeleton.persistence-mongo.enabled=true` (default off). `MongoPlatformAutoConfiguration`
is gated on the same flag. The connection URI and database come from Spring's standard
`spring.data.mongodb.*` settings; platform profiles come from
`ca-skeleton.persistence-mongo.platform.*`.
- `MongoOptInAutoConfigurationImportFilter`, registered through `META-INF/spring.factories`, blocks
Boot 4's classpath-driven sync/reactive/data/repository/health/metrics Mongo auto-configuration
when the module enable flag is absent or false.
## Allowed
- No project dependency is required by the generic infrastructure. The allowed-edge SSOT remains
the `adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`.
- External: `org.springframework.boot:spring-boot-starter-data-mongodb` (version via the shared
Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor).
- No project dependency is required. The allowed-edge SSOT remains the
`adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`.
- External: `spring-boot-starter-data-mongodb` and `-reactive`, `spring-boot-autoconfigure`,
`micrometer-core`, `slf4j-api`, `spring-boot-configuration-processor` (annotation processor).
Versions come from the shared Spring Boot BOM; never pin the driver directly.
- Test-only: ArchUnit, reactor-test, Testcontainers (`mongodb`, `toxiproxy`).
## Forbidden
@@ -38,13 +45,47 @@ Data MongoDB infrastructure. Design rationale lives in [README.md](README.md).
- Adding idempotency/outbox/lock on Mongo without a separately approved contract.
- Fully-qualified inline type references; more than one public top-level type per file.
### Package-boundary rules (`MongoModuleBoundaryTest`)
These reproduce the design's module dependency table. Breaking one fails the build:
- `…mongo.api..` must not import Spring, the MongoDB driver, BSON or Reactor. It is the
framework-free core contract; `api/package-info.java` records why.
- No Stable package may depend on `…mongo.advanced..`.
- No production package may depend on `…mongo.testkit..`.
- `imperative``reactive`, `query``aggregation`, `schema` ↛ execution packages,
`observation` ↛ execution packages, `migration``migration.flamingock`.
### Platform invariants that are not stylistic
- Transaction body retry and commit retry are **separate loops**: a new session per body attempt, and
commit-only retry on an unknown commit. The body is never replayed after a commit ambiguity
(`MongoTransactionRetryCoordinator`, ADR-MONGO-003).
- `MongoExecutionOutcome`'s two ambiguous values must not be collapsed into success or failure.
- BSON representations come from `MongoTypeRepresentationManifest`, never from a library default
(ADR-MONGO-002).
- Index and validator changes go through the manifest and the admin plane; ownership gates every drop
(ADR-MONGO-004).
- Every Advanced entry point refuses construction unless its `MongoAdvancedCapabilityFlags` capability
is enabled.
- Observation tags are limited to `MongoObservationConvention`'s allowlist.
## Tests
`MongoPersistenceConfigTest` proves default/false behavior through an actual
`@EnableAutoConfiguration` context, typed enablement binding, and enabled infrastructure with a
mock `MongoClient` plus a real `MongoTemplate` without a network connection.
mock `MongoClient` plus a real `MongoTemplate` without a network connection. It must keep passing —
the platform additions are opt-in and must not turn the module on by existing.
Hermetic contract tests carry `@Tag("mongodb-contract")` and run in `mongoStableContractTest`, which
`check` depends on. Container lanes carry `mongodb-replicaset` / `mongodb-failover` and run only in
their own tasks; the default `test` task excludes them, because a lane that needs Docker inside
`check` teaches people to skip `check`.
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:check
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
```
Release gates run from the repository root: `scripts/verify-mongodb-platform.sh` (Stable) and
`scripts/verify-mongodb-advanced.sh` (Advanced).
@@ -1,9 +1,13 @@
# adapter:outbound:persistence-mongo
`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in Spring Data MongoDB 인프라 모듈이다.
`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in MongoDB Document Persistence Platform이다.
템플릿 production 코드에 가짜 비즈니스 `Example*` 타입을 두지 않고, 실제 프로젝트가 자신의
document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계
제공한다.
document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계
플랫폼 정책을 제공한다.
설계 원본은 `mongodb-superpowers-package/docs/superpowers/specs/`이고, 이 저장소로 어떻게
매핑했는지는 [docs/mongodb/repository-adaptation.md](../../../../docs/mongodb/repository-adaptation.md)가
단일 기록이다.
## 활성화
@@ -15,9 +19,10 @@ spring.data.mongodb.uri=mongodb://localhost:27017/portfolio
```
활성화 시 `MongoPersistenceConfig`가 Spring Boot의 Mongo client 및 data auto-configuration을
명시적으로 가져와 `MongoClient``MongoTemplate`을 구성한다. repository scanning은 템플릿
임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와 composition을 명시해야
한다.
명시적으로 가져와 `MongoClient``MongoTemplate`을 구성하고, `MongoPlatformAutoConfiguration`
플랫폼 정책 bean(startup validator, client generation registry, health indicator)을 등록한다.
repository scanning은 템플릿이 임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와
composition을 명시해야 한다.
Mongo starter는 classpath만으로도 Boot auto-configuration 후보를 등록하므로 config의 조건만으로는
기본 비활성을 보장할 수 없다. `MongoOptInAutoConfigurationImportFilter`가 Boot 4의 sync/reactive
@@ -26,25 +31,98 @@ client, data, repository, health, metrics Mongo auto-configuration을 default/fa
등록되어 있으며, `enabled=true`일 때는 후보를 그대로 허용한다.
`MongoPersistenceProperties`는 모듈 opt-in만 소유한다. URI, database, credential은 Spring의
표준 `spring.data.mongodb.*` 설정을 사용한다.
표준 `spring.data.mongodb.*` 설정을 사용한다. 플랫폼 profile은
`ca-skeleton.persistence-mongo.platform.*` (`MongoPlatformProperties`)이 소유한다.
## 노출 계층 (D1D4)
| 계층 | 내용 | Client |
|---|---|---|
| D1 표준 document 영속성 | Spring Data repository, typed query, mapping manifest, atomic update, optimistic revision | Stable API V1 strict |
| D2 고급 document 연산 | `MongoTemplate`, transaction/session, bulk, aggregation, keyset cursor, change stream | Stable API V1 strict |
| D3 명시적 capability | native BSON, time series, search/vector, CSFLE/QE, shard-aware | capability client |
| D4 admin plane | collection, validator, index, migration, shard, repair | admin client + 별도 credential |
D3는 raw client escape가 아니다. `PolicyAwareMongoNativeGateway`가 capability → database profile →
collection allowlist → operation name → timeout → consistency → result limit → trace → redaction →
command category → D4 차단 순서를 고정한다.
## 패키지 지도
| 패키지 | 책임 |
|---|---|
| `api` (+ `capability`, `consistency`, `error`, `mapping`, `observation`, `profile`, `schema`) | framework 없는 core 계약. Spring/driver/BSON/Reactor import 금지 (ArchUnit) |
| `mapping` (+ `type`), `failure` | Spring Data 통합, BSON 표현 manifest, 실패 분류·변환 |
| `imperative` (+ `atomic`, `bulk`, `revision`) | 명령형 실행, update operator, bulk 부분 결과, optimistic revision |
| `reactive` (+ `cursor`) | 반응형 실행, cursor lease/guard |
| `query` (+ `budget`, `pagination`) | query guardrail, operation budget, keyset pagination |
| `aggregation` | 등록된 pipeline plan과 risk 등급 |
| `transaction` (+ `retry`, `session`) | transaction 실행, body/commit 분리 retry, causal session |
| `schema` (+ `index`, `manifest`, `model`, `ttl`, `validation`) | document model·index·validator manifest와 diff/apply, TTL 정책 |
| `changestream` (+ `projector`, `recovery`) | at-least-once projector, resume checkpoint, history-lost 처리 |
| `geo` | GeoJSON / 2dsphere |
| `migration` (+ `flamingock`) | checksum·lock·precondition 기반 migration runner |
| `observation` | driver-native command/pool/SDAM 관측, tag allowlist, redaction |
| `security` (+ `admin`), `nativecap` | 역할·credential·TLS profile, admin plane, native capability gateway |
| `autoconfigure` | Boot auto-configuration, startup validation, client generation, release gate |
| `advanced/**` | opt-in Advanced/Experimental capability (sharding, time series, CSFLE, QE, search, vector, tenancy, bridge, GridFS) |
| `architecture` | fork가 자기 코드에 적용하는 `@MongoOperation` marker와 ArchUnit rule set |
## 의존성 경계
- production project dependency 없음
- Spring Boot MongoDB starter configuration processor만 사용
- Spring Boot MongoDB starter(sync/reactive), configuration processor, Micrometer, SLF4J만 사용
- JPA persistence adapter 및 다른 adapter와 의존 관계 없음
- idempotency, outbox, distributed lock은 기존 JPA adapter 책임을 유지
- 패키지 간 방향은 `MongoModuleBoundaryTest`(ArchUnit) 10개 규칙이 강제한다: core-api는 framework
무의존, Stable은 Advanced에 의존 금지, production은 testkit에 의존 금지, imperative↛reactive,
query↛aggregation, schema↛execution, observability↛execution, migration↛flamingock
## 테스트 lane
| Task | 내용 | Docker |
|---|---|---|
| `test` | 단위 + hermetic contract (Docker tag 제외) | 불필요 |
| `mongoStableContractTest` | `mongodb-contract` 태그. `check`에 포함 | 불필요 |
| `mongoReplicaSetTest` | single-node replica set | 필요 |
| `mongoFailoverTest` | 3-node set + Toxiproxy | 필요 |
| `mongoMigrationTest` | migration/backfill 재시작 | 필요 |
| `mongoCompatibilityTest` | MongoDB 7.0 lane | 필요 |
| `mongoSecurityIntegrationTest` | credential/TLS/회전 | 필요 |
| `mongoPerformanceTest` | 자원 budget과 chaos gate | 필요 |
이미지는 고정되어 있다: `mongo:8.0.16`(primary), `mongo:7.0.28`(compatibility),
`ghcr.io/shopify/toxiproxy:2.12.0`. `-PmongoPrimaryImage=` 등으로 재정의할 수 있다.
## 검증
`MongoPersistenceConfigTest`는 다음을 검증한다.
- 실제 `@EnableAutoConfiguration` context의 기본/false 모드에서 Mongo 인프라가 생성되지 않는다.
- enable flag가 typed properties에 바인딩된다.
- enabled 모드는 mock `MongoClient`로 네트워크 없이 실제 `MongoTemplate`을 생성한다.
- `Example` production bean이 존재하지 않는다.
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
```
릴리스 게이트는 저장소 루트에서 실행한다.
```bash
bash scripts/verify-mongodb-platform.sh
bash scripts/verify-mongodb-advanced.sh
```
## 문서
- [docs/mongodb/support-matrix.md](../../../../docs/mongodb/support-matrix.md)
- [docs/mongodb/document-modeling-guide.md](../../../../docs/mongodb/document-modeling-guide.md)
- [docs/mongodb/bson-mapping-guide.md](../../../../docs/mongodb/bson-mapping-guide.md)
- [docs/mongodb/consistency-transaction-guide.md](../../../../docs/mongodb/consistency-transaction-guide.md)
- [docs/mongodb/query-aggregation-guide.md](../../../../docs/mongodb/query-aggregation-guide.md)
- [docs/mongodb/schema-index-migration-guide.md](../../../../docs/mongodb/schema-index-migration-guide.md)
- [docs/mongodb/change-stream-guide.md](../../../../docs/mongodb/change-stream-guide.md)
- [docs/mongodb/security-observability.md](../../../../docs/mongodb/security-observability.md)
- Runbook: [failover](../../../../docs/mongodb/runbooks/failover.md) ·
[unknown-commit](../../../../docs/mongodb/runbooks/unknown-commit.md) ·
[history-lost](../../../../docs/mongodb/runbooks/history-lost.md)
- ADR: [001 platform boundary](../../../../docs/adr/ADR-MONGO-001-platform-boundary.md) ·
[002 BSON representation](../../../../docs/adr/ADR-MONGO-002-bson-representation.md) ·
[003 transaction retry](../../../../docs/adr/ADR-MONGO-003-transaction-retry.md) ·
[004 index/schema admin plane](../../../../docs/adr/ADR-MONGO-004-index-schema-admin-plane.md) ·
[ADV-001 capability promotion](../../../../docs/adr/ADR-MONGO-ADV-001-capability-promotion.md)
@@ -1,13 +1,195 @@
// Driven adapter: opt-in Spring Data MongoDB infrastructure. This leaf owns only enablement and
// Mongo client/template auto-configuration; consuming projects add real documents, repositories,
// mappings, and ports without shipping a fake business domain in the template.
// MongoDB Document Persistence Platform leaf — see
// docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md (design package)
// and docs/mongodb/repository-adaptation.md (how the design's 19 stable + 12 advanced library
// modules map here).
//
// spring-boot-starter-data-mongodb's version is managed by the Spring Boot BOM (applied to every
// module in src/build.gradle), so no module-scoped platform is needed.
description = 'Outbound adapter: opt-in Spring Data MongoDB infrastructure'
// The design models the platform as 19 Stable and 12 Advanced Gradle modules under
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed 19-leaf registry
// (src/config/architecture/modules.json) outranks that layout, so the module boundaries are
// packages under dev.caskeleton.adapter.outbound.mongo and MongoModuleBoundaryTest enforces the
// design's module dependency table.
//
// Driver and Spring Data MongoDB versions come from the Spring Boot BOM applied to every module in
// src/build.gradle (design §4: "개별 Driver 버전 override 금지"), so nothing here pins them.
description = 'Outbound adapter: MongoDB document persistence platform (manifests, atomic writes, ' +
'consistency profiles, guardrails, change streams)'
dependencies {
// D1/D2 imperative execution path and the mapping subsystem.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
// D1/D2 reactive execution path: reactive template, cursors and change streams (design §20).
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
// The starter package registers auto-configuration and binds typed properties.
implementation 'org.springframework.boot:spring-boot-autoconfigure'
// Driver-native observability conventions (design §27) publish through Micrometer.
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The design's module dependency table is enforced as package rules, so ArchUnit is what keeps
// "packages instead of modules" from meaning "no boundary at all".
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'io.projectreactor:reactor-test'
// Real replica set / failover / migration lanes (design §29). Test-scoped so no production
// package can reach a container fixture.
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mongodb'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
}
// The testkit is its own source set rather than part of `test` because several lanes consume it and
// because the design forbids a production module from depending on the testkit. Declaring its
// dependencies only on the test configurations gives that guarantee without a new Gradle project.
sourceSets {
testkit {
java.srcDir 'src/testkit/java'
resources.srcDir 'src/testkit/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
mongoPerformanceTest {
java.srcDir 'src/mongoPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
// The testkit compiles against exactly what a test does: testImplementation already extends
// implementation, so this is the module's own dependencies plus the test libraries.
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
mongoPerformanceTestImplementation.extendsFrom testImplementation
mongoPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
}
// Every test lane compiles and runs against the testkit.
sourceSets.test {
compileClasspath += sourceSets.testkit.output
runtimeClasspath += sourceSets.testkit.output
}
dependencies {
testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testkitImplementation 'io.projectreactor:reactor-test'
testkitImplementation 'org.testcontainers:testcontainers'
testkitImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testkitImplementation 'org.testcontainers:testcontainers-mongodb'
testkitImplementation 'org.testcontainers:testcontainers-toxiproxy'
}
// Pinned server images. The design forbids `latest` for a certification lane (Task 44): a mutable
// tag makes a red run unattributable. `-PmongoPrimaryImage=` / `-PmongoCompatibilityImage=`
// override them for a one-off run.
Closure<Void> applyMongoImageSelection = { task ->
task.systemProperty 'mongodb.primary.image',
(project.findProperty('mongoPrimaryImage') ?: 'mongo:8.0.16').toString()
task.systemProperty 'mongodb.compatibility.image',
(project.findProperty('mongoCompatibilityImage') ?: 'mongo:7.0.28').toString()
task.systemProperty 'mongodb.toxiproxy.image',
(project.findProperty('mongoToxiproxyImage') ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString()
}
// Docker-backed lanes are excluded from the default unit run: they fail closed without Docker, and
// a `check` that fails on a laptop without Docker teaches people to skip `check`.
tasks.named('test', Test) {
useJUnitPlatform {
excludeTags 'quarantine',
'mongodb-replicaset',
'mongodb-failover',
'mongodb-migration',
'mongodb-compatibility',
'mongodb-security-integration'
}
}
tasks.register('mongoReplicaSetTest', Test) {
group = 'verification'
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
'change stream (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-replicaset' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoFailoverTest', Test) {
group = 'verification'
description = 'Three-node replica set failover lane: primary kill, partition, unknown commit, ' +
'resume (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-failover' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoMigrationTest', Test) {
group = 'verification'
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
'restart (design §12).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-migration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoCompatibilityTest', Test) {
group = 'verification'
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-compatibility' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoSecurityIntegrationTest', Test) {
group = 'verification'
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
'(design §26).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-security-integration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoPerformanceTest', Test) {
group = 'verification'
description = 'Certifies contention, aggregation spill, pagination and pool resource bounds ' +
'(design §29).'
testClassesDirs = sourceSets.mongoPerformanceTest.output.classesDirs
classpath = sourceSets.mongoPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyMongoImageSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// `check` gains only the hermetic lanes. The Docker-backed ones stay opt-in for the reason above.
tasks.named('check') {
dependsOn 'mongoStableContractTest'
}
tasks.register('mongoStableContractTest', Test) {
group = 'verification'
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
'redaction (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-contract' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
@@ -1,166 +1,194 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,mongoPerformanceTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath
com.google.code.gson:gson:2.13.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.java.dev.jna:jna:5.18.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.minidev:accessors-smart:2.6.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.minidev:json-smart:2.6.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.assertj:assertj-core:3.27.6=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.awaitility:awaitility:4.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,mongoPerformanceTestAnnotationProcessor,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit:junit-bom:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mongodb:bson-record-codec:5.6.1=runtimeClasspath,testRuntimeClasspath
org.mongodb:bson:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.latencyutils:LatencyUtils:2.0.3=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:bson-record-codec:5.6.1=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.mongodb:bson:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-reactivestreams:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.objenesis:objenesis:3.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.ow2.asm:asm:9.7.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-mongodb:5.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-reactor:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-mongodb-reactive:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.data:spring-data-mongodb:5.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-test:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-web:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-mongodb:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-toxiproxy:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
empty=
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/**
* The per-capability opt-in switch for everything Advanced (advanced plan Task 1).
*
* <p>Every Advanced capability is off unless explicitly enabled. That is not caution for its own
* sake: each of these needs something the Stable lane does not have — a sharded cluster, an Atlas
* deployment, a KMS, a separate credential — and a capability that wires itself because its code is
* on the classpath will fail at the first call rather than at startup.
*
* <p>The Stable starter references nothing in this package, so no Advanced capability can arrive as
* a transitive dependency of ordinary document persistence.
*/
public final class MongoAdvancedCapabilityFlags {
/** The configuration prefix each capability's flag lives under. */
public static final String PROPERTY_PREFIX = "ca-skeleton.persistence-mongo.advanced";
private final Map<MongoCapability, Boolean> enabled;
private MongoAdvancedCapabilityFlags(Map<MongoCapability, Boolean> enabled) {
this.enabled = enabled;
}
/** Every Advanced capability disabled. */
public static MongoAdvancedCapabilityFlags allDisabled() {
return new MongoAdvancedCapabilityFlags(new EnumMap<>(MongoCapability.class));
}
/** A flag set built from configuration. */
public static MongoAdvancedCapabilityFlags of(Map<MongoCapability, Boolean> flags) {
Objects.requireNonNull(flags, "flags");
return new MongoAdvancedCapabilityFlags(new EnumMap<>(flags));
}
/** Returns a copy with one capability enabled. */
public MongoAdvancedCapabilityFlags withEnabled(MongoCapability capability) {
Map<MongoCapability, Boolean> updated = new EnumMap<>(enabled);
updated.put(Objects.requireNonNull(capability, "capability"), true);
return new MongoAdvancedCapabilityFlags(updated);
}
/** True when a capability has been explicitly enabled. */
public boolean isEnabled(MongoCapability capability) {
return Boolean.TRUE.equals(enabled.get(Objects.requireNonNull(capability, "capability")));
}
/**
* Fails when a capability is used without being enabled.
*
* @throws MongoOperationRejectedException naming the property that would enable it
*/
public void require(MongoCapability capability) {
if (!isEnabled(capability)) {
throw MongoOperationRejectedException.of(
"advanced.capability",
"capability "
+ capability
+ " is an opt-in Advanced module; set "
+ propertyFor(capability)
+ "=true and provide the topology, credential and provider it requires");
}
}
/** The configuration property that enables a capability. */
public static String propertyFor(MongoCapability capability) {
return PROPERTY_PREFIX
+ '.'
+ Objects.requireNonNull(capability, "capability")
.name()
.toLowerCase(java.util.Locale.ROOT)
.replace('_', '-')
+ ".enabled";
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What promoting an Advanced capability requires (advanced plan Task 15).
*
* <p>{@code actual-topology} is the category that cannot be substituted. Every other kind of
* evidence can be produced in CI; sharding, search, vector and encryption behave differently on a
* real cluster or a real provider, and that difference is the whole reason they are Advanced rather
* than Stable.
*/
public record MongoAdvancedPromotionEvidence(Set<String> requiredCategories, Set<String> supplied) {
/** The categories every promotion must supply. */
public static final Set<String> REQUIRED =
Set.of("stable-platform", "actual-topology", "security", "migration", "failure", "runbook");
public MongoAdvancedPromotionEvidence {
Objects.requireNonNull(requiredCategories, "requiredCategories");
Objects.requireNonNull(supplied, "supplied");
requiredCategories = Set.copyOf(requiredCategories);
supplied = Set.copyOf(supplied);
}
/** The standard requirement set with nothing supplied yet. */
public static MongoAdvancedPromotionEvidence fixture() {
return new MongoAdvancedPromotionEvidence(REQUIRED, Set.of());
}
/** Returns a copy with one more category supplied. */
public MongoAdvancedPromotionEvidence with(String category) {
Set<String> updated = new LinkedHashSet<>(supplied);
updated.add(Objects.requireNonNull(category, "category"));
return new MongoAdvancedPromotionEvidence(requiredCategories, updated);
}
/**
* Asserts one category is supplied.
*
* @throws IllegalStateException naming the missing category
*/
public void require(String category) {
if (!supplied.contains(Objects.requireNonNull(category, "category"))) {
throw new IllegalStateException(
"the MongoDB Advanced promotion gate is missing '"
+ category
+ "' evidence; the required categories are "
+ requiredCategories);
}
}
/** The categories still missing. */
public Set<String> missing() {
Set<String> missing = new LinkedHashSet<>(requiredCategories);
missing.removeAll(supplied);
return Set.copyOf(missing);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import java.util.Objects;
/**
* The check that decides whether an Advanced capability may be promoted (advanced plan Task 15).
*
* <p>Promotion to Stable changes the support level, not the dependency boundary: a promoted
* capability is still an opt-in module until a separate starter ADR says otherwise. Conflating the
* two would mean a promotion silently adds a dependency — and a topology requirement — to every
* deployment that only wanted ordinary document persistence.
*/
public final class MongoAdvancedPromotionGate {
/**
* Verifies one capability's promotion evidence.
*
* @throws IllegalStateException naming the first missing category
*/
public void verify(MongoAdvancedPromotionEvidence evidence) {
Objects.requireNonNull(evidence, "evidence");
evidence.require("stable-platform");
evidence.require("actual-topology");
evidence.require("security");
evidence.require("failure");
evidence.require("runbook");
}
/** True when every required category is supplied. */
public boolean passes(MongoAdvancedPromotionEvidence evidence) {
return Objects.requireNonNull(evidence, "evidence").missing().isEmpty();
}
/**
* Whether promotion adds the capability to the Stable starter's dependencies.
*
* <p>Always false; that needs its own ADR.
*/
public boolean addsStarterDependency() {
return false;
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
/**
* When the bridge may advance its MongoDB checkpoint (advanced plan Task 12).
*
* <p>Only after the broker has accepted the message. Advancing on a failed publish loses the event
* with no trace — the change stream moves past it and nothing will ever redeliver it — so the
* bridge chooses duplicates over loss here, exactly as the projector does.
*/
public enum MongoBridgeCheckpointPolicy {
/** Advance only after the broker accepted the message. */
AFTER_PUBLISH_CONFIRMED,
/**
* Advance after an ambiguous publish result.
*
* <p>Valid only when the message id is deterministic and the consumer deduplicates, because the
* message may or may not have been accepted.
*/
AFTER_PUBLISH_AMBIGUOUS_WITH_DEDUPLICATION;
/**
* True when the checkpoint may advance given this publish outcome.
*
* @param published whether the broker confirmed acceptance
* @param ambiguous whether the publish result was unknown
*/
public boolean mayAdvance(boolean published, boolean ambiguous) {
if (published) {
return true;
}
return ambiguous && this == AFTER_PUBLISH_AMBIGUOUS_WITH_DEDUPLICATION;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
/**
* When a change stream bridge is not enough (advanced plan Task 12).
*
* <p>The bridge publishes after the write is committed, so there is a window in which the write
* exists and the event does not. For most integrations that is fine — the event arrives late. It is
* not fine when the event must exist if and only if the write does, and the only way to get that is
* to write the event in the same transaction as the data, which is a transactional outbox.
*
* <p>Stated as a policy rather than a comment because the distinction is easy to get wrong in the
* direction that looks like it works.
*/
public enum MongoBridgeOutboxPolicy {
/**
* At-least-once publication after commit is acceptable.
*
* <p>The consumer tolerates duplicates and a delay, and no business rule depends on the event
* existing exactly when the write does.
*/
CHANGE_STREAM_SUFFICIENT,
/**
* The event and the write must be atomic.
*
* <p>Use a transactional outbox: write the event document in the same transaction as the data and
* publish from the outbox.
*/
OUTBOX_REQUIRED;
/** True when a change stream bridge can serve this integration. */
public boolean bridgeSufficient() {
return this == CHANGE_STREAM_SUFFICIENT;
}
/**
* Chooses a policy from the integration's requirements.
*
* @param eventMustBeAtomicWithWrite whether a consumer may ever observe the write without the
* event
*/
public static MongoBridgeOutboxPolicy forRequirement(boolean eventMustBeAtomicWithWrite) {
return eventMustBeAtomicWithWrite ? OUTBOX_REQUIRED : CHANGE_STREAM_SUFFICIENT;
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore;
import java.util.Objects;
import org.bson.BsonDocument;
import reactor.core.publisher.Mono;
/**
* Publishes mapped integration events and checkpoints only after the broker accepts (advanced plan
* Task 12).
*
* <p>A failed publish leaves the MongoDB checkpoint untouched, so the change is redelivered. That
* is the same trade the change stream projector makes — duplicates rather than loss — and it works
* for the same reason: the message id is derived from the change identity, so a redelivered change
* produces a message the consumer can recognise as one it has already seen.
*/
public final class MongoChangeMessagingBridge {
private final MongoChangeToIntegrationEventMapper mapper;
private final MongoIntegrationEventPublisher publisher;
private final MongoResumeCheckpointStore checkpoints;
private final MongoBridgeCheckpointPolicy checkpointPolicy;
public MongoChangeMessagingBridge(
MongoChangeToIntegrationEventMapper mapper,
MongoIntegrationEventPublisher publisher,
MongoResumeCheckpointStore checkpoints,
MongoBridgeCheckpointPolicy checkpointPolicy) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.publisher = Objects.requireNonNull(publisher, "publisher");
this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints");
this.checkpointPolicy = Objects.requireNonNull(checkpointPolicy, "checkpointPolicy");
}
/**
* Handles one change event.
*
* <p>A change the mapper does not map still advances the checkpoint: it was considered and found
* uninteresting, which is different from having failed to publish it.
*/
public Mono<Void> handle(
MongoChangeEventIdentity identity, BsonDocument change, MongoResumeCheckpoint checkpoint) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(change, "change");
Objects.requireNonNull(checkpoint, "checkpoint");
MongoIntegrationEventEnvelope envelope = mapper.map(identity, change);
if (envelope == null) {
return checkpoints.save(checkpoint);
}
return publisher
.publish(envelope)
.then(Mono.defer(() -> advanceIfAllowed(checkpoint, true, false)))
.onErrorResume(failure -> Mono.error(failure));
}
private Mono<Void> advanceIfAllowed(
MongoResumeCheckpoint checkpoint, boolean published, boolean ambiguous) {
return checkpointPolicy.mayAdvance(published, ambiguous)
? checkpoints.save(checkpoint)
: Mono.empty();
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
import org.bson.BsonDocument;
/**
* Turns a physical change into a business event (advanced plan Task 12).
*
* <p>This mapper is the boundary the design refuses to remove. A MongoDB change event exposes the
* collection's field names, its update descriptions and its storage layout; publishing it as an
* integration contract makes every consumer depend on all three, so renaming a field becomes a
* breaking change to an external API.
*
* <p>The event type, schema version and message id are the mapper's own decisions, not derived from
* the change document, which is what lets the storage layout change without the contract changing.
*/
@FunctionalInterface
public interface MongoChangeToIntegrationEventMapper {
/**
* Maps one change event, or returns {@code null} when the change is not externally interesting.
*/
MongoIntegrationEventEnvelope map(MongoChangeEventIdentity identity, BsonDocument change);
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import java.util.Map;
import java.util.Objects;
/**
* A stable integration event, ready to publish (advanced plan Task 12).
*
* <p>The platform's own envelope rather than the messaging module's type: this leaf may not depend
* on a sibling adapter, so the composition root adapts this to whatever the messaging platform
* publishes. The adaptation is one mapper; the alternative would be a dependency edge the
* architecture registry forbids.
*
* <p>{@code messageId} is derived from the change event identity, so a redelivered change produces
* the same message id and the consumer's deduplication works.
*/
public record MongoIntegrationEventEnvelope(
String eventType, int schemaVersion, String messageId, Map<String, Object> payload) {
public MongoIntegrationEventEnvelope {
Objects.requireNonNull(eventType, "eventType");
Objects.requireNonNull(messageId, "messageId");
Objects.requireNonNull(payload, "payload");
payload = Map.copyOf(payload);
if (eventType.isBlank()) {
throw new IllegalArgumentException("an integration event needs a type");
}
if (schemaVersion < 1) {
throw new IllegalArgumentException("an integration event schema version starts at 1");
}
if (messageId.isBlank()) {
throw new IllegalArgumentException(
"an integration event needs a deterministic message id so a redelivered change produces "
+ "the same message");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import reactor.core.publisher.Mono;
/**
* The outbound port the bridge publishes through (advanced plan Task 12).
*
* <p>Declared here rather than imported from the messaging adapter, because the architecture
* registry does not permit an edge between two outbound adapters. The composition root implements
* this against whichever messaging platform is wired.
*/
@FunctionalInterface
public interface MongoIntegrationEventPublisher {
/** Publishes one event. Completes only when the broker has accepted it. */
Mono<Void> publish(MongoIntegrationEventEnvelope envelope);
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import com.mongodb.AutoEncryptionSettings;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Builds the automatic encryption settings for a CSFLE-enabled client (advanced plan Task 6).
*
* <p>KMS providers are passed through as an opaque map and never copied into a field, a log or a
* failure message. The platform's job here is to assemble settings, not to hold key material for
* longer than the call.
*/
public final class MongoCsfleClientFactory {
public MongoCsfleClientFactory(MongoAdvancedCapabilityFlags flags) {
// Checked once, at construction: an instance cannot exist unless CSFLE was enabled.
Objects.requireNonNull(flags, "flags").require(MongoCapability.CSFLE);
}
/**
* Builds automatic encryption settings for one profile.
*
* @param kmsProviders the KMS configuration, passed straight to the driver
* @param encryptedFieldsMapJson the per-collection encrypted field map, as extended JSON
*/
public AutoEncryptionSettings settingsFor(
MongoCsfleProfile profile,
Map<String, Map<String, Object>> kmsProviders,
String encryptedFieldsMapJson) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(kmsProviders, "kmsProviders");
Objects.requireNonNull(encryptedFieldsMapJson, "encryptedFieldsMapJson");
Map<String, org.bson.BsonDocument> schemaMap = new LinkedHashMap<>();
schemaMap.put(profile.collection(), org.bson.BsonDocument.parse(encryptedFieldsMapJson));
return AutoEncryptionSettings.builder()
.keyVaultNamespace(profile.keyVaultNamespace())
.kmsProviders(kmsProviders)
.schemaMap(schemaMap)
.build();
}
/** The capability this factory requires. */
public MongoCapability capability() {
return MongoCapability.CSFLE;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import java.util.Objects;
/**
* How one field is encrypted, and why (advanced plan Task 6).
*
* <p>{@link #forPii} defaults to randomized. Deterministic encryption is only reachable by asking
* for it with a stated equality-query requirement, because it is the choice that leaks: the default
* has to be the safe one, since the unsafe one is also the more convenient one.
*/
public record MongoCsfleFieldPolicy(
String fieldPath, MongoCsfleMode mode, String keyAlias, String equalityQueryJustification) {
public MongoCsfleFieldPolicy {
Objects.requireNonNull(fieldPath, "fieldPath");
Objects.requireNonNull(mode, "mode");
Objects.requireNonNull(keyAlias, "keyAlias");
Objects.requireNonNull(equalityQueryJustification, "equalityQueryJustification");
if (fieldPath.isBlank()) {
throw new IllegalArgumentException("an encrypted field needs a path");
}
if (mode.requiresLeakageReview() && equalityQueryJustification.isBlank()) {
throw new IllegalArgumentException(
"deterministic encryption of '"
+ fieldPath
+ "' needs a documented equality-query requirement: stable ciphertext exposes the "
+ "value distribution, which recovers low-cardinality plaintext by frequency analysis");
}
}
/**
* The policy for a PII field.
*
* @param queryable whether the field must support equality queries
*/
public static MongoCsfleFieldPolicy forPii(String fieldPath, boolean queryable) {
return queryable
? new MongoCsfleFieldPolicy(
fieldPath,
MongoCsfleMode.DETERMINISTIC,
defaultKeyAlias(fieldPath),
"equality lookup required by the use case")
: new MongoCsfleFieldPolicy(
fieldPath, MongoCsfleMode.RANDOMIZED, defaultKeyAlias(fieldPath), "");
}
/** The policy for a field that is never queried and never indexed. */
public static MongoCsfleFieldPolicy unindexed(String fieldPath) {
return new MongoCsfleFieldPolicy(
fieldPath, MongoCsfleMode.UNINDEXED, defaultKeyAlias(fieldPath), "");
}
private static String defaultKeyAlias(String fieldPath) {
return "key-" + fieldPath.replace('.', '-');
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
/**
* How a CSFLE field is encrypted (advanced plan Task 6).
*
* <p>The two differ in what they leak. Randomized produces a different ciphertext every time, so
* nothing can be inferred and nothing can be queried. Deterministic produces the same ciphertext
* for the same plaintext, which makes equality queries work and makes the distribution of values
* visible — on a low-cardinality field such as a status or a country, frequency analysis recovers
* the plaintext without any key.
*/
public enum MongoCsfleMode {
/** Different ciphertext each time. Not queryable, leaks nothing. The default for PII. */
RANDOMIZED,
/** Stable ciphertext. Supports equality queries, leaks value distribution. */
DETERMINISTIC,
/** Encrypted without an index; not queryable at all. */
UNINDEXED;
/** True when equality queries work against this mode. */
public boolean supportsEqualityQuery() {
return this == DETERMINISTIC;
}
/** True when choosing this mode requires a documented leakage review. */
public boolean requiresLeakageReview() {
return this == DETERMINISTIC;
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference;
import java.util.List;
import java.util.Objects;
/**
* One collection's CSFLE configuration (advanced plan Task 6).
*
* <p>The key vault has its own credential. Sharing the application's would mean a compromise of the
* application is also a compromise of the data keys, which makes the encryption ornamental.
*
* <p>CSFLE and Queryable Encryption are refused on the same collection: they are separate
* mechanisms with separate metadata, and combining them produces a collection neither can fully
* read.
*/
public record MongoCsfleProfile(
String collection,
List<MongoCsfleFieldPolicy> fields,
MongoCredentialReference keyVaultCredential,
String keyVaultNamespace,
boolean queryableEncryptionPresent) {
public MongoCsfleProfile {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(fields, "fields");
Objects.requireNonNull(keyVaultCredential, "keyVaultCredential");
Objects.requireNonNull(keyVaultNamespace, "keyVaultNamespace");
fields = List.copyOf(fields);
if (queryableEncryptionPresent) {
throw new IllegalArgumentException(
"collection '"
+ collection
+ "' already uses Queryable Encryption; CSFLE and QE are separate mechanisms and must "
+ "not be applied to the same collection");
}
if (fields.isEmpty()) {
throw new IllegalArgumentException("a CSFLE profile needs at least one encrypted field");
}
}
/**
* Rejects CSFLE on a time series collection.
*
* @throws MongoOperationRejectedException when the collection is a time series collection
*/
public void requireNotTimeSeries(boolean timeSeries) {
if (timeSeries) {
throw MongoOperationRejectedException.of(
"encryption.csfle",
"a time series collection does not support CSFLE; encrypt the measurements upstream");
}
}
/** The field policies that support equality queries. */
public List<MongoCsfleFieldPolicy> queryableFields() {
return fields.stream().filter(field -> field.mode().supportsEqualityQuery()).toList();
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import java.util.Optional;
/**
* Resolves the data key a field is encrypted with (advanced plan Task 6).
*
* <p>An interface rather than a lookup table, because the key for a field can depend on the tenant.
* Per-tenant keys are what make "delete this tenant's data" achievable by destroying one key
* instead of finding every document.
*
* <p>Implementations return an alias, never key material. The driver resolves the alias against the
* key vault; the platform never holds a plaintext key.
*/
public interface MongoDataKeyResolver {
/** The key alias for a field, optionally scoped to a tenant. */
Optional<String> resolveKeyAlias(String collection, String fieldPath, String tenantKey);
/** A resolver that always returns the field policy's declared alias. */
static MongoDataKeyResolver fixed(MongoCsfleProfile profile) {
return (collection, fieldPath, tenantKey) ->
profile.fields().stream()
.filter(field -> field.fieldPath().equals(fieldPath))
.map(MongoCsfleFieldPolicy::keyAlias)
.findFirst();
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import java.util.Objects;
import java.util.Optional;
/**
* One Queryable Encryption field (advanced plan Task 7).
*
* <p>A range field must declare its bounds. QE range indexes are built over a declared domain, and
* a value outside it cannot be inserted — so the bounds are part of the schema, not a tuning
* parameter, and widening them later is a re-encryption rather than a configuration change.
*/
public record MongoEncryptedFieldDescriptor(
String path,
MongoQueryableEncryptionQueryType queryType,
String keyAlias,
String bsonType,
Long rangeMinimum,
Long rangeMaximum) {
public MongoEncryptedFieldDescriptor {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(queryType, "queryType");
Objects.requireNonNull(keyAlias, "keyAlias");
Objects.requireNonNull(bsonType, "bsonType");
if (path.isBlank()) {
throw new IllegalArgumentException("an encrypted field needs a path");
}
if (queryType == MongoQueryableEncryptionQueryType.RANGE
&& (rangeMinimum == null || rangeMaximum == null)) {
throw new IllegalArgumentException(
"range field '"
+ path
+ "' must declare its minimum and maximum; a QE range index is built over a declared "
+ "domain and widening it later means re-encrypting the collection");
}
if (rangeMinimum != null && rangeMaximum != null && rangeMinimum >= rangeMaximum) {
throw new IllegalArgumentException("range field '" + path + "' has an empty domain");
}
}
/** An equality-queryable encrypted field. */
public static MongoEncryptedFieldDescriptor equality(
String path, String keyAlias, String bsonType) {
return new MongoEncryptedFieldDescriptor(
path, MongoQueryableEncryptionQueryType.EQUALITY, keyAlias, bsonType, null, null);
}
/** A range-queryable encrypted field over a declared domain. */
public static MongoEncryptedFieldDescriptor range(
String path, String keyAlias, String bsonType, long minimum, long maximum) {
return new MongoEncryptedFieldDescriptor(
path, MongoQueryableEncryptionQueryType.RANGE, keyAlias, bsonType, minimum, maximum);
}
/** The declared domain, when this is a range field. */
public Optional<long[]> domain() {
return rangeMinimum == null || rangeMaximum == null
? Optional.empty()
: Optional.of(new long[] {rangeMinimum, rangeMaximum});
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership;
import java.util.Objects;
import java.util.Set;
/**
* Marks Queryable Encryption's internal state as untouchable (advanced plan Task 7).
*
* <p>QE maintains a {@code __safeContent__} array and companion metadata collections. To
* application drift cleanup they look exactly like orphans nobody declared — and dropping one makes
* the encrypted collection unqueryable until it is rebuilt from scratch. This is the list that
* stops that from happening.
*/
public final class MongoEncryptionMetadataOwnership {
/** The field QE maintains inside every encrypted document. */
public static final String SAFE_CONTENT_FIELD = "__safeContent__";
/** The prefix of the collections QE maintains alongside an encrypted collection. */
public static final String METADATA_COLLECTION_PREFIX = "enxcol_.";
private MongoEncryptionMetadataOwnership() {}
/** The internal collections QE maintains for one encrypted collection. */
public static Set<String> metadataCollectionsFor(String collection) {
Objects.requireNonNull(collection, "collection");
return Set.of(
METADATA_COLLECTION_PREFIX + collection + ".esc",
METADATA_COLLECTION_PREFIX + collection + ".ecoc");
}
/** The ownership a drift engine must assign to a QE-managed artefact. */
public static MongoMetadataOwnership ownershipOf(String name) {
Objects.requireNonNull(name, "name");
return name.startsWith(METADATA_COLLECTION_PREFIX) || name.contains(SAFE_CONTENT_FIELD)
? MongoMetadataOwnership.ENCRYPTION_MANAGED
: MongoMetadataOwnership.APPLICATION;
}
/** True when drift cleanup must leave this artefact alone. */
public static boolean isEncryptionManaged(String name) {
return ownershipOf(name) == MongoMetadataOwnership.ENCRYPTION_MANAGED;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Creates and maintains encrypted collections, on the admin plane (advanced plan Task 7).
*
* <p>An encrypted collection must exist with its encrypted-fields map before the first application
* write. Writing to a collection that was created without it produces plaintext documents that look
* correct and are not encrypted and the only fix is to re-encrypt and re-import everything
* already written.
*/
public final class MongoQueryableEncryptionCollectionManager {
private final MongoAdminGateway adminGateway;
public MongoQueryableEncryptionCollectionManager(
MongoAdminGateway adminGateway, MongoAdvancedCapabilityFlags flags) {
this.adminGateway = Objects.requireNonNull(adminGateway, "adminGateway");
// The flag is checked once, here: an instance of this manager cannot exist unless the
// capability
// was enabled, so no later method has to re-check it.
Objects.requireNonNull(flags, "flags").require(MongoCapability.QUERYABLE_ENCRYPTION);
}
/** Creates the encrypted collection and its metadata collections. */
public void createEncryptedCollection(
MongoQueryableEncryptionProfile profile,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(profile, "profile");
adminGateway.execute(
MongoAdminOperation.CREATE_COLLECTION, profile.collection(), operator, reason, apply);
}
/**
* Refuses an application write to a collection that was not set up as encrypted.
*
* @throws MongoOperationRejectedException when setup has not completed
*/
public void requireSetupComplete(String collection, boolean encryptedCollectionExists) {
if (!encryptedCollectionExists) {
throw MongoOperationRejectedException.of(
"encryption.qe",
"collection '"
+ collection
+ "' has not been created with its encrypted-fields map; writing now would store "
+ "plaintext that looks correct and is not encrypted");
}
}
/** Rotates a data key. Requires its own runbook and evidence. */
public void rotateDataKey(String keyAlias, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(
MongoAdminOperation.MANAGE_ENCRYPTION_KEY, keyAlias, operator, reason, apply);
}
/** Compacts the QE metadata collections, which grow with every encrypted write. */
public void compactMetadata(
String collection, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(MongoAdminOperation.COLL_MOD, collection, operator, reason, apply);
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import java.util.List;
import java.util.Objects;
/**
* One collection's Queryable Encryption configuration (advanced plan Task 7).
*
* <p>The unsupported query shapes are constructible and immediately rejected, so a team that
* planned a "search encrypted names" feature learns it is not available from a named exception
* rather than from a query that returns nothing.
*/
public record MongoQueryableEncryptionProfile(
String collection, List<MongoEncryptedFieldDescriptor> fields, boolean csfleAlreadyApplied) {
public MongoQueryableEncryptionProfile {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(fields, "fields");
fields = List.copyOf(fields);
if (csfleAlreadyApplied) {
throw new IllegalArgumentException(
"collection '"
+ collection
+ "' already uses CSFLE; CSFLE and Queryable Encryption are separate mechanisms and "
+ "must not be applied to the same collection");
}
if (fields.isEmpty()) {
throw new IllegalArgumentException("a QE profile needs at least one encrypted field");
}
}
/** A profile whose fields are all equality-queryable. */
public static MongoQueryableEncryptionProfile equality(
String collection, List<MongoEncryptedFieldDescriptor> fields) {
return new MongoQueryableEncryptionProfile(collection, fields, false);
}
/**
* Prefix queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile prefix(String path) {
return refuse("prefix", path);
}
/**
* Suffix queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile suffix(String path) {
return refuse("suffix", path);
}
/**
* Substring queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile substring(String path) {
return refuse("substring", path);
}
private static MongoQueryableEncryptionProfile refuse(String queryShape, String path) {
Objects.requireNonNull(path, "path");
throw new UnsupportedOperationException(
queryShape
+ " Queryable Encryption on '"
+ path
+ "' is not part of the MongoDB 8.0 Stable surface; the supported query types are "
+ List.of(MongoQueryableEncryptionQueryType.values()));
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
/**
* The query types Queryable Encryption supports on the MongoDB 8.0 Stable lane (advanced plan Task
* 7).
*
* <p>Equality and range, and nothing else. Prefix, suffix and substring queries are not part of the
* 8.0 Stable surface, and modelling them as constants that are rejected rather than leaving them
* out is what turns "we planned a search feature on an encrypted field" into a design-time
* answer.
*/
public enum MongoQueryableEncryptionQueryType {
/** Encrypted equality lookup. */
EQUALITY,
/** Encrypted range lookup. */
RANGE
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.io.InputStream;
/**
* Read-only access to legacy GridFS content (advanced plan Task 13, design D-14).
*
* <p>There is no upload method, and that is deliberate. Offering one would make GridFS a live file
* platform again, and the migration this module exists to perform would never finish new files
* would keep arriving in the place everything is being moved out of.
*/
public interface MongoGridFsCompatibilityReader {
/** Opens a legacy file for reading. */
GridFsLegacyContent open(String legacyId);
/**
* One legacy GridFS file.
*
* @param legacyId the GridFS file id
* @param filename the stored filename
* @param sizeBytes the file length
* @param checksum the stored checksum, or an empty string when GridFS recorded none
* @param content the byte stream, which the caller closes
*/
record GridFsLegacyContent(
String legacyId, String filename, long sizeBytes, String checksum, InputStream content) {}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.time.Instant;
import java.util.Objects;
/**
* How far a GridFS migration has progressed (advanced plan Task 13).
*
* <p>Migrating a file corpus is measured in hours or days, so the checkpoint is what makes the job
* survivable across deployments. The failed count is tracked separately from the migrated count
* because a run that migrated everything except forty files is a different situation from one that
* migrated everything.
*/
public record MongoGridFsMigrationCheckpoint(
String lastMigratedLegacyId, long migratedCount, long failedCount, Instant updatedAt) {
public MongoGridFsMigrationCheckpoint {
Objects.requireNonNull(lastMigratedLegacyId, "lastMigratedLegacyId");
Objects.requireNonNull(updatedAt, "updatedAt");
if (migratedCount < 0 || failedCount < 0) {
throw new IllegalArgumentException("migration counts must not be negative");
}
}
/** The checkpoint before anything has been migrated. */
public static MongoGridFsMigrationCheckpoint start(Instant now) {
return new MongoGridFsMigrationCheckpoint("", 0, 0, now);
}
/** The checkpoint after one successful file. */
public MongoGridFsMigrationCheckpoint migrated(String legacyId, Instant now) {
return new MongoGridFsMigrationCheckpoint(legacyId, migratedCount + 1, failedCount, now);
}
/** The checkpoint after one failed file; the position still advances so the run continues. */
public MongoGridFsMigrationCheckpoint failed(String legacyId, Instant now) {
return new MongoGridFsMigrationCheckpoint(legacyId, migratedCount, failedCount + 1, now);
}
/** True when every file processed so far succeeded. */
public boolean clean() {
return failedCount == 0;
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import java.time.Clock;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Consumer;
/**
* Copies legacy GridFS content into the file source of truth (advanced plan Task 13).
*
* <p>Verify before switching, and never delete here. The order is copy, verify size and checksum,
* then write the new reference so a mismatch leaves the document pointing at GridFS, where the
* bytes still are. Deleting the source is a separate, audited cleanup phase that runs after the
* references have been switched and observed.
*
* <p>The legacy id is the deterministic identity, so re-running the job over an already-migrated
* file produces the same content key rather than a second copy.
*/
public final class MongoGridFsMigrationJob {
private final MongoGridFsCompatibilityReader reader;
private final BiFunction<
String, MongoGridFsCompatibilityReader.GridFsLegacyContent, MongoGridFsObjectReference>
contentStoreWriter;
private final Consumer<MongoGridFsObjectReference> referenceWriter;
private final Clock clock;
public MongoGridFsMigrationJob(
MongoGridFsCompatibilityReader reader,
BiFunction<
String,
MongoGridFsCompatibilityReader.GridFsLegacyContent,
MongoGridFsObjectReference>
contentStoreWriter,
Consumer<MongoGridFsObjectReference> referenceWriter,
MongoAdvancedCapabilityFlags flags,
Clock clock) {
this.reader = Objects.requireNonNull(reader, "reader");
this.contentStoreWriter = Objects.requireNonNull(contentStoreWriter, "contentStoreWriter");
this.referenceWriter = Objects.requireNonNull(referenceWriter, "referenceWriter");
this.clock = Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(flags, "flags").require(MongoCapability.GRIDFS_COMPATIBILITY);
}
/**
* Migrates one file.
*
* @return the new reference when the copy verified, empty when it did not
*/
public Optional<MongoGridFsObjectReference> migrate(String legacyId) {
Objects.requireNonNull(legacyId, "legacyId");
MongoGridFsCompatibilityReader.GridFsLegacyContent source = reader.open(legacyId);
MongoGridFsObjectReference written = contentStoreWriter.apply(legacyId, source);
if (!written.matches(source.sizeBytes(), source.checksum())) {
// The source stays exactly where it is: the document still references GridFS, so nothing is
// lost and the file can be retried.
return Optional.empty();
}
referenceWriter.accept(written);
return Optional.of(written);
}
/** Migrates one file and folds the outcome into a checkpoint. */
public MongoGridFsMigrationCheckpoint migrate(
String legacyId, MongoGridFsMigrationCheckpoint checkpoint) {
Objects.requireNonNull(checkpoint, "checkpoint");
return migrate(legacyId).isPresent()
? checkpoint.migrated(legacyId, clock.instant())
: checkpoint.failed(legacyId, clock.instant());
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.util.Objects;
/**
* The reference that replaces a GridFS file after migration (advanced plan Task 13, design D-14).
*
* <p>The document keeps a reference; the bytes live in the file source of truth. That is the whole
* point of the migration: GridFS makes MongoDB a file server, which means file storage competes
* with the working set for cache and with the oplog for replication bandwidth.
*/
public record MongoGridFsObjectReference(
String legacyGridFsId, String contentKey, long sizeBytes, String checksum) {
public MongoGridFsObjectReference {
Objects.requireNonNull(legacyGridFsId, "legacyGridFsId");
Objects.requireNonNull(contentKey, "contentKey");
Objects.requireNonNull(checksum, "checksum");
if (sizeBytes < 0) {
throw new IllegalArgumentException("a content size must not be negative");
}
if (checksum.isBlank()) {
throw new IllegalArgumentException(
"a migrated reference needs a checksum; without one the copy cannot be verified and the "
+ "source cannot safely be deleted");
}
}
/** True when a target object matches this reference's size and checksum. */
public boolean matches(long targetSizeBytes, String targetChecksum) {
return sizeBytes == targetSizeBytes && checksum.equals(targetChecksum);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership;
import java.util.List;
import java.util.Objects;
/**
* A search index declaration (advanced plan Task 8).
*
* <p>Owned by {@link MongoMetadataOwnership#SEARCH_MANAGED}, so ordinary index drift cleanup leaves
* it alone: a search index is not a b-tree index, it does not appear in {@code listIndexes}, and
* the subsystem that maintains it has its own admin plane.
*/
public record MongoSearchIndexDescriptor(
String name, String collection, List<String> searchablePaths, String analyzer) {
public MongoSearchIndexDescriptor {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(searchablePaths, "searchablePaths");
Objects.requireNonNull(analyzer, "analyzer");
searchablePaths = List.copyOf(searchablePaths);
if (searchablePaths.isEmpty()) {
throw new IllegalArgumentException("a search index needs at least one searchable path");
}
}
/** A search index over the given paths using the standard analyzer. */
public static MongoSearchIndexDescriptor standard(
String name, String collection, List<String> searchablePaths) {
return new MongoSearchIndexDescriptor(name, collection, searchablePaths, "lucene.standard");
}
/** Who owns this index for drift purposes. Always the search subsystem. */
public MongoMetadataOwnership metadataOwnership() {
return MongoMetadataOwnership.SEARCH_MANAGED;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
/**
* The lifecycle of a search or vector index (advanced plan Task 8).
*
* <p>Creation returns as soon as the request is accepted, and the index then builds asynchronously.
* A query against a {@code BUILDING} index does not fail it returns partial results so a
* deployment that queries immediately after creating gets a search feature that silently misses
* documents and then quietly starts working.
*/
public enum MongoSearchIndexState {
/** The creation request was accepted. */
CREATED,
/** The index is being built. Queries would return partial results. */
BUILDING,
/** The index is complete and safe to query. */
READY,
/** The build failed. */
FAILED,
/** The index is being removed. */
DELETING;
/** True when queries against this index return complete results. */
public boolean queryable() {
return this == READY;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.util.List;
/**
* Full-text search against a {@code READY} index (advanced plan Task 8).
*
* <p>Legacy {@code $text} is deliberately not routed here. The two have different relevance models
* and different index requirements, so silently redirecting a {@code $text} query to Search would
* change result ordering for every caller that was relying on the old behaviour.
*/
public interface MongoSearchOperations {
/** Runs a search query, refusing if the index is not ready. */
<T> List<T> search(MongoOperationContext context, MongoSearchQuery query, Class<T> documentType);
/** The current state of a search index. */
MongoSearchIndexState indexState(String indexName);
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* A bounded, allowlisted search query (advanced plan Task 8).
*
* <p>Search paths are allowlisted for the same reason ordinary query fields are: {@code $search}
* runs against whatever the index covers, and an index built over a whole document covers fields
* the caller was never meant to search or to learn the existence of from a hit count.
*/
public record MongoSearchQuery(
String indexName, List<String> paths, String queryText, int resultLimit) {
/** The largest result set a search query may request. */
public static final int MAX_RESULT_LIMIT = 200;
/** The longest query text accepted. */
public static final int MAX_QUERY_LENGTH = 512;
public MongoSearchQuery {
Objects.requireNonNull(indexName, "indexName");
Objects.requireNonNull(paths, "paths");
Objects.requireNonNull(queryText, "queryText");
paths = List.copyOf(paths);
if (paths.isEmpty()) {
throw new IllegalArgumentException("a search query needs at least one path");
}
if (queryText.length() > MAX_QUERY_LENGTH) {
throw MongoOperationRejectedException.of(
"search.query",
"the search text is " + queryText.length() + " characters, above " + MAX_QUERY_LENGTH);
}
if (resultLimit <= 0 || resultLimit > MAX_RESULT_LIMIT) {
throw MongoOperationRejectedException.of(
"search.query", "a search query needs a result limit between 1 and " + MAX_RESULT_LIMIT);
}
}
/**
* Checks the query's paths against the index's allowlist.
*
* @throws MongoOperationRejectedException when a path is not searchable
*/
public void requireAllowedPaths(Set<String> allowedPaths) {
Objects.requireNonNull(allowedPaths, "allowedPaths");
for (String path : paths) {
if (!allowedPaths.contains(path)) {
throw MongoOperationRejectedException.of(
"search.query", "path '" + path + "' is not on this index's searchable allowlist");
}
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
/**
* Refuses to query an index that is not {@code READY} (advanced plan Task 8).
*
* <p>The distinction this gate enforces is the one the API makes easy to miss: "the index was
* created" and "the index can answer queries" are different states, separated by a build that can
* take minutes on a large collection.
*/
public final class MongoSearchReadinessGate {
/**
* Checks an index state before a query runs.
*
* @throws MongoOperationRejectedException when the index cannot return complete results
*/
public void requireReady(MongoSearchIndexState state) {
Objects.requireNonNull(state, "state");
if (!state.queryable()) {
throw MongoOperationRejectedException.of(
"search.readiness",
"the search index is "
+ state
+ ", not READY; querying it now returns partial results rather than an error");
}
}
/** True when a query may run against this index. */
public boolean queryable(MongoSearchIndexState state) {
return Objects.requireNonNull(state, "state").queryable();
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
/**
* How many shards an operation will reach (advanced plan Task 2).
*
* <p>The classification is what makes sharded performance predictable at review time. A
* scatter-gather query works perfectly on a two-shard cluster and degrades linearly as shards are
* added so the query that was fine in staging is the one that stops the migration to twelve
* shards.
*/
public enum MongoRoutingClassification {
/** The full shard key is present; exactly one shard is contacted. */
TARGETED,
/** A prefix of the shard key is present; a subset of shards is contacted. */
PREFIX_TARGETED,
/** No usable shard key predicate; every shard is contacted. */
SCATTER_GATHER,
/** The operation is not permitted at all without routing evidence. */
REJECTED;
/** True when the operation contacts fewer than all shards. */
public boolean isRouted() {
return this == TARGETED || this == PREFIX_TARGETED;
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
import java.util.Set;
/**
* Classifies an operation's routing before it runs (advanced plan Task 2).
*
* <p>Read classification is advisory: a scatter-gather read is legal, expensive, and only permitted
* on a profile that declared it. Write classification is not advisory a single-document update
* without the shard key is refused, because MongoDB cannot route it and the alternatives are worse
* than an error.
*
* <p>This module never executes {@code shardCollection}, {@code refineCollectionShardKey} or {@code
* reshardCollection}. Those are D4 operations with their own credential and approval.
*/
public final class ShardAwareQueryValidator {
/** Classifies a query by which shard key fields its predicate constrains. */
public MongoRoutingClassification classify(
ShardKeyDescriptor shardKey, Set<String> predicateFields) {
Objects.requireNonNull(shardKey, "shardKey");
Objects.requireNonNull(predicateFields, "predicateFields");
if (shardKey.isFullyCovered(predicateFields)) {
return MongoRoutingClassification.TARGETED;
}
return shardKey.coveredPrefixLength(predicateFields) > 0
? MongoRoutingClassification.PREFIX_TARGETED
: MongoRoutingClassification.SCATTER_GATHER;
}
/**
* Checks a single-document write.
*
* @throws MongoOperationRejectedException when the write carries no shard key
*/
public void requireRoutedWrite(ShardKeyDescriptor shardKey, Set<String> predicateFields) {
MongoRoutingClassification classification = classify(shardKey, predicateFields);
if (classification != MongoRoutingClassification.TARGETED) {
throw MongoOperationRejectedException.of(
"sharding.write",
"a single-document write on a sharded collection needs the full shard key "
+ shardKey.fields()
+ "; the predicate constrains "
+ predicateFields
+ ", which classifies as "
+ classification);
}
}
/**
* Checks a read against the profile's declared routing tolerance.
*
* @throws MongoOperationRejectedException when a scatter-gather read was not declared
*/
public MongoRoutingClassification requireAllowedRead(
ShardKeyDescriptor shardKey, Set<String> predicateFields, boolean scatterGatherReviewed) {
MongoRoutingClassification classification = classify(shardKey, predicateFields);
if (classification == MongoRoutingClassification.SCATTER_GATHER && !scatterGatherReviewed) {
throw MongoOperationRejectedException.of(
"sharding.read",
"this read contacts every shard and its profile has not declared scatter-gather; the cost "
+ "grows with every shard added, so it needs an explicit review");
}
return classification;
}
/**
* Checks a unique index against the shard key.
*
* @throws MongoOperationRejectedException when uniqueness could only be enforced per shard
*/
public void requireCompatibleUniqueIndex(
ShardKeyDescriptor shardKey, java.util.List<String> indexFields) {
if (!shardKey.supportsUniqueIndexOn(indexFields)) {
throw MongoOperationRejectedException.of(
"sharding.index",
"a unique index on "
+ indexFields
+ " is not prefixed by the shard key "
+ shardKey.fields()
+ "; MongoDB would enforce uniqueness per shard only, so duplicates appear as soon as "
+ "two matching documents land on different shards");
}
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* A collection's shard key, in order (advanced plan Task 2).
*
* <p>Order is the whole content of a compound shard key. {@code (tenantId, orderId)} lets a query
* on {@code tenantId} alone target a subset of shards; {@code (orderId, tenantId)} does not, and no
* amount of indexing recovers it.
*/
public record ShardKeyDescriptor(List<ShardKeyPart> parts) {
public ShardKeyDescriptor {
Objects.requireNonNull(parts, "parts");
parts = List.copyOf(parts);
if (parts.isEmpty()) {
throw new IllegalArgumentException("a shard key needs at least one field");
}
}
/** A ranged shard key over the given fields, in order. */
public static ShardKeyDescriptor range(String... fields) {
return new ShardKeyDescriptor(
Arrays.stream(fields).map(field -> new ShardKeyPart(field, ShardStrategy.RANGE)).toList());
}
/** A hashed shard key on a single field. */
public static ShardKeyDescriptor hashed(String field) {
return new ShardKeyDescriptor(List.of(new ShardKeyPart(field, ShardStrategy.HASHED)));
}
/** The shard key fields, in order. */
public List<String> fields() {
return parts.stream().map(ShardKeyPart::field).toList();
}
/** True when the given fields include the complete shard key. */
public boolean isFullyCovered(java.util.Set<String> predicateFields) {
Objects.requireNonNull(predicateFields, "predicateFields");
return predicateFields.containsAll(fields());
}
/** How many leading shard key fields the given predicate fields cover. */
public int coveredPrefixLength(java.util.Set<String> predicateFields) {
Objects.requireNonNull(predicateFields, "predicateFields");
int covered = 0;
for (String field : fields()) {
if (!predicateFields.contains(field)) {
break;
}
covered++;
}
return covered;
}
/**
* True when a unique index on the given fields is compatible with this shard key.
*
* <p>MongoDB can only enforce uniqueness within a shard, so a unique index must be prefixed by
* the shard key. A unique index that is not an email address on a tenant-sharded collection
* is silently only unique per shard, and the duplicate appears the first time two tenants land
* differently.
*/
public boolean supportsUniqueIndexOn(List<String> indexFields) {
Objects.requireNonNull(indexFields, "indexFields");
List<String> shardFields = fields();
if (indexFields.size() < shardFields.size()) {
return false;
}
return indexFields.subList(0, shardFields.size()).equals(shardFields);
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import java.util.Objects;
/** One field of a compound shard key, in declaration order (advanced plan Task 2). */
public record ShardKeyPart(String field, ShardStrategy strategy) {
public ShardKeyPart {
Objects.requireNonNull(field, "field");
Objects.requireNonNull(strategy, "strategy");
if (field.isBlank()) {
throw new IllegalArgumentException("a shard key part needs a field");
}
}
@Override
public String toString() {
return field + ':' + strategy;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
/**
* How a shard key distributes documents (advanced plan Task 2).
*
* <p>The choice is effectively permanent changing it means resharding, which rewrites the whole
* collection and the two options fail in opposite ways. Ranged keeps documents with adjacent keys
* together, so range queries stay targeted and a monotonically increasing key sends every insert to
* one shard. Hashed spreads writes evenly and makes every range query a scatter-gather.
*/
public enum ShardStrategy {
/** Documents are distributed by key ranges. Range queries target; monotonic keys hotspot. */
RANGE,
/** Documents are distributed by the hash of the key. Writes spread; range queries scatter. */
HASHED;
/** True when a range predicate on the shard key can target a subset of shards. */
public boolean supportsTargetedRangeQueries() {
return this == RANGE;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Sharding topology operations, on the admin plane only (advanced plan Task 3).
*
* <p>Every method here changes the shape of the cluster. They run through the D4 gateway so each
* one carries an operator, a reason and an audit record, and behind the shard-admin credential so
* an application runtime cannot reach them even by constructing this class.
*/
public final class MongoShardingAdminGateway {
private final MongoAdminGateway adminGateway;
public MongoShardingAdminGateway(
MongoAdminGateway adminGateway, MongoAdvancedCapabilityFlags flags) {
this.adminGateway = Objects.requireNonNull(adminGateway, "adminGateway");
// Checked once, at construction: an instance cannot exist unless sharding was enabled.
Objects.requireNonNull(flags, "flags").require(MongoCapability.SHARDING);
}
/**
* Enables sharding on a collection.
*
* @throws MongoOperationRejectedException when the key was not approved or its index is missing
*/
public void shardCollection(
String collection,
ShardKeyDescriptor shardKey,
ShardKeyReadinessReport readiness,
List<String> supportingIndexFields,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(shardKey, "shardKey");
Objects.requireNonNull(readiness, "readiness");
if (!readiness.approved()) {
throw MongoOperationRejectedException.of(
"sharding.shard-collection",
"the shard key for '" + collection + "' was not approved: " + readiness.reasons());
}
if (!supportingIndexFields
.subList(0, Math.min(shardKey.fields().size(), supportingIndexFields.size()))
.equals(shardKey.fields())) {
throw MongoOperationRejectedException.of(
"sharding.shard-collection",
"sharding '"
+ collection
+ "' needs a supporting index prefixed by the shard key "
+ shardKey.fields());
}
adminGateway.execute(MongoAdminOperation.SHARD_COLLECTION, collection, operator, reason, apply);
}
/** Adds a field to an existing shard key. Requires the same evidence as a reshard. */
public void refineShardKey(
String collection,
ReshardApproval approval,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(approval, "approval").require();
adminGateway.execute(MongoAdminOperation.REFINE_SHARD_KEY, collection, operator, reason, apply);
}
/** Changes a collection's shard key, rewriting the whole collection. */
public void reshardCollection(
String collection,
ReshardApproval approval,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(approval, "approval").require();
adminGateway.execute(
MongoAdminOperation.RESHARD_COLLECTION, collection, operator, reason, apply);
}
/** Starts or stops the balancer. */
public void controlBalancer(String scope, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(MongoAdminOperation.BALANCER_CONTROL, scope, operator, reason, apply);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
/**
* The evidence a reshard needs before it may start (advanced plan Task 3).
*
* <p>Resharding rewrites every document in a collection while it keeps serving traffic. It cannot
* be paused for convenience and there is no reverse operation the way back is another reshard. So
* the approval carries a readiness report, a completed dry run, a named approver and a written
* forward strategy, and the gateway refuses without all four.
*/
public record ReshardApproval(
ShardKeyDescriptor newShardKey,
ShardKeyReadinessReport readiness,
boolean dryRunCompleted,
String approver,
String forwardStrategy) {
public ReshardApproval {
Objects.requireNonNull(newShardKey, "newShardKey");
Objects.requireNonNull(readiness, "readiness");
Objects.requireNonNull(approver, "approver");
Objects.requireNonNull(forwardStrategy, "forwardStrategy");
}
/**
* Checks that the approval is complete.
*
* @throws MongoOperationRejectedException naming the first missing piece of evidence
*/
public void require() {
if (!readiness.approved()) {
throw MongoOperationRejectedException.of(
"sharding.reshard",
"the new shard key was not approved by analysis: " + readiness.reasons());
}
if (!dryRunCompleted) {
throw MongoOperationRejectedException.of(
"sharding.reshard", "a reshard requires a completed dry run before it starts");
}
if (approver.isBlank()) {
throw MongoOperationRejectedException.of(
"sharding.reshard", "a reshard requires a named approver");
}
if (forwardStrategy.isBlank()) {
throw MongoOperationRejectedException.of(
"sharding.reshard",
"a reshard requires a written forward strategy; there is no reverse operation, so the "
+ "recovery from a bad outcome is another reshard");
}
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* Turns sampled statistics into a shard key verdict (advanced plan Task 3).
*
* <p>The thresholds are conservative on purpose. A shard key that is marginal at today's volume is
* a shard key that fails at ten times the volume, and by then the only remedy is a reshard which
* rewrites the whole collection while it is serving traffic.
*
* <p>The sampling itself runs through the shard-admin credential; this class only interprets the
* result, which is what makes the verdict testable without a cluster.
*/
public final class ShardKeyAnalyzer {
/** Below this many distinct values per shard, chunks cannot be split evenly. */
public static final long MINIMUM_CARDINALITY_PER_SHARD = 1000;
/** Above this share for a single value, one chunk becomes a hotspot. */
public static final double MAXIMUM_VALUE_FREQUENCY = 0.05;
/** Above this monotonicity score, inserts concentrate on the highest chunk. */
public static final double MAXIMUM_MONOTONICITY = 0.8;
/** Below this targeting ratio, most operations contact every shard. */
public static final double MINIMUM_TARGETING_RATIO = 0.9;
/**
* Analyses one candidate.
*
* @param shardKey the candidate key
* @param distinctValues how many distinct key values were sampled
* @param shardCount how many shards the collection would spread across
* @param topValueFrequency the share of documents holding the most common key value
* @param monotonicity 0 for random, 1 for strictly increasing
* @param readTargetingRatio the share of sampled reads that would be targeted
* @param writeTargetingRatio the share of sampled writes that would be targeted
*/
public ShardKeyReadinessReport analyze(
ShardKeyDescriptor shardKey,
long distinctValues,
int shardCount,
double topValueFrequency,
double monotonicity,
double readTargetingRatio,
double writeTargetingRatio) {
Objects.requireNonNull(shardKey, "shardKey");
if (shardCount <= 0) {
throw new IllegalArgumentException("shardCount must be positive");
}
Set<String> reasons = new LinkedHashSet<>();
if (distinctValues < MINIMUM_CARDINALITY_PER_SHARD * shardCount) {
reasons.add(ShardKeyReadinessReport.LOW_CARDINALITY);
}
if (topValueFrequency > MAXIMUM_VALUE_FREQUENCY) {
reasons.add(ShardKeyReadinessReport.HIGH_FREQUENCY);
}
if (monotonicity > MAXIMUM_MONOTONICITY) {
reasons.add(ShardKeyReadinessReport.MONOTONIC);
}
if (readTargetingRatio < MINIMUM_TARGETING_RATIO
|| writeTargetingRatio < MINIMUM_TARGETING_RATIO) {
reasons.add(ShardKeyReadinessReport.POOR_TARGETING);
}
return reasons.isEmpty()
? ShardKeyReadinessReport.approved(monotonicity, readTargetingRatio, writeTargetingRatio)
: ShardKeyReadinessReport.rejected(
reasons, monotonicity, readTargetingRatio, writeTargetingRatio);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import java.util.Objects;
import java.util.Set;
/**
* Whether a shard key candidate is fit to be committed to (advanced plan Task 3).
*
* <p>The shard key is close to irreversible: changing it means resharding, which rewrites every
* document. The three ways a candidate goes wrong are all measurable in advance too few distinct
* values to spread across shards, one value dominating the distribution, or a monotonically
* increasing value that sends every insert to the same chunk so the decision is made from a
* report rather than from intuition.
*/
public record ShardKeyReadinessReport(
boolean approved,
Set<String> reasons,
double monotonicity,
double readTargetingRatio,
double writeTargetingRatio) {
/** Reason code: too few distinct shard key values. */
public static final String LOW_CARDINALITY = "LOW_CARDINALITY";
/** Reason code: one shard key value dominates the distribution. */
public static final String HIGH_FREQUENCY = "HIGH_FREQUENCY";
/** Reason code: the shard key increases monotonically, so all inserts hit one chunk. */
public static final String MONOTONIC = "MONOTONIC";
/** Reason code: too many operations would be scatter-gather. */
public static final String POOR_TARGETING = "POOR_TARGETING";
public ShardKeyReadinessReport {
Objects.requireNonNull(reasons, "reasons");
reasons = Set.copyOf(reasons);
if (approved && !reasons.isEmpty()) {
throw new IllegalArgumentException(
"an approved shard key report must have no reasons against it");
}
}
/** A candidate with too few distinct values to spread across shards. */
public static ShardKeyReadinessReport lowCardinality(String field) {
Objects.requireNonNull(field, "field");
return new ShardKeyReadinessReport(false, Set.of(LOW_CARDINALITY), 0, 0, 0);
}
/** A candidate that passed every check. */
public static ShardKeyReadinessReport approved(
double monotonicity, double readTargetingRatio, double writeTargetingRatio) {
return new ShardKeyReadinessReport(
true, Set.of(), monotonicity, readTargetingRatio, writeTargetingRatio);
}
/** A candidate rejected for the given reasons. */
public static ShardKeyReadinessReport rejected(
Set<String> reasons, double monotonicity, double readTargeting, double writeTargeting) {
return new ShardKeyReadinessReport(false, reasons, monotonicity, readTargeting, writeTargeting);
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Bounds how many tenant clients exist at once (advanced plan Task 11).
*
* <p>Database-per-tenant is Experimental precisely because of this: each client carries its own
* connection pool and its own monitoring threads, so a thousand tenants is a thousand pools. The
* registry caps the number and evicts idle ones, and refuses rather than exceeding the cap an
* unbounded registry fails later, as connection exhaustion on an unrelated request.
*/
public final class MongoTenantClientRegistry {
private final int maximumActiveClients;
private final Duration idleEviction;
private final Map<String, Instant> lastUsed = new LinkedHashMap<>();
public MongoTenantClientRegistry(int maximumActiveClients) {
this(maximumActiveClients, Duration.ofMinutes(10));
}
public MongoTenantClientRegistry(int maximumActiveClients, Duration idleEviction) {
this.idleEviction = Objects.requireNonNull(idleEviction, "idleEviction");
if (maximumActiveClients <= 0) {
throw new IllegalArgumentException("the client cap must be positive");
}
this.maximumActiveClients = maximumActiveClients;
}
/**
* Acquires a client for a tenant.
*
* @throws MongoOperationRejectedException when the cap is reached and nothing can be evicted
*/
public void acquire(String tenantKey) {
acquire(tenantKey, Instant.now());
}
/** Acquires a client for a tenant at an explicit instant, so eviction is testable. */
public void acquire(String tenantKey, Instant now) {
Objects.requireNonNull(tenantKey, "tenantKey");
Objects.requireNonNull(now, "now");
if (lastUsed.containsKey(tenantKey)) {
lastUsed.put(tenantKey, now);
return;
}
evictIdle(now);
if (lastUsed.size() >= maximumActiveClients) {
throw MongoOperationRejectedException.of(
"tenancy.client",
"the tenant client cap of "
+ maximumActiveClients
+ " is reached and no client is idle; each tenant client carries its own connection "
+ "pool and monitoring threads, so the cap is what stops one process from exhausting "
+ "the cluster's connections");
}
lastUsed.put(tenantKey, now);
}
/** Releases a tenant's client. */
public void release(String tenantKey) {
lastUsed.remove(Objects.requireNonNull(tenantKey, "tenantKey"));
}
/** The tenants with an active client. */
public Set<String> activeTenants() {
return Set.copyOf(lastUsed.keySet());
}
/** How many clients are currently open. */
public int activeCount() {
return lastUsed.size();
}
private void evictIdle(Instant now) {
lastUsed.entrySet().removeIf(entry -> entry.getValue().plus(idleEviction).isBefore(now));
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext;
import dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName;
/**
* Maps a tenant to its database profile (advanced plan Task 11).
*
* <p>The mapping comes from a trusted registry, never from request input. Deriving a database name
* from a header or a token claim makes the database name attacker-controlled, and a database name
* is the one string that decides which tenant's data a query reads.
*/
@FunctionalInterface
public interface MongoTenantDatabaseResolver {
/** The database profile for a tenant. */
DatabaseProfileName resolve(MongoTenantContext tenant);
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.util.Objects;
/**
* What must happen before a tenant database is used, and before it is destroyed (advanced plan Task
* 11).
*
* <p>Onboarding first: a tenant database that starts serving before its schema and indexes are in
* place accepts documents that fail the validator and queries that scan, and both are then already
* in the data by the time anyone notices.
*
* <p>Offboarding is the same rule in reverse. A tenant's data cannot be dropped on request alone
* retention obligations may still apply, and once dropped there is no export.
*/
public record MongoTenantLifecyclePolicy(
Duration retentionAfterOffboarding, boolean exportRequiredBeforeDelete) {
public MongoTenantLifecyclePolicy {
Objects.requireNonNull(retentionAfterOffboarding, "retentionAfterOffboarding");
if (retentionAfterOffboarding.isNegative()) {
throw new IllegalArgumentException("a retention window must not be negative");
}
}
/** The platform default: 30 days of retention and a mandatory export. */
public static MongoTenantLifecyclePolicy standard() {
return new MongoTenantLifecyclePolicy(Duration.ofDays(30), true);
}
/**
* Refuses to activate a tenant whose schema and indexes are not in place.
*
* @throws MongoOperationRejectedException when validation has not completed
*/
public void requireActivationReady(String tenantKey, boolean schemaAndIndexesValidated) {
Objects.requireNonNull(tenantKey, "tenantKey");
if (!schemaAndIndexesValidated) {
throw MongoOperationRejectedException.of(
"tenancy.activation",
"the tenant database is not validated; activating it now accepts documents the validator "
+ "would have rejected and queries no index supports");
}
}
/**
* Refuses a delete that lacks its evidence.
*
* @throws MongoOperationRejectedException when the retention window has not elapsed or no export
* exists
*/
public void requireDeleteAllowed(
String tenantKey, Duration elapsedSinceOffboarding, boolean exportCompleted) {
Objects.requireNonNull(tenantKey, "tenantKey");
Objects.requireNonNull(elapsedSinceOffboarding, "elapsedSinceOffboarding");
if (elapsedSinceOffboarding.compareTo(retentionAfterOffboarding) < 0) {
throw MongoOperationRejectedException.of(
"tenancy.offboarding",
"the retention window of "
+ retentionAfterOffboarding
+ " has not elapsed; "
+ elapsedSinceOffboarding
+ " has passed since offboarding");
}
if (exportRequiredBeforeDelete && !exportCompleted) {
throw MongoOperationRejectedException.of(
"tenancy.offboarding",
"no completed export exists for this tenant; once the database is dropped there is "
+ "nothing left to export from");
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Fans a migration out across tenant databases, slowly (advanced plan Task 11).
*
* <p>The concurrency limit is the point. Running the same migration against a thousand tenant
* databases at once turns a routine schema change into a cluster-wide load event, and the databases
* that fail are the ones whose tenants happened to be busy.
*
* <p>Checkpoints are per tenant, so a fan-out interrupted after six hundred tenants resumes at six
* hundred and one rather than at one.
*/
public final class MongoTenantMigrationCoordinator {
private final int maxConcurrentTenants;
private final Duration pauseBetweenTenants;
private final Map<String, MongoMigrationCheckpoint> checkpointsByTenant = new LinkedHashMap<>();
public MongoTenantMigrationCoordinator(int maxConcurrentTenants, Duration pauseBetweenTenants) {
this.pauseBetweenTenants = Objects.requireNonNull(pauseBetweenTenants, "pauseBetweenTenants");
if (maxConcurrentTenants <= 0) {
throw new IllegalArgumentException("the tenant concurrency limit must be positive");
}
this.maxConcurrentTenants = maxConcurrentTenants;
}
/** The platform default: four tenants at a time, a second apart. */
public static MongoTenantMigrationCoordinator standard() {
return new MongoTenantMigrationCoordinator(4, Duration.ofSeconds(1));
}
/** The next batch of tenants to migrate, skipping those already completed. */
public List<String> nextBatch(List<String> allTenants, List<String> completedTenants) {
Objects.requireNonNull(allTenants, "allTenants");
Objects.requireNonNull(completedTenants, "completedTenants");
return allTenants.stream()
.filter(tenant -> !completedTenants.contains(tenant))
.limit(maxConcurrentTenants)
.toList();
}
/** Records how far one tenant's migration got. */
public void recordCheckpoint(String tenantKey, MongoMigrationCheckpoint checkpoint) {
checkpointsByTenant.put(
Objects.requireNonNull(tenantKey, "tenantKey"),
Objects.requireNonNull(checkpoint, "checkpoint"));
}
/** The stored checkpoint for a tenant, if the fan-out was interrupted mid-tenant. */
public Optional<MongoMigrationCheckpoint> checkpointFor(String tenantKey) {
return Optional.ofNullable(
checkpointsByTenant.get(Objects.requireNonNull(tenantKey, "tenantKey")));
}
/** How long to wait between tenants. */
public Duration pauseBetweenTenants() {
return pauseBetweenTenants;
}
/** How many tenants may migrate concurrently. */
public int maxConcurrentTenants() {
return maxConcurrentTenants;
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
/**
* The tenant a request belongs to (advanced plan Task 10).
*
* <p>The key is opaque and the raw tenant id never reaches telemetry: a tenant id in a metric tag
* is both unbounded cardinality and, on a B2B system, a customer list published to whoever can read
* the dashboard. {@link #observableKey()} is the hashed form for the rare case where per-tenant
* observability is genuinely needed.
*/
public record MongoTenantContext(String opaqueTenantKey) {
public MongoTenantContext {
if (opaqueTenantKey == null || opaqueTenantKey.isBlank()) {
throw new IllegalArgumentException("tenant context required");
}
}
/** A short, stable hash suitable for correlation without naming the tenant. */
public String observableKey() {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of()
.formatHex(digest.digest(opaqueTenantKey.getBytes(StandardCharsets.UTF_8)))
.substring(0, 12);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required to derive an observable tenant key");
}
}
/** Describes the tenant without naming it. */
@Override
public String toString() {
return "MongoTenantContext[" + observableKey() + "]";
}
/** The document field a shared collection stores the tenant in. */
public static String tenantField() {
return "tenantId";
}
/** The value written into the tenant field. */
public String storedValue() {
return Objects.requireNonNull(opaqueTenantKey);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest;
import java.util.List;
import java.util.Objects;
/**
* Checks that a shared collection's indexes agree with its tenancy (advanced plan Task 10).
*
* <p>A unique index without the tenant field enforces uniqueness across the whole collection, which
* on a shared-collection system means one tenant's value blocks another's. The failure is invisible
* during development, where there is one tenant, and appears the day the second one signs up.
*/
public final class MongoTenantManifestValidator {
/**
* Validates one shared collection's manifest.
*
* @param tenantScopedUniqueIndexes index names whose uniqueness is meant to be per tenant
* @throws MongoOperationRejectedException naming the index that would be globally unique
*/
public void validate(MongoCollectionManifest manifest, List<String> tenantScopedUniqueIndexes) {
Objects.requireNonNull(manifest, "manifest");
Objects.requireNonNull(tenantScopedUniqueIndexes, "tenantScopedUniqueIndexes");
for (MongoIndexManifest index : manifest.indexes()) {
if (!index.unique() || !tenantScopedUniqueIndexes.contains(index.name())) {
continue;
}
if (!startsWithTenantField(index)) {
throw MongoOperationRejectedException.of(
"tenancy.index",
"unique index '"
+ index.name()
+ "' on shared collection '"
+ manifest.collection()
+ "' is meant to be per tenant but does not start with '"
+ MongoTenantContext.tenantField()
+ "'; it would make one tenant's value block every other tenant's");
}
}
}
/**
* Rejects a shard key that assumes the tenant field without analysis.
*
* @throws MongoOperationRejectedException when the tenant field was chosen without a readiness
* report
*/
public void requireShardKeyAnalysed(String collection, boolean readinessReportPresent) {
if (!readinessReportPresent) {
throw MongoOperationRejectedException.of(
"tenancy.shard-key",
"the shard key for shared collection '"
+ collection
+ "' was chosen without a readiness report; the tenant field is the obvious candidate "
+ "and often the wrong one, because one large tenant becomes one hot shard");
}
}
private static boolean startsWithTenantField(MongoIndexManifest index) {
List<MongoIndexKey> keys = index.keys();
return !keys.isEmpty() && keys.get(0).field().equals(MongoTenantContext.tenantField());
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter;
import java.util.Objects;
import java.util.Optional;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
/**
* Adds the tenant predicate to every tenant-scoped operation (advanced plan Task 10).
*
* <p>Fail-closed on a missing tenant context, which is the only safe default: the failure mode of
* "no tenant predicate" on a shared collection is a query that returns every tenant's data, and it
* returns it successfully. An error is recoverable; a cross-tenant read is a disclosure.
*
* <p>Aggregations get the predicate as a first-stage match rather than anywhere later, so no stage
* ever observes another tenant's documents a {@code $group} placed before the filter would leak
* through its own output even if the final result were filtered.
*/
public final class MongoTenantPredicateInjector {
/**
* Adds the tenant predicate to an atomic filter.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public AtomicFilter apply(Optional<MongoTenantContext> tenant, AtomicFilter filter) {
Objects.requireNonNull(filter, "filter");
MongoTenantContext context = require(tenant);
return filter.andEquals(MongoTenantContext.tenantField(), context.storedValue());
}
/**
* Adds the tenant predicate to a query.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public Query apply(Optional<MongoTenantContext> tenant, Query query) {
Objects.requireNonNull(query, "query");
MongoTenantContext context = require(tenant);
query.addCriteria(Criteria.where(MongoTenantContext.tenantField()).is(context.storedValue()));
return query;
}
/**
* The first-stage match an aggregation must begin with.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public Criteria firstStageMatch(Optional<MongoTenantContext> tenant) {
MongoTenantContext context = require(tenant);
return Criteria.where(MongoTenantContext.tenantField()).is(context.storedValue());
}
private static MongoTenantContext require(Optional<MongoTenantContext> tenant) {
Objects.requireNonNull(tenant, "tenant");
return tenant.orElseThrow(
() ->
MongoOperationRejectedException.of(
"tenancy.context",
"no tenant context is present; a tenant-scoped operation without its predicate "
+ "reads and writes across every tenant in the shared collection"));
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult;
import java.util.List;
import java.util.Optional;
import org.springframework.data.mongodb.core.query.Query;
/**
* Operations that cannot run without a tenant predicate (advanced plan Task 10).
*
* <p>Every method takes the tenant context explicitly rather than reading it from a thread or a
* request scope. An implicit lookup is invisible at the call site, which means a background job, a
* scheduled task or a message consumer can run tenant-scoped code with no tenant and nothing in the
* code says so.
*/
public interface TenantScopedMongoOperations {
/** Finds documents belonging to the tenant. */
<T> List<T> find(
MongoOperationContext context,
Optional<MongoTenantContext> tenant,
Query query,
Class<T> documentType);
/** Updates one of the tenant's documents. */
<T> AtomicUpdateResult<T> updateOne(
MongoOperationContext context,
Optional<MongoTenantContext> tenant,
AtomicFilter filter,
AtomicUpdate update,
Class<T> documentType);
/** Deletes one of the tenant's documents. */
long deleteOne(
MongoOperationContext context, Optional<MongoTenantContext> tenant, AtomicFilter filter);
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.util.Objects;
/**
* Refuses the ordinary collection capabilities a time series collection does not have (advanced
* plan Task 5).
*
* <p>Each refusal maps to something MongoDB genuinely does not support on a time series collection.
* The reason for making them explicit is that the failure otherwise arrives late and looks like a
* bug: a change stream on a time series collection simply never delivers an event, and a schema
* validator configured on one is accepted and never applied.
*/
public final class MongoTimeSeriesCapabilityValidator {
/**
* Time series collections do not support change streams.
*
* @throws UnsupportedOperationException always
*/
public void requireChangeStream(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support change streams; watch the source of the "
+ "measurements instead of the bucketed collection");
}
/**
* Time series collections do not support JSON Schema validators.
*
* @throws UnsupportedOperationException always
*/
public void requireSchemaValidator(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support a JSON Schema validator; validate measurements "
+ "before they are written");
}
/**
* Time series collections do not support client-side field level encryption.
*
* @throws UnsupportedOperationException always
*/
public void requireFieldLevelEncryption(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support CSFLE or Queryable Encryption; encrypt the "
+ "measurements upstream or keep sensitive fields in a separate collection");
}
/**
* Time series collections do not support transactional writes.
*
* @throws UnsupportedOperationException always
*/
public void requireTransactionalWrite(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection cannot be written inside a multi-document transaction");
}
/**
* Validates a descriptor's own settings.
*
* @throws IllegalArgumentException when a required field is missing or a bound is unusable
*/
public void validate(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
descriptor
.retentionWindow()
.ifPresent(
retention -> {
if (retention.compareTo(descriptor.granularity().bucketSpan()) < 0) {
throw new IllegalArgumentException(
"a retention window of "
+ retention
+ " is shorter than one "
+ descriptor.granularity()
+ " bucket ("
+ descriptor.granularity().bucketSpan()
+ "), so measurements would expire before their bucket closes");
}
});
}
/** True when the given server version supports sharding a time series collection. */
public boolean shardingSupported(String serverVersion) {
Objects.requireNonNull(serverVersion, "serverVersion");
return !serverVersion.startsWith("5.") && !serverVersion.startsWith("6.");
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* A time series collection's shape (advanced plan Task 5).
*
* <p>A separate descriptor rather than a variant of the ordinary collection manifest, because a
* time series collection does not inherit the ordinary contract: it has no schema validator, no
* change stream, no CSFLE, and its update and delete support is restricted. Modelling it as "a
* collection with a flag" would let all of those be configured and silently ignored.
*/
public record MongoTimeSeriesDescriptor(
String timeField,
String metaField,
MongoTimeSeriesGranularity granularity,
Duration retention) {
public MongoTimeSeriesDescriptor {
Objects.requireNonNull(timeField, "timeField");
Objects.requireNonNull(granularity, "granularity");
if (timeField.isBlank()) {
throw new IllegalArgumentException("a time series collection needs an explicit timeField");
}
if (retention != null && retention.isNegative()) {
throw new IllegalArgumentException("a time series retention must not be negative");
}
}
/** The common shape: a time field, a metadata field and minute granularity. */
public static MongoTimeSeriesDescriptor standard(String timeField, String metaField) {
return new MongoTimeSeriesDescriptor(
timeField, metaField, MongoTimeSeriesGranularity.MINUTES, null);
}
/** The metadata field, when the collection declares one. */
public Optional<String> meta() {
return Optional.ofNullable(metaField).filter(field -> !field.isBlank());
}
/**
* The retention window, when one is declared.
*
* <p>Time series retention is TTL-based, so it carries the same caveat as any TTL: it reclaims
* space eventually and is not a scheduler.
*/
public Optional<Duration> retentionWindow() {
return Optional.ofNullable(retention);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.time.Duration;
/**
* How far apart a time series collection expects consecutive measurements (advanced plan Task 5).
*
* <p>Granularity decides the bucket span, and a wrong choice is expensive in both directions: too
* coarse and each bucket holds too many measurements to read efficiently, too fine and the
* collection carries a bucket per measurement plus its overhead.
*/
public enum MongoTimeSeriesGranularity {
/** Measurements arrive seconds apart. */
SECONDS(Duration.ofHours(1)),
/** Measurements arrive minutes apart. */
MINUTES(Duration.ofHours(24)),
/** Measurements arrive hours apart. */
HOURS(Duration.ofDays(30));
private final Duration bucketSpan;
MongoTimeSeriesGranularity(Duration bucketSpan) {
this.bucketSpan = bucketSpan;
}
/** The time span one bucket covers at this granularity. */
public Duration bucketSpan() {
return bucketSpan;
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.time.Instant;
import java.util.List;
/**
* The operations a time series collection actually supports (advanced plan Task 5).
*
* <p>Insert and range read, and nothing else. There is no update or delete method because MongoDB's
* support for both is restricted on time series collections, and an API that offered them would be
* offering something that fails at runtime depending on the server version and the fields touched.
*/
public interface MongoTimeSeriesOperations {
/** Appends measurements. */
<T> void insertAll(MongoOperationContext context, List<T> measurements, Class<T> measurementType);
/** Reads measurements in a bounded time range, which is the query shape buckets are built for. */
<T> List<T> findInRange(
MongoOperationContext context,
Instant from,
Instant to,
Class<T> measurementType,
int resultLimit);
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.Objects;
/**
* An embedding bound to the index it was produced for (advanced plan Task 9).
*
* <p>{@link #forIndex} is the only way to build one, so a dimension mismatch cannot reach a query.
* MongoDB rejects a wrong-length vector, but the more common mistake a vector of the right length
* from a different model is caught here only by the index binding, which is why the factory takes
* the descriptor rather than a bare length.
*/
public final class MongoEmbedding {
private final float[] values;
private final MongoVectorIndexDescriptor index;
private MongoEmbedding(float[] values, MongoVectorIndexDescriptor index) {
this.values = values;
this.index = index;
}
/**
* Binds a vector to an index.
*
* @throws IllegalArgumentException when the vector's length is not the index's dimension
*/
public static MongoEmbedding forIndex(MongoVectorIndexDescriptor index, float[] values) {
Objects.requireNonNull(index, "index");
Objects.requireNonNull(values, "values");
if (values.length != index.dimensions()) {
throw new IllegalArgumentException(
"embedding has "
+ values.length
+ " dimensions but index '"
+ index.name()
+ "' expects "
+ index.dimensions());
}
return new MongoEmbedding(values.clone(), index);
}
/** A defensive copy of the vector. */
public float[] values() {
return values.clone();
}
/** The index this embedding was produced for. */
public MongoVectorIndexDescriptor index() {
return index;
}
/** The number of dimensions. */
public int dimensions() {
return values.length;
}
@Override
public String toString() {
return "MongoEmbedding[index=" + index.name() + ", dimensions=" + values.length + "]";
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.Objects;
/**
* A vector index declaration (advanced plan Task 9).
*
* <p>Dimension and similarity metric are part of the index contract, not query parameters. Querying
* a cosine index with vectors produced for a dot-product model returns results ranked by the
* wrong notion of similarity so the mismatch has to be caught at the type level rather than
* observed in the output.
*/
public record MongoVectorIndexDescriptor(
String name, String path, int dimensions, MongoVectorSimilarity similarity) {
/** The largest embedding dimension the platform accepts. */
public static final int MAX_DIMENSIONS = 4096;
public MongoVectorIndexDescriptor {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(path, "path");
Objects.requireNonNull(similarity, "similarity");
if (dimensions <= 0 || dimensions > MAX_DIMENSIONS) {
throw new IllegalArgumentException(
"an embedding dimension must be between 1 and " + MAX_DIMENSIONS);
}
}
/** A cosine-similarity index. */
public static MongoVectorIndexDescriptor cosine(String path, int dimensions) {
return new MongoVectorIndexDescriptor(
"ix_vector_" + path.replace('.', '_'), path, dimensions, MongoVectorSimilarity.COSINE);
}
/** A dot-product index. */
public static MongoVectorIndexDescriptor dotProduct(String path, int dimensions) {
return new MongoVectorIndexDescriptor(
"ix_vector_" + path.replace('.', '_'), path, dimensions, MongoVectorSimilarity.DOT_PRODUCT);
}
/** How similarity is measured by this index. */
public enum MongoVectorSimilarity {
/** Angle between vectors; magnitude-independent. */
COSINE,
/** Dot product; magnitude matters. */
DOT_PRODUCT,
/** Euclidean distance. */
EUCLIDEAN
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* A bounded approximate-nearest-neighbour query (advanced plan Task 9).
*
* <p>{@code numCandidates} is the accuracy-versus-cost dial: the search examines that many
* candidates and returns the best {@code limit} of them. It has to exceed the limit asking for 10
* results from 10 candidates is not an approximate search, it is an arbitrary one and it has to
* be bounded, because the cost grows with it.
*/
public record MongoVectorQuery(
MongoEmbedding queryVector,
int limit,
int numCandidates,
Set<String> filterFields,
Duration timeout) {
/** The largest candidate pool the platform allows. */
public static final int MAX_CANDIDATES = 10_000;
/** The largest result set the platform allows. */
public static final int MAX_LIMIT = 100;
public MongoVectorQuery {
Objects.requireNonNull(queryVector, "queryVector");
Objects.requireNonNull(filterFields, "filterFields");
Objects.requireNonNull(timeout, "timeout");
filterFields = Set.copyOf(filterFields);
if (limit <= 0 || limit > MAX_LIMIT) {
throw MongoOperationRejectedException.of(
"vector.query", "a vector query needs a limit between 1 and " + MAX_LIMIT);
}
if (numCandidates > MAX_CANDIDATES) {
throw MongoOperationRejectedException.of(
"vector.query", "numCandidates is above the ceiling of " + MAX_CANDIDATES);
}
if (numCandidates <= limit) {
throw MongoOperationRejectedException.of(
"vector.query",
"numCandidates ("
+ numCandidates
+ ") must exceed the limit ("
+ limit
+ "); otherwise the search returns whatever it examined rather than the nearest");
}
if (timeout.isZero() || timeout.isNegative()) {
throw MongoOperationRejectedException.of(
"vector.query", "a vector query needs a positive timeout");
}
}
/** A query with the platform's default candidate ratio. */
public static MongoVectorQuery nearest(MongoEmbedding queryVector, int limit) {
return new MongoVectorQuery(
queryVector, limit, Math.min(limit * 20, MAX_CANDIDATES), Set.of(), Duration.ofSeconds(2));
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What a vector search deployment must prove before promotion (advanced plan Tasks 9, 15).
*
* <p>Functional success is not evidence for vector search. An approximate index returns results for
* any query; whether they are the right results depends on recall, which cannot be observed from
* the application side. So the gate requires a measured recall against a known-answer set,
* alongside the usual latency and memory bounds.
*/
public record MongoVectorSearchBenchmarkGate(
double minimumRecallAtK, long maximumP99Millis, long maximumIndexMemoryBytes) {
/** The platform's default bar: 90% recall@10, 200 ms p99. */
public static MongoVectorSearchBenchmarkGate standard() {
return new MongoVectorSearchBenchmarkGate(0.9, 200, 4L * 1024 * 1024 * 1024);
}
public MongoVectorSearchBenchmarkGate {
if (minimumRecallAtK <= 0 || minimumRecallAtK > 1) {
throw new IllegalArgumentException("recall must be a fraction between 0 and 1");
}
if (maximumP99Millis <= 0 || maximumIndexMemoryBytes <= 0) {
throw new IllegalArgumentException("benchmark bounds must be positive");
}
}
/** The bounds a measured run failed, empty when it passed. */
public Set<String> failures(
double measuredRecall, long measuredP99Millis, long measuredIndexMemoryBytes) {
Set<String> failures = new LinkedHashSet<>();
if (measuredRecall < minimumRecallAtK) {
failures.add("recall " + measuredRecall + " below " + minimumRecallAtK);
}
if (measuredP99Millis > maximumP99Millis) {
failures.add("p99 " + measuredP99Millis + "ms above " + maximumP99Millis + "ms");
}
if (measuredIndexMemoryBytes > maximumIndexMemoryBytes) {
failures.add(
"index memory " + measuredIndexMemoryBytes + " above " + maximumIndexMemoryBytes);
}
return Set.copyOf(failures);
}
/** True when a measured run clears every bound. */
public boolean passes(
double measuredRecall, long measuredP99Millis, long measuredIndexMemoryBytes) {
return failures(measuredRecall, measuredP99Millis, measuredIndexMemoryBytes).isEmpty();
}
/** The evidence categories a promotion must supply. */
public static Set<String> requiredEvidence() {
return Objects.requireNonNull(
Set.of("index-readiness", "recall", "latency", "memory", "actual-topology"));
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.util.List;
/**
* Vector similarity search against a {@code READY} index (advanced plan Task 9).
*
* <p>Results carry a named score contract rather than the provider's raw number. A raw score is
* only interpretable against the index's similarity metric, and a caller that thresholds on it is
* coupled to a metric that can change when the index is rebuilt.
*/
public interface MongoVectorSearchOperations {
/** Runs a vector query, refusing if the index is not ready. */
<T> List<MongoVectorHit<T>> search(
MongoOperationContext context, MongoVectorQuery query, Class<T> documentType);
/** The current state of a vector index. */
MongoSearchIndexState indexState(String indexName);
/**
* One result and its interpreted score.
*
* @param <T> the document type
*/
record MongoVectorHit<T>(T document, double normalizedScore, String scoreContract) {}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
/**
* A pipeline together with the grading of every stage in it (design §17).
*
* <p>Stages and operations are added in one call so the two lists cannot drift. If a caller could
* append an {@code AggregationOperation} without declaring which stage it is, the policy check
* would be reviewing a description of the pipeline rather than the pipeline itself.
*/
public record MongoAggregationPlan(
List<AggregationOperation> operations,
List<MongoAggregationStageDescriptor> stages,
Set<String> lookupCollections) {
public MongoAggregationPlan {
Objects.requireNonNull(operations, "operations");
Objects.requireNonNull(stages, "stages");
Objects.requireNonNull(lookupCollections, "lookupCollections");
operations = List.copyOf(operations);
stages = List.copyOf(stages);
lookupCollections = Set.copyOf(lookupCollections);
if (operations.size() != stages.size()) {
throw new IllegalArgumentException(
"every aggregation operation must declare exactly one stage descriptor");
}
if (operations.isEmpty()) {
throw new IllegalArgumentException("an aggregation plan needs at least one stage");
}
}
/** Starts a plan. */
public static Builder builder() {
return new Builder();
}
/** Collects operations together with their declared stage names. */
public static final class Builder {
private final List<AggregationOperation> operations = new ArrayList<>();
private final List<MongoAggregationStageDescriptor> stages = new ArrayList<>();
private final Set<String> lookupCollections = new LinkedHashSet<>();
private Builder() {}
/** Appends one operation and the stage name it produces. */
public Builder stage(String stageName, AggregationOperation operation) {
Objects.requireNonNull(stageName, "stageName");
Objects.requireNonNull(operation, "operation");
stages.add(MongoAggregationStageDescriptor.of(stageName));
operations.add(operation);
return this;
}
/** Appends a {@code $lookup} and records the collection it reads. */
public Builder lookup(String targetCollection, AggregationOperation operation) {
lookupCollections.add(Objects.requireNonNull(targetCollection, "targetCollection"));
return stage("$lookup", operation);
}
/** Builds the immutable plan. */
public MongoAggregationPlan build() {
return new MongoAggregationPlan(operations, stages, lookupCollections);
}
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What one aggregation caller is allowed to run (design §17).
*
* <p>{@code allowDiskUse} is a declared property rather than a retry-on-failure fallback. Enabling
* it after a memory-limit failure turns an error into a silent slowdown: the pipeline then succeeds
* by writing to disk, and nobody learns that the data outgrew the plan.
*/
public record MongoAggregationProfile(
Set<MongoAggregationRisk> allowedRisks,
Set<String> reviewedStages,
Set<String> lookupCollectionAllowlist,
int maxStages,
boolean allowDiskUse,
boolean strictMapping) {
/** A pipeline longer than this is a report, not a query, and belongs in an offline job. */
public static final int DEFAULT_MAX_STAGES = 12;
public MongoAggregationProfile {
Objects.requireNonNull(allowedRisks, "allowedRisks");
Objects.requireNonNull(reviewedStages, "reviewedStages");
Objects.requireNonNull(lookupCollectionAllowlist, "lookupCollectionAllowlist");
allowedRisks = Set.copyOf(allowedRisks);
reviewedStages = Set.copyOf(reviewedStages);
lookupCollectionAllowlist = Set.copyOf(lookupCollectionAllowlist);
if (maxStages <= 0) {
throw new IllegalArgumentException("an aggregation profile needs a positive stage limit");
}
if (allowedRisks.contains(MongoAggregationRisk.A4_ADMIN)) {
throw new IllegalArgumentException(
"an aggregation profile must not allow admin stages; $out and $merge are D4 operations");
}
}
/** The default read profile: streaming stages only, strict mapping, no disk spill. */
public static MongoAggregationProfile stableRead() {
return new MongoAggregationProfile(
Set.of(MongoAggregationRisk.A1_BOUNDED),
Set.of(),
Set.of(),
DEFAULT_MAX_STAGES,
false,
true);
}
/** A profile that also permits accumulating stages, having declared the resources for them. */
public static MongoAggregationProfile budgetedRead(boolean allowDiskUse) {
return new MongoAggregationProfile(
Set.of(MongoAggregationRisk.A1_BOUNDED, MongoAggregationRisk.A2_BUDGETED),
Set.of(),
Set.of(),
DEFAULT_MAX_STAGES,
allowDiskUse,
true);
}
/** Returns a copy that permits the named reviewed stages. */
public MongoAggregationProfile withReviewedStages(String... stages) {
Set<String> reviewed = new LinkedHashSet<>(reviewedStages);
reviewed.addAll(Arrays.asList(stages));
return new MongoAggregationProfile(
allowedRisks, reviewed, lookupCollectionAllowlist, maxStages, allowDiskUse, strictMapping);
}
/** Returns a copy that permits {@code $lookup} against the named collections. */
public MongoAggregationProfile withLookupCollections(String... collections) {
Set<String> allowlist = new LinkedHashSet<>(lookupCollectionAllowlist);
allowlist.addAll(Arrays.asList(collections));
return new MongoAggregationProfile(
allowedRisks, reviewedStages, allowlist, maxStages, allowDiskUse, strictMapping);
}
/**
* Checks one stage against this profile.
*
* @throws MongoOperationRejectedException naming why the stage is not permitted
*/
public void requireAllowed(MongoAggregationStageDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
if (descriptor.writes()) {
throw MongoOperationRejectedException.of(
"aggregation.stage",
"stage "
+ descriptor.stage()
+ " writes to a collection and is a D4 admin operation; it never executes through the "
+ "read aggregation API");
}
if (allowedRisks.contains(descriptor.risk())) {
return;
}
if (descriptor.risk() == MongoAggregationRisk.A3_REVIEWED
&& reviewedStages.contains(descriptor.stage())) {
return;
}
throw MongoOperationRejectedException.of(
"aggregation.stage",
"stage "
+ descriptor.stage()
+ " is graded "
+ descriptor.risk()
+ ", which this profile does not permit");
}
/**
* Checks a {@code $lookup} target.
*
* @throws MongoOperationRejectedException when the collection is not on the allowlist
*/
public void requireLookupCollection(String collection) {
if (!lookupCollectionAllowlist.contains(Objects.requireNonNull(collection, "collection"))) {
throw MongoOperationRejectedException.of(
"aggregation.lookup",
"collection '" + collection + "' is not on this profile's $lookup allowlist");
}
}
/**
* Checks the pipeline length.
*
* @throws MongoOperationRejectedException when the pipeline is longer than the profile allows
*/
public void requireStageCount(int stageCount) {
if (stageCount > maxStages) {
throw MongoOperationRejectedException.of(
"aggregation.stage",
"the pipeline has " + stageCount + " stages, above this profile's limit of " + maxStages);
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
/**
* The resource class of one aggregation stage (design §17).
*
* <p>Aggregation stages are not equally dangerous, and the difference is not visible in the
* pipeline text. {@code $match} streams; {@code $group} and {@code $sort} accumulate, hit a 100 MiB
* per-stage memory limit and then either fail or spill to disk; {@code $facet} and {@code
* $graphLookup} multiply that cost; {@code $out} and {@code $merge} write. Grading the stages is
* what lets one policy answer "may this pipeline run here" without reading it line by line.
*/
public enum MongoAggregationRisk {
/** Streaming, bounded stages. Allowed by default. */
A1_BOUNDED,
/** Accumulating stages. Require a declared resource profile. */
A2_BUDGETED,
/** Multiplying stages. Require explicit review registration. */
A3_REVIEWED,
/** Write and administrative stages. D4 only; never reachable from a read API. */
A4_ADMIN;
/** True when a stage of this class may run without extra registration. */
public boolean allowedByDefault() {
return this == A1_BOUNDED;
}
/** True when a stage of this class writes and therefore belongs to the admin plane. */
public boolean isWriteStage() {
return this == A4_ADMIN;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import java.util.Map;
import java.util.Objects;
/**
* One aggregation stage, graded (design §17).
*
* <p>The grade table is the platform's own, not the server's. MongoDB will happily run a {@code
* $facet} over an unbounded input; whether this application should is a capacity decision, and this
* is where it is recorded.
*/
public record MongoAggregationStageDescriptor(String stage, MongoAggregationRisk risk) {
private static final Map<String, MongoAggregationRisk> KNOWN_STAGES =
Map.ofEntries(
Map.entry("$match", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$project", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$set", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$addFields", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$unset", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$limit", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$skip", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$count", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$sort", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$group", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$unwind", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$lookup", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$bucket", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$bucketAuto", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$sortByCount", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$facet", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$graphLookup", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$setWindowFields", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$unionWith", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$densify", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$out", MongoAggregationRisk.A4_ADMIN),
Map.entry("$merge", MongoAggregationRisk.A4_ADMIN),
Map.entry("$planCacheStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$collStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$indexStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$currentOp", MongoAggregationRisk.A4_ADMIN),
Map.entry("$listSessions", MongoAggregationRisk.A4_ADMIN));
public MongoAggregationStageDescriptor {
Objects.requireNonNull(stage, "stage");
Objects.requireNonNull(risk, "risk");
if (!stage.startsWith("$")) {
throw new IllegalArgumentException("an aggregation stage name starts with '$': " + stage);
}
}
/**
* Grades a stage by name.
*
* <p>An unknown stage is graded {@code A3_REVIEWED}, not {@code A1_BOUNDED}. A stage this
* platform has never seen is one whose cost nobody here has reasoned about, and defaulting it to
* "cheap" would let a future server release introduce an expensive stage that runs unreviewed.
*/
public static MongoAggregationStageDescriptor of(String stage) {
Objects.requireNonNull(stage, "stage");
return new MongoAggregationStageDescriptor(
stage, KNOWN_STAGES.getOrDefault(stage, MongoAggregationRisk.A3_REVIEWED));
}
/** True when this stage writes to a collection. */
public boolean writes() {
return risk.isWriteStage();
}
}
@@ -0,0 +1,109 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
/**
* Runs an aggregation only after its stages, budget and lookups have been checked (design §17).
*
* <p>Every guard runs before the first stage reaches the server, because an aggregation that is
* going to be refused should cost nothing. The one guard that runs afterwards is the result-count
* check: {@code $limit} bounds what the pipeline emits, but a pipeline whose own shape produced
* more than the budget allows has still told the caller something worth failing over.
*/
public final class PolicyAwareMongoAggregationExecutor {
private final MongoOperations operations;
private final MongoBudgetPolicyRegistry budgets;
private final MongoBudgetEnforcer enforcer;
public PolicyAwareMongoAggregationExecutor(
MongoOperations operations, MongoBudgetPolicyRegistry budgets, MongoBudgetEnforcer enforcer) {
this.operations = Objects.requireNonNull(operations, "operations");
this.budgets = Objects.requireNonNull(budgets, "budgets");
this.enforcer = Objects.requireNonNull(enforcer, "enforcer");
}
/**
* Validates a plan against a profile without executing it.
*
* <p>Separate from execution so a startup check or a test can prove a pipeline is admissible
* without a server.
*/
public void validate(MongoAggregationPlan plan, MongoAggregationProfile profile) {
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(profile, "profile");
profile.requireStageCount(plan.stages().size());
plan.stages().forEach(profile::requireAllowed);
plan.lookupCollections().forEach(profile::requireLookupCollection);
}
/**
* Executes a typed aggregation.
*
* @throws MongoOperationRejectedException when a stage, lookup, budget or result size is not
* permitted
*/
public <T> List<T> execute(
MongoOperationContext context,
MongoAggregationProfile profile,
MongoAggregationPlan plan,
String collection,
Class<T> outputType) {
Objects.requireNonNull(context, "context");
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(outputType, "outputType");
validate(plan, profile);
MongoOperationBudget budget = budgets.require(context.operationName());
MongoOperationBudget effective =
enforcer.narrow(budget, budget.narrowedTo(budgetFor(context, budget)));
Aggregation aggregation =
Aggregation.newAggregation(plan.operations()).withOptions(optionsFor(profile, effective));
AggregationResults<T> results = operations.aggregate(aggregation, collection, outputType);
List<T> mapped = results.getMappedResults();
if (mapped.size() > effective.maxResults()) {
throw MongoOperationRejectedException.of(
"aggregation.result",
"the aggregation returned "
+ mapped.size()
+ " documents, above the budget of "
+ effective.maxResults());
}
return mapped;
}
private static MongoOperationBudget budgetFor(
MongoOperationContext context, MongoOperationBudget registered) {
// The context's timeout is the caller's deadline; it may tighten maxTimeMS but never extend it.
long contextMillis = Math.max(1L, context.timeout().toMillis());
return new MongoOperationBudget(
registered.maxResults(),
registered.maxResultBytes(),
Math.min(registered.maxTimeMillis(), contextMillis),
registered.cursorBatchSize());
}
private static AggregationOptions optionsFor(
MongoAggregationProfile profile, MongoOperationBudget budget) {
AggregationOptions.Builder options =
AggregationOptions.builder()
.allowDiskUse(profile.allowDiskUse())
.cursorBatchSize(budget.cursorBatchSize())
.maxTime(Duration.ofMillis(budget.maxTimeMillis()));
return profile.strictMapping() ? options.strictMapping().build() : options.build();
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Registered logical name of a collection profile (design §7.1, §16.2).
*
* <p>Every operation resolves its guardrails field allowlist, operator allowlist, budget, index
* manifest, TTL policy through this name. Accepting a caller-supplied collection string instead
* would defeat all of them at once, so the same dynamic-value rejection as {@link
* DatabaseProfileName} applies here.
*/
public record CollectionProfileName(String value) {
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9-]{2,63}");
private static final Pattern UUID_LIKE =
Pattern.compile("(?i).*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*");
public CollectionProfileName {
if (value == null || !FORMAT.matcher(value).matches() || UUID_LIKE.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB collection profile name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Registered logical name of a database profile (design §7.1, §28).
*
* <p>A profile name selects a configured connection, credential, consistency default and timeout
* set. It is never the physical database name supplied by a caller: allowing that would turn a
* request value into a routing decision and into a metric tag. The pattern therefore rejects
* slashes, whitespace and generated-identifier shapes, which is what {@code Dynamic collection
* profile} in the design's startup failure list means in practice.
*/
public record DatabaseProfileName(String value) {
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9-]{2,63}");
/** A value that looks like a generated identifier is dynamic input, not a registered profile. */
private static final Pattern UUID_LIKE =
Pattern.compile("(?i).*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*");
public DatabaseProfileName {
if (value == null || !FORMAT.matcher(value).matches() || UUID_LIKE.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB database profile name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile;
import java.time.Duration;
import java.util.Objects;
/**
* Immutable execution context every platform operation requires (design §7.1).
*
* <p>The context is deliberately the only way to reach an execution path: it forces the caller to
* name the operation, the database and collection profiles, the consistency guarantee it is asking
* for, and a positive timeout. Nothing here identifies a document, a tenant or a user, so the whole
* context can be attached to telemetry without a redaction step.
*/
public record MongoOperationContext(
MongoOperationName operationName,
DatabaseProfileName databaseProfile,
CollectionProfileName collectionProfile,
MongoConsistencyProfile consistency,
Duration timeout) {
public MongoOperationContext {
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(databaseProfile, "databaseProfile");
Objects.requireNonNull(collectionProfile, "collectionProfile");
Objects.requireNonNull(consistency, "consistency");
Objects.requireNonNull(timeout, "timeout");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("MongoDB operation timeout must be positive");
}
}
/** Convenience factory for the common case where profile names are plain registered strings. */
public static MongoOperationContext of(
String operationName,
String databaseProfile,
String collectionProfile,
MongoConsistencyProfile consistency,
Duration timeout) {
return new MongoOperationContext(
new MongoOperationName(operationName),
new DatabaseProfileName(databaseProfile),
new CollectionProfileName(collectionProfile),
consistency,
timeout);
}
/**
* Narrows the consistency profile of an existing context.
*
* <p>Returns a new value; a context is never mutated, because the same instance is handed to
* observation, budget resolution and failure translation.
*/
public MongoOperationContext withConsistency(MongoConsistencyProfile newConsistency) {
return new MongoOperationContext(
operationName, databaseProfile, collectionProfile, newConsistency, timeout);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Bounded, low-cardinality identity for one logical MongoDB operation (design §7.1).
*
* <p>The value is the key used by metrics, traces, budget lookup and policy resolution, so it must
* never carry a dynamic value: no document id, no tenant id, no collection name assembled at
* runtime, no request-scoped value. The format is fixed by the design and validated in the
* canonical constructor, which is what keeps metric cardinality bounded at the type level rather
* than by convention.
*/
public record MongoOperationName(String value) {
/** Design §7.1 — the exact accepted shape of an operation name. */
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
public MongoOperationName {
if (value == null || !FORMAT.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB operation name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.Objects;
/**
* The three identifiers every failure, metric and trace is keyed by (design §15, §27).
*
* <p>Grouping them is what makes "never null" affordable: a failure raised before profile
* resolution still needs an operation name, so the unresolved profiles become the explicit {@code
* unspecified} identity rather than a null the telemetry layer has to defend against.
*/
public record MongoOperationScope(
MongoOperationName operationName,
DatabaseProfileName databaseProfile,
CollectionProfileName collectionProfile) {
/** Registered name used when a failure happens before profile resolution. */
public static final String UNSPECIFIED = "unspecified";
public MongoOperationScope {
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(databaseProfile, "databaseProfile");
Objects.requireNonNull(collectionProfile, "collectionProfile");
}
/** The scope of a fully resolved operation context. */
public static MongoOperationScope of(MongoOperationContext context) {
Objects.requireNonNull(context, "context");
return new MongoOperationScope(
context.operationName(), context.databaseProfile(), context.collectionProfile());
}
/** A scope for a failure raised before the database and collection profiles were resolved. */
public static MongoOperationScope ofOperation(MongoOperationName operationName) {
return new MongoOperationScope(
operationName,
new DatabaseProfileName(UNSPECIFIED),
new CollectionProfileName(UNSPECIFIED));
}
/** True when the profiles are still the placeholder identity. */
public boolean isProfileResolved() {
return !UNSPECIFIED.equals(databaseProfile.value())
&& !UNSPECIFIED.equals(collectionProfile.value());
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.mongo.api;
/**
* The kind of MongoDB operation, as a bounded telemetry dimension (design §15, §27).
*
* <p>Distinct from {@link MongoOperationName}: the name says which business operation ran, the type
* says which shape of database work it was. Both are low cardinality, and neither carries data.
*/
public enum MongoOperationType {
FIND,
COUNT,
DISTINCT,
INSERT,
UPDATE,
REPLACE,
DELETE,
FIND_AND_MODIFY,
BULK_WRITE,
AGGREGATE,
CURSOR,
CHANGE_STREAM,
TRANSACTION_COMMIT,
TRANSACTION_ABORT,
CAPABILITY_COMMAND,
ADMIN_COMMAND,
UNKNOWN
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
/**
* The closed set of capabilities the platform can report on (design §7.4, §22-§25).
*
* <p>A closed enum rather than free strings, because a capability name is looked up at startup to
* decide whether a whole subsystem may wire itself. A typo in a free-form key would silently answer
* "unsupported" and disable a feature the deployment paid for.
*/
public enum MongoCapability {
/** Multi-document transactions; implies a replica set or sharded topology. */
TRANSACTION,
/** Causally consistent sessions for read-your-writes. */
CAUSAL_SESSION,
/** Change streams; implies a replica set or sharded topology and a watch privilege. */
CHANGE_STREAM,
/** GeoJSON and 2dsphere queries. */
GEOSPATIAL,
/** TTL indexes as physical cleanup. */
TTL_CLEANUP,
/** Sharded routing awareness in the application plane. */
SHARDING,
/** Time series collections, which do not inherit general collection capabilities. */
TIME_SERIES,
/** Client-side field level encryption. */
CSFLE,
/** Queryable encryption, equality and range only on the MongoDB 8.0 Stable lane. */
QUERYABLE_ENCRYPTION,
/** Full-text search indexes and queries. */
SEARCH,
/** Vector search indexes and queries. */
VECTOR_SEARCH,
/** Shared-collection multi-tenancy guardrails. */
SHARED_COLLECTION_TENANCY,
/** Database-per-tenant routing and lifecycle. */
DATABASE_PER_TENANT,
/** GridFS legacy read and migration compatibility. */
GRIDFS_COMPATIBILITY,
/** D4 administrative plane: collection, validator, index, migration, shard, repair. */
ADMIN_PLANE
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/**
* The capability report for one configured runtime (design §7.4).
*
* <p>Unknown capabilities do not throw: they answer {@code UNSUPPORTED} with the reason {@code
* not-reported}, so a startup probe can distinguish "the server said no" from "nobody ever asked
* the server". Both are refusals, but only the second is a configuration bug.
*/
public final class MongoCapabilitySet {
private static final String NOT_REPORTED = "not-reported";
private final Map<MongoCapability, MongoCapabilitySupport> supports;
private MongoCapabilitySet(Map<MongoCapability, MongoCapabilitySupport> supports) {
this.supports = supports;
}
/** Builds a set from explicit support declarations; a later duplicate replaces an earlier one. */
public static MongoCapabilitySet of(MongoCapabilitySupport... declarations) {
return of(Arrays.asList(declarations));
}
/** Builds a set from explicit support declarations; a later duplicate replaces an earlier one. */
public static MongoCapabilitySet of(Collection<MongoCapabilitySupport> declarations) {
Objects.requireNonNull(declarations, "declarations");
Map<MongoCapability, MongoCapabilitySupport> byCapability =
new EnumMap<>(MongoCapability.class);
for (MongoCapabilitySupport declaration : declarations) {
Objects.requireNonNull(declaration, "declaration");
byCapability.put(declaration.capability(), declaration);
}
return new MongoCapabilitySet(byCapability);
}
/** An empty report: every capability answers unsupported with an explicit reason. */
public static MongoCapabilitySet empty() {
return new MongoCapabilitySet(new EnumMap<>(MongoCapability.class));
}
/**
* Returns the support record for a capability, never {@code null}.
*
* <p>The design's rule is that a refusal always carries a reason, so an unreported capability is
* materialised as an {@code UNSUPPORTED} record rather than an empty optional the caller might
* quietly ignore.
*/
public MongoCapabilitySupport require(MongoCapability capability) {
Objects.requireNonNull(capability, "capability");
MongoCapabilitySupport support = supports.get(capability);
return support != null ? support : MongoCapabilitySupport.unsupported(capability, NOT_REPORTED);
}
/** True only when the capability is certified on the Stable lane. */
public boolean isStable(MongoCapability capability) {
return require(capability).usableOnStableLane();
}
/** All declared support records, keyed by capability. */
public Map<MongoCapability, MongoCapabilitySupport> declared() {
return Map.copyOf(supports);
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* What one capability actually supports here, and under which constraints (design §7.4).
*
* <p>The design is explicit that a capability must not answer with a bare {@code boolean}. Time
* series, sharding, encryption and search each fail for a different reason wrong topology, server
* version, missing privilege, unsupported combination and a caller that only learns "no" cannot
* tell an operator what to change. The constraint map carries that reason, as immutable strings
* only: no driver handle, no provider object, nothing that would make this value unsafe to log.
*/
public record MongoCapabilitySupport(
MongoCapability capability, MongoSupportLevel level, Map<String, String> constraints) {
/** Constraint key for the topology a capability requires. */
public static final String TOPOLOGY = "topology";
/** Constraint key for the minimum server version a capability requires. */
public static final String SERVER_VERSION = "serverVersion";
/** Constraint key for the privilege or principal a capability requires. */
public static final String PRIVILEGE = "privilege";
/** Constraint key explaining why a capability is unsupported. */
public static final String REASON = "reason";
public MongoCapabilitySupport {
Objects.requireNonNull(capability, "capability");
Objects.requireNonNull(level, "level");
Objects.requireNonNull(constraints, "constraints");
constraints = Map.copyOf(constraints);
if (level == MongoSupportLevel.UNSUPPORTED && !constraints.containsKey(REASON)) {
throw new IllegalArgumentException(
"unsupported MongoDB capability must carry an explicit reason: " + capability);
}
}
/** Declares a capability supported at the given level with no further constraint. */
public static MongoCapabilitySupport of(MongoCapability capability, MongoSupportLevel level) {
return new MongoCapabilitySupport(capability, level, Map.of());
}
/** Declares a capability unsupported, forcing the caller to state why. */
public static MongoCapabilitySupport unsupported(MongoCapability capability, String reason) {
return new MongoCapabilitySupport(
capability, MongoSupportLevel.UNSUPPORTED, Map.of(REASON, reason));
}
/** Returns a copy with one additional constraint entry. */
public MongoCapabilitySupport withConstraint(String key, String value) {
Map<String, String> merged = new LinkedHashMap<>(constraints);
merged.put(Objects.requireNonNull(key, "key"), Objects.requireNonNull(value, "value"));
return new MongoCapabilitySupport(capability, level, merged);
}
/** The topology this capability requires, or an empty string when it imposes none. */
public String requiredTopology() {
return constraints.getOrDefault(TOPOLOGY, "");
}
/**
* The minimum server version this capability requires, or an empty string when it imposes none.
*/
public String requiredServerVersion() {
return constraints.getOrDefault(SERVER_VERSION, "");
}
/** The privilege this capability requires, or an empty string when it imposes none. */
public String requiredPrivilege() {
return constraints.getOrDefault(PRIVILEGE, "");
}
/** True when this capability may be used on the Stable lane without an opt-in module. */
public boolean usableOnStableLane() {
return level == MongoSupportLevel.STABLE;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
/**
* Support tier of one MongoDB capability (design §7.4).
*
* <p>The tier is part of the public contract, not documentation: an {@code ADVANCED} or {@code
* EXPERIMENTAL} capability is never reachable from the Stable starter's default wiring, and {@code
* UNSUPPORTED} always carries a reason instead of degrading into a silent {@code false}.
*/
public enum MongoSupportLevel {
/** Certified on both release lanes and covered by the Stable release gate. */
STABLE,
/** Isolated opt-in module with its own topology, credential or provider gate. */
ADVANCED,
/** Not promotable yet: operational scale or provider evidence is missing. */
EXPERIMENTAL,
/** Not available in this profile, topology, server version or privilege set. */
UNSUPPORTED
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
import java.util.Objects;
/**
* The concrete read preference, read concern and write concern behind a named profile (design
* §7.2).
*
* <p>The values are plain strings rather than driver types because this type lives in the
* framework-free core: the Spring Data layer translates them once, at the edge, and every other
* layer reasons about the profile instead of about driver enums.
*/
public record MongoConsistencyDescriptor(
MongoConsistencyProfile profile,
String readPreference,
String readConcern,
String writeConcern,
boolean requiresCausalSession,
MongoConsistencyGuarantee guarantee) {
/** Read preference value meaning "always the primary". */
public static final String PRIMARY = "primary";
/** Read preference value meaning "a secondary when one is available". */
public static final String SECONDARY_PREFERRED = "secondaryPreferred";
public MongoConsistencyDescriptor {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(readPreference, "readPreference");
Objects.requireNonNull(readConcern, "readConcern");
Objects.requireNonNull(writeConcern, "writeConcern");
Objects.requireNonNull(guarantee, "guarantee");
if (requiresCausalSession && !"majority".equals(readConcern)) {
throw new IllegalArgumentException(
"a causal session profile requires majority read concern: " + profile);
}
if (requiresCausalSession && !"majority".equals(writeConcern)) {
throw new IllegalArgumentException(
"a causal session profile requires majority write concern: " + profile);
}
}
/** True when this profile may serve reads from a secondary. */
public boolean readsFromSecondary() {
return !PRIMARY.equals(readPreference);
}
/** Convenience view used by the transaction layer, which forbids secondary reads. */
public boolean staleReadsPossible() {
return guarantee.staleReadsPossible();
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
import java.util.Objects;
/**
* The human-readable guarantee a consistency profile actually provides (design §7.2).
*
* <p>The design's completion criterion asks whether "the caller knows the real guarantee of the
* read/write concern". A prose sentence attached to the profile is how that question gets a
* checkable answer: it is asserted in tests and rendered into the generated support matrix, so a
* profile whose concerns change without its promise changing fails the build.
*/
public record MongoConsistencyGuarantee(
String summary,
boolean durableAgainstPrimaryFailover,
boolean readsOwnWrites,
boolean staleReadsPossible) {
public MongoConsistencyGuarantee {
Objects.requireNonNull(summary, "summary");
if (summary.isBlank()) {
throw new IllegalArgumentException("consistency guarantee needs a summary");
}
}
}

Some files were not shown because too many files have changed in this diff Show More