Architecture Decision Records
The significant architectural decisions visible in the codebase, captured as short records. Each states the decision, why, and where it shows up in code.
ADR-1 — Shared database with Row-Level Security for tenant isolation
Decision. Use a shared database, tenant-scoped model. In PostgreSQL, enforce isolation with Row-Level Security plus a dedicated non-BYPASSRLS application role (falcone_app, with anon/service variants); in MongoDB, enforce it with an adapter-injected tenantId predicate.
Why. RLS makes isolation a property of the database, not just the application — a forgotten WHERE tenant_id = … is still constrained. Using a non-superuser role is essential because RLS does not apply to superusers/BYPASSRLS roles.
Evidence. docs/reference/postgresql/tenant-isolation-baseline.sql; executor SET LOCAL ROLE; packages/adapters/src/*.
ADR-2 — Credential-derived tenant; fail closed
Decision. The tenant/workspace are derived from a verified credential in strict precedence (API key → Bearer JWT → gateway-injected headers). A presented-but-invalid credential returns 401 and never falls back to client-supplied headers.
Why. Trusting a client-supplied x-tenant-id is a spoofing vector (a real bug, since fixed). Deriving identity from the credential makes spoofing impossible by construction.
Evidence. apps/control-plane-executor/src/runtime/server.mjs::resolveIdentity.
ADR-3 — Single gateway, two privilege domains
Decision. Front the whole platform with APISIX and split routes into structural_admin and data_access, enforced by a scope plugin. Route anon/service traffic by the apikey header.
Why. One ingress simplifies clients and TLS; domain separation keeps the management plane unreachable from data-plane credentials.
Evidence. deploy/gateway-config/public-route-catalog.json; scope-enforcement plugin + specs.
ADR-4 — Executor over adapter-built plans
Decision. Separate plan building (adapters, pure functions) from plan execution (a dependency-light executor running real drivers).
Why. Plan builders are testable without a database and concentrate the tenant predicate in one place; the executor stays small and driver-focused.
Evidence. packages/adapters/src/* (build*Plan) → apps/control-plane-executor/src/runtime/*-executor.mjs.
ADR-5 — Supabase-style anon/service API keys
Decision. Offer flc_anon_… (read-mostly, browser-safe, RLS-bound) and flc_service_… (server-side, elevated) keys, transported via the apikey header (and ?apikey= for SSE).
Why. Lets frontends talk to the platform directly without a backend, while keeping privileged access server-side. Anon keys bind to a constrained DB role so RLS still applies.
Evidence. api-keys.mjs; gateway vars on ^flc_.
ADR-6 — Realtime over SSE, tenant-scoped at the source
Decision. Deliver realtime via Server-Sent Events (no WebSocket dependency), sourced from the document store's Postgres logical-replication CDC (pgoutput on the DocumentDB engine; see ADR-14) and PostgreSQL trigger CDC, with tenant matching done inside the pipeline/channel.
Why. SSE is simple and proxy-friendly; matching the tenant at the source guarantees a subscriber cannot receive another tenant's changes even if routing is wrong. EventSource can't set headers, so SSE accepts ?apikey=.
Evidence. realtime-executor.mjs, postgres-realtime-executor.mjs.
ADR-7 — Per-key rate limiting with explicit policy
Decision. Rate-limit with APISIX limit-count keyed by var_combination on $http_apikey (per-key buckets), with a choice of local (node-local) or redis (globally exact) policy.
Why. A plain var key rate-limits globally across keys (a real bug, caught by live testing). var_combination isolates each key; the policy choice trades exactness for a Redis dependency.
Evidence. scripts/lib/gateway-policy.mjs; gateway route config.
ADR-8 — Umbrella Helm chart with layered values
Decision. Package the core platform as one umbrella chart with unconditional service dependencies, configured by layered values (environment → customer → platform → airgap → local) and sizing profiles (all-in-one / standard / ha).
Why. One artifact serves dev, Kubernetes, OpenShift and air-gapped installs; layering keeps environment/customer/platform concerns separable and reviewable.
Evidence. ../falcone-charts/charts/in-falcone/Chart.yaml, values/, templates/NOTES.txt.
ADR-9 — Secrets via OpenBao + External Secrets Operator
Decision. Source secrets from OpenBao through the External Secrets Operator; reference secret names in the chart, never values. (Migrated from HashiCorp Vault to OpenBao, a drop-in fork — PR #720.)
Why. Keeps secret material out of git and values files; integrates with existing secret stores.
Evidence. chart eso/openbao sections; secretKeyRef mounts.
ADR-10 — Soft-delete lifecycle for cascading cleanup
Decision. Govern entities with a draft → active → suspended → soft_deleted lifecycle.
Why. Soft deletion lets the platform deprovision tenants/workspaces and cascade resource cleanup while preserving audit history and avoiding orphaned cross-tenant data.
Evidence. packages/internal-contracts/src/domain-model.json (lifecycle_transitions).
ADR-11 — Temporal for the durable workflow (flows) engine
Decision. Adopt Temporal as the durable execution engine for the flows capability, using the TypeScript SDK (@temporalio/{worker,workflow,activity,client}). Flows are authored as a YAML DSL interpreted by a single generic interpreter workflow; the parsed definition is passed as workflow input (definition-passing strategy), not loaded per-replay. Branch conditions are evaluated by the CEL expression engine (cel-js). Tenancy uses a shared namespace with a tenantId custom search attribute (not namespace-per-tenant). Run-history visibility is served by Temporal's PostgreSQL SQL advanced-visibility store — no Elasticsearch. Temporal itself is internal-only, and its Web UI is operator-only (never exposed to tenants).
Why.
- Adoption rationale. Temporal gives durable execution (workflow state survives process/worker crashes via event-sourced history replay), millisecond task dispatch via task-queue long-polling, native namespaces for multi-environment/multi-tenant partitioning, and is MIT-licensed (no commercial-license risk for self-hosted install). The trade-off accepted is that Temporal is code-first, so flows are wrapped in a YAML DSL + a generic interpreter workflow to give product/console users a declarative authoring surface over the code-first engine.
- SDK = TypeScript. The entire backend is Node — there is no non-Node backend language in
apps/orservices/. Evidence:apps/control-plane-executor/DockerfilebuildsFROM node:22-alpine;apps/control-plane-executor/package.jsondeclares"type":"module"(Node ESM); every service underservices/ships.mjsmodules with"type":"module". The TypeScript SDK is the only first-class fit; introducing another language solely for flows would fragment the stack. - Tenancy = shared namespace +
tenantIdsearch attribute. Spike B measured both models at N = {1, 5, 20} tenants. Namespace-per-tenant scales pollers linearly (4 → 20 → 80) and gRPC connections super-linearly (4 → 60 → 840 total), an operational ceiling that worsens per tenant. The shared model holds fleet topology flat at 4 pollers / 4 connections for all N, and thetenantIdfilter isolates run history with zero leakage. The flat profile and low operational complexity win; the soft isolation boundary is enforced by mandating thetenantIdfilter on every visibility query. - Definition-passing = workflow input. Spike A confirmed the full parsed definition is recorded in the
WorkflowExecutionStartedhistory event, making replay deterministic with no external lookup; the SDK replayer re-ran the history with no non-determinism error. - Expression engine = CEL. Spike A bundled and ran both
cel-jsandjsonatainside the Temporal V8 workflow sandbox — both survived and evaluated deterministically. CEL is chosen for its semantic fit (purpose-built side-effect-free boolean condition language); JSONata is the validated fallback. The measured bundle trade-off (CEL ~2.17 MB vs JSONata ~0.74 MB delta) is a one-time worker-image cost. - Visibility = PostgreSQL SQL, sufficient. Spike B ran
tenantId-filtered visibility queries against the PostgreSQL advanced-visibility store and observed zero cross-tenant leakage at N up to 20, establishing that PostgreSQL SQL visibility is sufficient and Elasticsearch is not required at this tier. - UI stance. Temporal is an internal-only dependency; its Web UI is operator-only and never reachable by tenant credentials — flow authoring/inspection for tenants happens through the Falcone console/API, consistent with the structural_admin / data_access split (ADR-3).
Evidence. SDK: apps/control-plane-executor/Dockerfile (FROM node:22-alpine), apps/control-plane-executor/package.json ("type":"module"), services/*/package.json ("type":"module", .mjs). Spikes: spikes/add-flows-adr-temporal-spikes/spike-a/ (durable resume, retry-across-restart, replay determinism, expression-engines.md) and spikes/add-flows-adr-temporal-spikes/spike-b/ (measurements.md, comparison-table.md, PostgreSQL SQL-visibility proof).
ADR-12 — MCP server hosting: runtime, gateway, OAuth and isolation
Decision. Host tenant MCP servers by reusing Falcone's existing Knative per-tenant runtime — each MCP server is a Knative Service (ksvc) in the tenant's namespace, served over Streamable HTTP. Front inbound traffic with the existing APISIX gateway (ADR-3), enforcing OAuth and per-tool scopes; adopt agentgateway only if APISIX per-tool RBAC proves insufficient. Use the existing realm-per-tenant Keycloak as the OAuth 2.1 Authorization Server (per-tool scopes = client scopes; dynamic client registration curated through the control-plane; consent via the approval-flow precedent). Instant MCP generation always passes through a mandatory curation gate (never a raw dump). Keep runtime pods stateless while storing registry, version, audit, and rate-limit state durably in PostgreSQL; idle servers scale to zero (free via Knative). The runtime/operator and gateway are internal-only, never tenant-exposed. Do not adopt the ToolHive operator or KEDA at this time.
Why.
- Runtime = reuse Knative (validated live). The platform already runs Knative Serving + Kourier with functions deployed as ksvcs (
fn-primary-multiplier), giving namespace-per-tenant, HTTP invocation and scale-to-zero that the team operates today. Spike (a) deployed a minimal MCP server (initialize/tools/list/tools/call, protocol2025-11-25) as a ksvc in a per-tenant namespace ontest-cluster-b; it served MCP JSON-RPC over Streamable HTTP and returned per-tenant context (tenant=tenant-a). Adopting the ToolHive operator would add a new operator to run and an OpenShift-SCC surface to validate, for no isolation/scale capability Knative does not already provide; revisit only if MCP-nativeMCPServerCRD ergonomics become compelling. - Statelessness + scale-to-zero (validated live). Spike (d): the ksvc scaled 1 → 0 replicas at ~30s idle and cold-started in ~1.16s on the next request, returning the correct result — idle tenant servers cost nothing and KEDA is unnecessary while on Knative.
- Gateway = reuse APISIX. The platform already fronts everything with APISIX and a scope-enforcement plugin across two privilege domains (ADR-3); MCP adds an MCP-aware route terminating Streamable HTTP, validating OAuth tokens and enforcing per-tool scopes. agentgateway (native MCP/A2A, per-tool RBAC) is the recorded fallback.
- OAuth = extend Keycloak (validated live). Spike (b): the running Keycloak exposes OIDC discovery with a dynamic client registration endpoint (
/realms/{realm}/clients-registrations/openid-connect) and supportsauthorization_code+client_credentials+refresh_tokengrants — the OAuth 2.1 primitives MCP requires. Falcone models per-tool scopes as client scopes and curates DCR through the control-plane (never exposing raw Keycloak admin), with consent reusing thewf-con-001-user-approvalpattern. This avoids building a greenfield Authorization Server. - Generation requires mandatory curation. Auto-generated tools from a raw schema/API degrade LLM tool-call quality; Instant MCP therefore emits a draft manifest that must pass a curation gate (prune + LLM-optimized descriptions + scopes) before publish.
- Spec baseline. Keep the hosted runtime stateless, persist control-plane MCP state in PostgreSQL, pin contracts to the 2025-11-25 stable spec, and treat 2026-07-28 as an announced RC — verify wording before locking any server contract (notably the Tasks extension used by workflows-as-tools).
Risks / caveats.
- NetworkPolicy isolation is not provable on kind.
test-cluster-b's CNI is kindnet, which does not enforce NetworkPolicy, so network-level cross-namespace blocking cannot be demonstrated there. Isolation in this ADR rests on namespace-per-tenant + gateway-only ingress; production/CI must run a policy-enforcing CNI (Calico/Cilium) for the NetworkPolicy/egress controls of issue #399. Recorded as a deployment requirement, not an assumption. - Temporal is not present on the spike cluster, so workflows-as-MCP-tools (#395) evidence is deferred to an environment where Temporal runs.
Evidence. Spikes: spikes/add-mcp-hosting-adr-spikes/runtime/ (server.mjs, Dockerfile, ksvc.yaml, evidence/invocation.txt, evidence/scale-to-zero.txt). Live cluster test-cluster-b: a Knative ksvc served MCP JSON-RPC; scaled to zero at ~30s with ~1.16s cold-start; Keycloak OIDC discovery exposes the DCR endpoint and the required grants. Reuses deploy/kind/knative, packages/provisioning-orchestrator/src/appliers/functions-applier.mjs (namespace = tenantId), the APISIX gateway (ADR-3), and packages/adapters/src/keycloak-admin.mjs.
ADR-13 — Migrate object store from MinIO to SeaweedFS
Runbook. Operational architecture, topology, filer-on-PostgreSQL, per-tenant identity model, replication, PVC sizing, TLS/networking, day-2 ops, observability and licensing: SeaweedFS Storage Runbook.
Decision. Replace the bundled MinIO object store with SeaweedFS (Apache-2.0) as the S3-compatible backend for the storage capability. Pin the migration to SeaweedFS 4.33 (chrislusf/seaweedfs@sha256:f0b358973e81f884304737645dd3b278c590c2c9d47d60089729d46324f70495). Back the filer with PostgreSQL ([postgres2], one table per bucket) in a dedicated database, reusing Falcone's existing Postgres tier rather than adding a new stateful dependency. Serve S3 on the confirmed port 8333 with path-style addressing. Bootstrap S3 identities from a static config file and onboard tenants live via s3.configure (no gateway restart). Reject MinIO CE (licence + console regression), RustFS (alpha), and Ceph/Rook (operational weight).
Why.
- Licence & sustainability. MinIO Community Edition removed OIDC/SSO from its console in May 2025 and its flagship repository was archived in February 2026; its AGPLv3 network-copyleft is a legal misfit for a BaaS that exposes S3 directly to tenants. SeaweedFS is Apache-2.0 with no commercial-licence risk for the self-hosted install, and is purpose-fit for the platform's small-object (SDK artefacts, tenant blobs) access pattern.
- S3 compatibility validated against Falcone's actual callers. The spike scored 13 SUPPORTED / 1 PARTIAL / 0 UNSUPPORTED at 4.33: path-style + SigV4 presigned GET,
ListBuckets/ListObjectsV2,createBucket, object round-trip,putBucketVersioning,putBucketLifecycleConfiguration,putBucketCors, object versioning and object-lock/WORM all work; onlyputBucketPolicyis PARTIAL (requiresPrincipal:"*"string form vs the AWS-canonical{"AWS":[…]}, a small normalization shim). The gateway ignores the credential region scope, so bothregion:'auto'(packages/openapi-sdk-service/src/sdk-storage.mjs) andregion:'us-east-1'(apps/control-plane/storage-handlers.mjs) work unchanged. Critically, the live runtime's hand-rolled regex XML parser (storage-handlers.mjs:76-97) is byte-compatible with SeaweedFS's list envelopes, including the"-encoded ETag quotes and theNextContinuationTokenpagination path — no parser change is required. - Operational fit — filer-on-PostgreSQL. SeaweedFS's
postgres2filer reuses the existing Postgres tier with a trivial(dirhash, name, directory, meta)schema and no extensions beyond the defaultplpgsql. It creates one table per bucket (physical metadata isolation that maps to a tenant/workspace boundary) and DROPs that table on bucket delete (no orphaned metadata — consistent with cascading tenant cleanup, ADR-10). The required correction is an explicitcreateTabletemplate (the default crashes the filer at boot). - Multitenancy — per-tenant identities. New tenant identities are added live through
s3.configure -applywith no gateway restart; per-bucketactions(Read:bkt/Write:bkt/List:bkt) enforce isolation (a scoped identity is403 AccessDeniedon another tenant's bucket). The SeaweedFS identity fields map directly onto the syntheticAKST…/sk_…credentials Falcone already derives inpackages/adapters/src/storage-programmatic-credentials.mjs; only the scope→actionstranslation and the injection call are net-new (todayprovisionWorkspaceStorageBoundaryis aNOT_YET_IMPLEMENTEDstub and the synthetic keys reach no backend).
Risks / caveats.
- Version divergence. All downstream changes are written against SeaweedFS 4.33 / the pinned digest; any upgrade must re-run the relevant compatibility-matrix cells before adoption.
- Filer-on-PostgreSQL schema coupling. Bucket name = PostgreSQL table name, so the filer's DB role needs CREATE/DROP TABLE at runtime and bucket names inherit PG identifier limits (63-byte). SeaweedFS creates these tables out-of-band from Falcone's managed migrations — mitigated by giving SeaweedFS a dedicated database (not the application DB).
- Bucket-policy shape.
putBucketPolicyneeds a Principal-normalization shim ({"AWS":[…]}→"*") in the SeaweedFS provider;getBucketPolicyround-trips for accepted policies. - STS / session tokens unconfigured. The gateway logs
Failed to load IAM configuration: no signing key found for STS serviceat boot; static access/secret-key auth (the path Falcone uses) is unaffected. Setjwt.filer_signing.keyonly if AssumeRole/STS is later required. - Extra surfaces. 4.33 also starts an Iceberg REST catalog on 8181; the deployment change should leave it unexposed.
Rejected alternatives.
- MinIO CE — AGPLv3 network-copyleft misfit for a tenant-facing S3 BaaS, console OIDC/SSO removed (May 2025), and the flagship repo archived (Feb 2026): not a sustainable bundled default.
- RustFS — alpha-maturity S3 reimplementation, not production-ready for the tenant data path.
- Ceph / Rook — operationally heavy (full distributed-storage operator) and disproportionate to Falcone's small-object S3 tier.
ADR-14 — Migrate document store from MongoDB to FerretDB v2 + DocumentDB
Findings. Version-pinned compatibility matrix (aggregation, indexes, transactions, change streams), the per-tenant tenancy spike, the colocated-vs-dedicated decision, and gap remediation paths with downstream owners — all grounded in a live instance (reproducible harness + raw captures recorded with the spike findings).
Runbook. Operational architecture, two-layer topology, pinned image pair + upgrade order, PostgreSQL extension prerequisites, the verified tenancy model, the known-compatibility-differences table, change-stream remediation, observability and licensing: FerretDB + DocumentDB Document-Store Runbook.
Decision. Replace the bundled MongoDB document store with FerretDB v2 — Apache-2.0, Linux Foundation governance — which speaks the MongoDB wire protocol over a PostgreSQL 17 + DocumentDB storage engine. Pin the migration to the version pair ghcr.io/ferretdb/ferretdb:2.7.0 (sha256:5706414241eb84f0515512c37b46db0f1b1eac9e5ceb7e4c2523211c184b1985) / ghcr.io/ferretdb/postgres-documentdb:17-0.107.0-ferretdb-2.7.0 (sha256:2386795ec2aa7ae559304361979f1dc5708d383ee9020ae63dadc2940dfe58f7). Run the document store on a dedicated Postgres instance (the postgres-documentdb image), not colocated on Falcone's existing relational Postgres, because pg_documentdb_core/pg_documentdb ship only in that image and require shared_preload_libraries (a server-start setting, not session-settable) — colocation would force replacing the relational bitnami/postgresql image for every tenant's RLS schemas. Reject Percona Server (SSPL), native-JSONB (not MongoDB-wire-compatible), ArangoDB (BSL), RavenDB (AGPL), and Couchbase (source-available).
Why.
- Licence misfit. MongoDB's SSPL is a source-available copyleft licence incompatible with an open-source BaaS that exposes the document store's wire protocol directly to tenants. FerretDB is Apache-2.0 under Linux Foundation governance, removing the licensing risk while preserving the MongoDB driver/wire contract Falcone's clients already use.
- Wire compatibility validated against Falcone's actual usage (live).
helloreportsmaxWireVersion:21(MongoDB 7.0 wire level),buildInfo.version 7.0.77,ferretdb v2.7.0. All 15 aggregation stages allowed bypackages/adapters/src/mongodb-data-api.mjs($match/$project/$sort/$limit/$skip/$group/$unwind/$lookup/$count/$facet/$addFields/$set/$unset/$replaceRoot/$replaceWith) are SUPPORTED;$group$sum/$avgover mixed int+double is exact; all five index types (single, compound, unique→E11000, sparse, TTL→purged in ~45s) are SUPPORTED. No PARTIAL aggregation/index entries. - Postgres leverage + AI-ready path. The engine is PostgreSQL 17.6 with the document data stored as rows in
documentdb_data, so Falcone reuses its Postgres operational tooling. The engine image bundlesvector 0.8.1(pgvector) alongsidepostgis 3.6.0andrum 1.3— an in-place vector-search path for tenant documents — and Falcone's schema-per-tenant RLS (non-BYPASSRLSfalcone_approle +app.tenant_idGUC) coexists cleanly with the DocumentDB extensions on the same instance (verified: RLS still enforces, no DDL conflict).
Evidence. The pinned image tags and digests for both ferretdb and postgres-documentdb are recorded above. Upgrade order: start the DocumentDB engine first, then the FerretDB gateway — the pg_documentdb libraries are preloaded via shared_preload_libraries and the documentdb_api schema must exist before the gateway's first wire handshake, so gateway-first fails. Engine GUCs observed: shared_preload_libraries=pg_cron,pg_documentdb_core,pg_documentdb, cron.database_name=postgres, wal_level=logical (the enabling GUC for the logical-replication realtime path). The full per-feature matrix (aggregation task 2, indexes task 3, transaction gap task 4, change-stream gap task 5, tenancy spike task 7, colocated-vs-dedicated task 8), its remediation paths, the reproducible harness (compose.yaml, probes probe-main.mjs/probe-refine.mjs/probe-tenancy.mjs/probe-ttl.mjs) and raw captures are in the findings file linked above; code touchpoints packages/adapters/src/mongodb-data-api.mjs (applyTenantScopeToFilter/injectTenantIntoDocument, AGGREGATION_BLOCKED_STAGES), apps/control-plane-executor/src/runtime/mongo-data-executor.mjs, apps/control-plane-executor/src/runtime/realtime-executor.mjs (#454).
Risks / caveats.
- Change streams are UNSUPPORTED — highest severity.
collection.watch()returnsCommandNotSupported (115)($changeStream is not supported yet) andcollMod changeStreamPreAndPostImagesreturnsUnknownBsonField (40415). This blocks bothapps/control-plane-executor/src/runtime/realtime-executor.mjsandpackages/mongo-cdc-bridge/src/ChangeStreamWatcher.mjs. Remediation is re-architect via a Postgres logical replication slot +pgoutputon thedocumentdb_datatables, withREPLICA IDENTITY FULLsubstituting for pre-images (needed to tenant-scope deletes); no native-wire shim exists at this version pair — escalated to the realtime/CDC children. - Multi-document transactions are UNSUPPORTED.
commitTransactionreturnsCommandNotFound (59)and the in-flight write persists with no atomicity;abortTransactionis a silent no-op that does not roll back. Themongodb-data-api.mjstransaction op (declaredsnapshot/majority) must be shimmed to single-operation semantics (DocumentDB guarantees single-doc atomicity) and reject true multi-doc atomic requests at the API boundary, or be dropped. - Per-database role isolation is NOT a boundary. A user scoped
readWriteon one tenant database successfully read another tenant's database; DocumentDB 0.107 does not enforce per-database role scoping. Falcone's application-layertenantIdscoping (applyTenantScopeToFilter/injectTenantIntoDocument) remains the authoritative isolation mechanism; hard DB-level isolation requires a dedicated DocumentDB database/instance per tenant tier. - Aggregation policy nuance.
$out,$merge, and$geoNearare actually accepted by the engine (not rejected as assumed); Falcone keeps blocking them via the adapter allowlist (AGGREGATION_BLOCKED_STAGES) — no engine-side guarantee to rely on. - Version divergence. Every downstream child is coded to 2.7.0 / 0.107; any upgrade must re-run the affected matrix cells before adoption.
- Colocated-Postgres extension coupling.
shared_preload_librariescannot be patched per-session, which is exactly why the dedicated instance is chosen; revisiting colocation would re-introduce a whole-instance restart/image change.
Rejected alternatives.
- Percona Server for MongoDB — distributed under SSPL, the same source-available copyleft that makes stock MongoDB a misfit; swapping vendors does not change the licence problem.
- Native PostgreSQL JSONB — would drop the MongoDB wire protocol, breaking every existing tenant MongoDB driver and the
mongodb-data-api.mjscontract; a client-rewrite, not a migration. - ArangoDB — core is BSL (Business Source License), a non-open delayed-source licence that fails the open-source BaaS bar.
- RavenDB — AGPLv3 network-copyleft, a legal misfit for a document store exposed directly to tenants (same class of problem as MinIO's AGPL in ADR-13).
- Couchbase — source-available (BSL-style) rather than OSI open-source, and not MongoDB-wire-compatible, so it offers neither the licence nor the drop-in-driver property required.