diff --git a/.changeset/mongodb-single-tenant-boot-guard.md b/.changeset/mongodb-single-tenant-boot-guard.md
new file mode 100644
index 0000000000..222246dadb
--- /dev/null
+++ b/.changeset/mongodb-single-tenant-boot-guard.md
@@ -0,0 +1,48 @@
+---
+"@objectstack/driver-mongodb": minor
+"@objectstack/cli": patch
+---
+
+feat(driver-mongodb)!: declare the driver single-tenant and refuse to boot multi-tenant (#3724)
+
+`MongoDBDriver` implements **no row-level tenant isolation** — it never reads
+`DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
+stamped with a tenant column. The layer the SQL driver has (`resolveTenantField`
++ `applyTenantScope`) simply does not exist here, while everything above the
+driver — object metadata's `tenancy` block, `applySystemFields` injecting
+`organization_id`, the engine threading `tenantId` into every driver call —
+operates on the assumption that tenant isolation is a platform guarantee. Point
+a multi-tenant deployment's datasource at Mongo and every query read, updated
+and deleted other tenants' documents, silently.
+
+Rather than serve unisolated, the driver now fails fast at startup:
+
+- The **constructor** and `connect()` call `assertSingleTenantPosture()`, which
+ refuses any tenancy posture other than `single` (`OS_TENANCY_POSTURE=group` /
+ `isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true`),
+ resolved through the shared `resolveTenancyPosture()` so the driver can never
+ disagree with auth / the registry / the CLI about the mode. The check sits in
+ the constructor and not only in `connect()` because `ObjectQLEngine.init()`
+ *catches* a driver's connect rejection and boots anyway — a connect-only guard
+ would have degraded "refuses to start" into "starts, then throws at query
+ time". `connect()` re-checks in case a host flips the posture in between.
+- `syncSchema()` / `syncSchemasBatch()` call `assertObjectsNotTenantScoped()` and
+ refuse objects declaring `tenancy.enabled: true`, naming every offender in one
+ message.
+- `objectstack serve` / `dev` (CLI) now re-throw this error out of the
+ auto-driver-registration block instead of swallowing it, so boot exits 1 with
+ the actionable message — the same treatment `UnsupportedDriverError` already
+ gets. Matched duck-typed by `code`, so the CLI takes no dependency on the
+ driver package.
+
+Both throw `MongoDBMultiTenantUnsupportedError` with
+`code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected
+signal, the remedy, and `@objectstack/driver-sql` as the multi-tenant option.
+
+There is deliberately **no override env var**: an escape hatch would restore
+exactly the silent non-isolation this guard removes. Single-tenant deployments —
+every currently-working Mongo deployment — are unaffected.
+
+This is option B of #3724. Implementing real row-level isolation (option A)
+remains open; the `unique` index shape stays single-field until then, which is
+now correct by construction rather than by omission.
diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx
index 1819f57c97..6b1d7cbe9b 100644
--- a/content/docs/data-modeling/drivers.mdx
+++ b/content/docs/data-modeling/drivers.mdx
@@ -62,7 +62,7 @@ actually connect to Turso.
| **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` |
| **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` |
| **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` |
-| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` |
+| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
| **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | `memory` |
> All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single
@@ -139,6 +139,28 @@ The CLI infers the MongoDB driver from the `mongodb://` (or `mongodb+srv://`)
URL scheme automatically — no `OS_DATABASE_DRIVER` needed. Set
`OS_DATABASE_DRIVER=mongodb` only if you want to be explicit.
+### Multi-tenancy: not supported
+
+
+The MongoDB driver is **single-tenant only**. It implements no row-level tenant
+isolation — unlike `SqlDriver`, it ignores `DriverOptions.tenantId`, so reads
+carry no tenant predicate and writes are not stamped with a tenant column.
+
+
+Rather than serve a multi-tenant deployment without isolation, the driver
+**refuses to start** in one ([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)):
+
+| Signal | Checked in | Result |
+| :--- | :--- | :--- |
+| Tenancy posture is not `single` — `OS_TENANCY_POSTURE=group`/`isolated`, or derived from `OS_MULTI_ORG_ENABLED=true` | `new MongoDBDriver()`, re-checked in `connect()` | throws `MongoDBMultiTenantUnsupportedError`; `objectstack serve` exits 1 |
+| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object |
+
+The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`. There is no
+override flag — one would restore exactly the silent cross-tenant access the
+guard prevents. For multi-tenant deployments use `@objectstack/driver-sql`
+(PostgreSQL / MySQL / SQLite), which enforces tenant scoping at the driver
+level.
+
## SQLite (via `@objectstack/driver-sql`)
SQLite is ideal for local-first development and embedded applications.
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index 4d11e9eb81..7331466bc7 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -32,7 +32,7 @@ workable default:
| Variable | Why it must be set |
|:---|:---|
-| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** supported by the open framework — that driver ships in ObjectStack Cloud). |
+| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mongodb://…`, or a mounted `file:…` path (`libsql://` / Turso is **not** supported by the open framework — that driver ships in ObjectStack Cloud). `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
| `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `/api/v1/auth/*` is **silently skipped** — the server runs unauthenticated. |
| `OS_SECRET_KEY` | 32-byte master key encrypting every stored secret (`openssl rand -hex 32`). On a container's ephemeral filesystem the auto-minted key is **lost on restart**, making previously-encrypted secrets undecryptable. |
| `OS_PORT` | `os start` **fails loudly** if the port is busy (it never auto-shifts like `os dev`). Pin it and keep your reverse-proxy upstream in sync. |
diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx
index 1ae906ed16..94a676beac 100644
--- a/content/docs/plugins/packages.mdx
+++ b/content/docs/plugins/packages.mdx
@@ -185,7 +185,8 @@ const driver = new SqliteWasmDriver({ filename: ':memory:' });
**MongoDB Driver** — Document-oriented storage backend.
- **Purpose**: Native MongoDB driver for ObjectQL with document-flavored objects
-- **When to use**: Existing MongoDB infrastructure, document-shaped data
+- **When to use**: Existing MongoDB infrastructure, document-shaped data — **single-tenant deployments only**
+- **Not supported**: row-level tenant isolation. The driver refuses to boot when the tenancy posture is not `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported)
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/driver-mongodb/README.md)
---
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index bf2be31696..eef867ff08 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -939,6 +939,14 @@ export default class Serve extends Command {
// actionable message, and exits 1 (in dev AND prod). All OTHER driver
// construction errors keep the prior best-effort silent behavior.
if (e instanceof UnsupportedDriverError) throw e;
+ // Same class of fatal (#3724): a driver that refuses to run in this
+ // deployment's tenancy mode — driver-mongodb has no row-level tenant
+ // isolation and rejects a non-`single` posture. Swallowing it would
+ // boot the server with NO driver at all, burying "your database
+ // cannot isolate tenants" under a later, unrelated failure. Matched
+ // by `code` (duck-typed) so the CLI keeps no dependency on the
+ // driver package and cross-realm `instanceof` can't bite.
+ if (e?.code === 'MONGODB_MULTI_TENANT_UNSUPPORTED') throw e;
// silent
}
}
diff --git a/packages/plugins/driver-mongodb/README.md b/packages/plugins/driver-mongodb/README.md
index 6f5a643ffa..a59598376c 100644
--- a/packages/plugins/driver-mongodb/README.md
+++ b/packages/plugins/driver-mongodb/README.md
@@ -2,6 +2,16 @@
MongoDB driver for ObjectStack — native document database support via the official MongoDB Node.js driver.
+> ### ⚠️ Single-tenant only
+>
+> This driver has **no row-level tenant isolation**: it ignores
+> `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not
+> stamped with a tenant column. Rather than serve a multi-tenant deployment
+> without isolation, it **refuses to start** in one — see
+> [Multi-tenancy](#multi-tenancy) below. Use
+> [`@objectstack/driver-sql`](../driver-sql) (PostgreSQL / MySQL / SQLite) for
+> multi-tenant deployments.
+
## Installation
```bash
@@ -66,6 +76,39 @@ export default defineStack({
| **Advanced** | Full-text search, JSON queries, geospatial | ✅ |
| **Joins** | Cross-collection joins ($lookup) | ❌ |
| **Window Functions** | ROW_NUMBER, RANK, etc. | ❌ |
+| **Multi-tenancy** | Row-level tenant isolation | ❌ (boots single-tenant only) |
+
+### Multi-tenancy
+
+The driver is a **single-tenant / embedded** driver. Unlike
+`@objectstack/driver-sql` — which resolves a tenant column per object, injects a
+`WHERE tenant_field = ?` predicate on reads and stamps that column on writes —
+this driver implements none of that layer. In a multi-tenant deployment every
+query would read, update and delete other tenants' documents.
+
+So instead of running unisolated, it fails fast at startup
+([#3724](https://github.com/objectstack-ai/objectstack/issues/3724)):
+
+| Signal | Where it is checked | Result |
+|--------|--------------------|--------|
+| Tenancy posture is not `single` — `OS_TENANCY_POSTURE=group\|isolated`, or derived from `OS_MULTI_ORG_ENABLED=true` | `new MongoDBDriver()`, re-checked in `connect()` before a socket is opened | throws `MongoDBMultiTenantUnsupportedError` |
+| An object declares `tenancy.enabled: true` | `syncSchema()` / `syncSchemasBatch()` | throws, naming every offending object |
+
+The error carries `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'` so a host can
+recognise it without matching on the message:
+
+```typescript
+import {
+ MongoDBMultiTenantUnsupportedError,
+ MULTI_TENANT_UNSUPPORTED_CODE,
+} from '@objectstack/driver-mongodb';
+```
+
+There is deliberately **no override flag** — one would restore exactly the
+silent cross-tenant access the guard exists to prevent. To run MongoDB, keep the
+deployment single-tenant (`OS_TENANCY_POSTURE=single` or unset, `OS_MULTI_ORG_ENABLED`
+unset or `false`, no object declaring `tenancy.enabled: true`); to go
+multi-tenant, switch to `@objectstack/driver-sql`.
### ID Handling
diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json
index 4a8ea50799..409123d743 100644
--- a/packages/plugins/driver-mongodb/package.json
+++ b/packages/plugins/driver-mongodb/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*",
+ "@objectstack/types": "workspace:*",
"mongodb": "^7.5.0",
"nanoid": "^6.0.0"
},
diff --git a/packages/plugins/driver-mongodb/src/index.ts b/packages/plugins/driver-mongodb/src/index.ts
index 15f0122bfd..06b9cc2890 100644
--- a/packages/plugins/driver-mongodb/src/index.ts
+++ b/packages/plugins/driver-mongodb/src/index.ts
@@ -7,6 +7,13 @@ export type { MongoDBDriverConfig } from './mongodb-driver.js';
export { translateFilter } from './mongodb-filter.js';
export { buildAggregationPipeline, postProcessAggregation } from './mongodb-aggregation.js';
export type { AggregationInput } from './mongodb-aggregation.js';
+export {
+ MongoDBMultiTenantUnsupportedError,
+ MULTI_TENANT_UNSUPPORTED_CODE,
+ assertSingleTenantPosture,
+ assertObjectsNotTenantScoped,
+ declaresTenantScope,
+} from './mongodb-tenancy-guard.js';
export default {
id: 'com.objectstack.driver.mongodb',
diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.ts
index 67ecc4338a..b85d0ed48e 100644
--- a/packages/plugins/driver-mongodb/src/mongodb-driver.ts
+++ b/packages/plugins/driver-mongodb/src/mongodb-driver.ts
@@ -27,6 +27,10 @@ import {
postProcessAggregation,
} from './mongodb-aggregation.js';
import { syncCollectionSchema, dropCollection } from './mongodb-schema.js';
+import {
+ assertSingleTenantPosture,
+ assertObjectsNotTenantScoped,
+} from './mongodb-tenancy-guard.js';
const DEFAULT_ID_LENGTH = 16;
@@ -59,6 +63,14 @@ export interface MongoDBDriverConfig {
*
* Implements the IDataDriver contract via the official MongoDB driver.
* Uses native MongoDB queries, aggregation pipelines, and transactions.
+ *
+ * **Single-tenant only (#3724).** This driver implements no row-level tenant
+ * isolation — it ignores `DriverOptions.tenantId`, so reads carry no tenant
+ * predicate and writes are not stamped with a tenant column. Rather than serve
+ * a multi-tenant deployment unisolated, it **refuses to start** in one: see
+ * `mongodb-tenancy-guard.ts`, wired into the constructor + {@link connect}
+ * (deployment posture) and {@link syncSchema} / {@link syncSchemasBatch}
+ * (object metadata).
*/
export class MongoDBDriver implements IDataDriver {
public readonly name: string = 'com.objectstack.driver.mongodb';
@@ -116,6 +128,14 @@ export class MongoDBDriver implements IDataDriver {
private config: MongoDBDriverConfig;
constructor(config: MongoDBDriverConfig) {
+ // Refuse to even EXIST in a multi-tenant deployment (#3724). The check is
+ // repeated in `connect()`, but construction is the only seam guaranteed to
+ // fail loudly: `ObjectQLEngine.init()` catches a driver's connect rejection
+ // and logs it, then boots anyway ("may recover via lazy reconnection"), so
+ // a connect-only guard would degrade from "refuses to start" to "starts,
+ // then throws at query time".
+ assertSingleTenantPosture();
+
this.config = config;
const clientOptions: MongoClientOptions = {
maxPoolSize: config.maxPoolSize ?? 10,
@@ -132,6 +152,12 @@ export class MongoDBDriver implements IDataDriver {
// ===========================================================================
async connect(): Promise {
+ // Fail before a socket is opened: this driver cannot isolate tenants, so a
+ // multi-tenant deployment must never get a usable connection out of it.
+ // Re-checked here (not just in the constructor) because a host may flip the
+ // posture between construction and boot.
+ assertSingleTenantPosture();
+
await this.client.connect();
const dbName = this.config.database || this.extractDatabaseName(this.config.url);
this.db = this.client.db(dbName);
@@ -498,11 +524,19 @@ export class MongoDBDriver implements IDataDriver {
// ===========================================================================
async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise {
+ // An object asking for row-level tenant isolation gets none here (#3724) —
+ // refuse rather than materialise a collection that silently mixes tenants.
+ assertObjectsNotTenantScoped([{ object, schema }]);
+
const objectDef = schema as { name: string; fields?: Record };
await syncCollectionSchema(this.db, object, objectDef);
}
async syncSchemasBatch(schemas: Array<{ object: string; schema: unknown }>, options?: DriverOptions): Promise {
+ // Pre-scan so the failure names every tenant-scoped object at once instead
+ // of surfacing them one boot at a time.
+ assertObjectsNotTenantScoped(schemas);
+
for (const { object, schema } of schemas) {
await this.syncSchema(object, schema, options);
}
diff --git a/packages/plugins/driver-mongodb/src/mongodb-schema.ts b/packages/plugins/driver-mongodb/src/mongodb-schema.ts
index c526468a7c..0f1a7a2e97 100644
--- a/packages/plugins/driver-mongodb/src/mongodb-schema.ts
+++ b/packages/plugins/driver-mongodb/src/mongodb-schema.ts
@@ -23,8 +23,14 @@ interface FieldDef {
* tenant stamp on write), so there is no tenant column to compose with. A
* `(tenant, field)` index would advertise an isolation this driver does not
* deliver — worse than the single-field index, because it would read as
- * fixed. The tenancy gap itself is tracked separately; when it lands, this
- * must adopt the same scoping rule as `SqlDriver.uniqueIndexesFromFields`.
+ * fixed.
+ *
+ * Settled by #3724: the driver is now explicitly single-tenant and refuses to
+ * boot into a multi-tenant deployment (see `mongodb-tenancy-guard.ts`), so a
+ * single-field unique index is exactly right — there is no second tenant for
+ * it to over-constrain. Should row-level tenancy ever land here (option A of
+ * that issue), this must adopt the same scoping rule as
+ * `SqlDriver.uniqueIndexesFromFields`.
*/
unique?: boolean | 'global';
indexed?: boolean;
diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts
new file mode 100644
index 0000000000..f9ea6990dd
--- /dev/null
+++ b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.test.ts
@@ -0,0 +1,187 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Multi-tenancy boot guard (#3724).
+ *
+ * These tests need no MongoDB binary: the guard is pure (env flag + object
+ * metadata), and the driver-level assertions exercise `connect()` / schema sync
+ * on an unreachable URL — the refusal must happen *before* any I/O.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ assertSingleTenantPosture,
+ assertObjectsNotTenantScoped,
+ declaresTenantScope,
+ MongoDBMultiTenantUnsupportedError,
+ MULTI_TENANT_UNSUPPORTED_CODE,
+} from './mongodb-tenancy-guard.js';
+import { MongoDBDriver } from './mongodb-driver.js';
+
+const ORIGINAL_MULTI_ORG = process.env.OS_MULTI_ORG_ENABLED;
+const ORIGINAL_POSTURE = process.env.OS_TENANCY_POSTURE;
+
+/** A URL nothing listens on — reaching the network at all fails these tests. */
+const UNREACHABLE_URL = 'mongodb://127.0.0.1:1/never_connected';
+
+function makeDriver() {
+ return new MongoDBDriver({
+ url: UNREACHABLE_URL,
+ database: 'guard_test',
+ serverSelectionTimeoutMS: 50,
+ connectTimeoutMS: 50,
+ });
+}
+
+describe('multi-tenancy boot guard (#3724)', () => {
+ beforeEach(() => {
+ delete process.env.OS_MULTI_ORG_ENABLED;
+ delete process.env.OS_TENANCY_POSTURE;
+ });
+
+ afterEach(() => {
+ if (ORIGINAL_MULTI_ORG === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
+ else process.env.OS_MULTI_ORG_ENABLED = ORIGINAL_MULTI_ORG;
+ if (ORIGINAL_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE;
+ else process.env.OS_TENANCY_POSTURE = ORIGINAL_POSTURE;
+ });
+
+ describe('assertSingleTenantPosture', () => {
+ it('passes when nothing is configured (posture derives to `single`)', () => {
+ expect(() => assertSingleTenantPosture()).not.toThrow();
+ });
+
+ it('passes when OS_MULTI_ORG_ENABLED is explicitly false', () => {
+ process.env.OS_MULTI_ORG_ENABLED = 'false';
+ expect(() => assertSingleTenantPosture()).not.toThrow();
+ });
+
+ it('passes for an explicit single posture', () => {
+ process.env.OS_TENANCY_POSTURE = 'single';
+ expect(() => assertSingleTenantPosture()).not.toThrow();
+ });
+
+ it('throws a coded error when multi-org mode is on', () => {
+ process.env.OS_MULTI_ORG_ENABLED = 'true';
+ try {
+ assertSingleTenantPosture();
+ expect.unreachable('expected the guard to throw');
+ } catch (err) {
+ expect(err).toBeInstanceOf(MongoDBMultiTenantUnsupportedError);
+ expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
+ // The message must name the knobs and the escape route, not just fail.
+ expect((err as Error).message).toContain('OS_MULTI_ORG_ENABLED');
+ expect((err as Error).message).toContain('OS_TENANCY_POSTURE');
+ expect((err as Error).message).toContain('@objectstack/driver-sql');
+ expect((err as Error).message).toContain('3724');
+ }
+ });
+
+ it('treats any non-`false` value as enabled (matches resolveMultiOrgEnabled)', () => {
+ process.env.OS_MULTI_ORG_ENABLED = '1';
+ expect(() => assertSingleTenantPosture()).toThrow(MongoDBMultiTenantUnsupportedError);
+ });
+
+ // OS_TENANCY_POSTURE (ADR-0105 D1) supersedes the boolean — BOTH walled
+ // postures need an organization wall this driver cannot draw.
+ it.each(['isolated', 'group', 'multi'])(
+ 'throws for the `%s` posture even with OS_MULTI_ORG_ENABLED unset',
+ (posture) => {
+ process.env.OS_TENANCY_POSTURE = posture;
+ const err = (() => {
+ try {
+ assertSingleTenantPosture();
+ return null;
+ } catch (e) {
+ return e;
+ }
+ })();
+ expect(err).toBeInstanceOf(MongoDBMultiTenantUnsupportedError);
+ // `multi` is the legacy alias, normalized to `isolated` by the resolver.
+ expect((err as Error).message).toContain(posture === 'multi' ? 'isolated' : posture);
+ },
+ );
+ });
+
+ describe('declaresTenantScope', () => {
+ it('is true only for an explicit tenancy.enabled === true', () => {
+ expect(declaresTenantScope({ name: 'task', tenancy: { enabled: true } })).toBe(true);
+ expect(declaresTenantScope({ name: 'task', tenancy: { enabled: false } })).toBe(false);
+ expect(declaresTenantScope({ name: 'task', tenancy: {} })).toBe(false);
+ expect(declaresTenantScope({ name: 'task' })).toBe(false);
+ expect(declaresTenantScope(null)).toBe(false);
+ expect(declaresTenantScope(undefined)).toBe(false);
+ });
+ });
+
+ describe('assertObjectsNotTenantScoped', () => {
+ it('passes for objects that do not declare tenancy', () => {
+ expect(() =>
+ assertObjectsNotTenantScoped([
+ { object: 'task', schema: { name: 'task' } },
+ { object: 'sys_license', schema: { name: 'sys_license', tenancy: { enabled: false } } },
+ ]),
+ ).not.toThrow();
+ });
+
+ it('names every offending object in a single message', () => {
+ try {
+ assertObjectsNotTenantScoped([
+ { object: 'task', schema: { name: 'task' } },
+ { object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
+ { object: 'contact', schema: { name: 'contact', tenancy: { enabled: true } } },
+ ]);
+ expect.unreachable('expected the guard to throw');
+ } catch (err) {
+ expect((err as any).code).toBe(MULTI_TENANT_UNSUPPORTED_CODE);
+ const message = (err as Error).message;
+ expect(message).toContain('`account`');
+ expect(message).toContain('`contact`');
+ expect(message).not.toContain('`task`');
+ }
+ });
+ });
+
+ describe('MongoDBDriver wiring', () => {
+ it('the constructor refuses in multi-tenant mode', () => {
+ process.env.OS_MULTI_ORG_ENABLED = 'true';
+ // Construction is the seam that fails loudly: ObjectQLEngine.init()
+ // catches a connect() rejection and boots anyway.
+ expect(() => makeDriver()).toThrow(MongoDBMultiTenantUnsupportedError);
+ });
+
+ it('connect() refuses without opening a connection when the posture flips after construction', async () => {
+ const driver = makeDriver(); // built single-tenant
+ process.env.OS_TENANCY_POSTURE = 'isolated';
+ // An unreachable URL would surface a MongoServerSelectionError if the
+ // guard let the connection attempt through — the coded error proves the
+ // refusal happens first.
+ await expect(driver.connect()).rejects.toThrow(MongoDBMultiTenantUnsupportedError);
+ });
+
+ it('connect() is not gated when the deployment is single-tenant', async () => {
+ const driver = makeDriver();
+ // Still fails — the server is unreachable — but NOT with the tenancy guard.
+ await expect(driver.connect()).rejects.not.toBeInstanceOf(
+ MongoDBMultiTenantUnsupportedError,
+ );
+ });
+
+ it('syncSchema() refuses a tenant-scoped object before touching the db', async () => {
+ const driver = makeDriver();
+ await expect(
+ driver.syncSchema('account', { name: 'account', tenancy: { enabled: true } }),
+ ).rejects.toThrow(MongoDBMultiTenantUnsupportedError);
+ });
+
+ it('syncSchemasBatch() refuses when any object is tenant-scoped', async () => {
+ const driver = makeDriver();
+ await expect(
+ driver.syncSchemasBatch([
+ { object: 'task', schema: { name: 'task' } },
+ { object: 'account', schema: { name: 'account', tenancy: { enabled: true } } },
+ ]),
+ ).rejects.toThrow(MongoDBMultiTenantUnsupportedError);
+ });
+ });
+});
diff --git a/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts
new file mode 100644
index 0000000000..233c0a9309
--- /dev/null
+++ b/packages/plugins/driver-mongodb/src/mongodb-tenancy-guard.ts
@@ -0,0 +1,139 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * MongoDB Driver — multi-tenancy boot guard (#3724)
+ *
+ * This driver implements **no row-level tenant isolation**: it never reads
+ * `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are
+ * never stamped with a tenant column. The SQL driver's `resolveTenantField()`
+ * + `applyTenantScope()` layer simply does not exist here.
+ *
+ * The platform above the driver assumes tenant isolation is a *platform*
+ * guarantee (object metadata's `tenancy` block, `applySystemFields` injecting
+ * `organization_id`, the engine threading `tenantId` into every driver call).
+ * Booting this driver into a multi-tenant deployment therefore produces
+ * **silent** cross-tenant reads, updates and deletes — the exact
+ * "declared ≠ enforced" shape Prime Directive #10 forbids.
+ *
+ * So the driver refuses to run there. It is positioned as a **single-tenant /
+ * embedded** driver and fails fast — loudly, at startup — the moment it detects
+ * multi-tenant mode:
+ *
+ * 1. The deployment's tenancy posture is not `single` (deployment-level signal)
+ * → {@link assertSingleTenantPosture}, called from the `MongoDBDriver`
+ * **constructor** and re-checked in `connect()` before a socket is opened.
+ * The constructor is the seam that fails loudly: `ObjectQLEngine.init()`
+ * catches a driver's connect rejection and boots anyway, so a connect-only
+ * guard would degrade into a query-time failure.
+ * 2. An object declares `tenancy.enabled: true` (metadata-level signal) →
+ * {@link assertObjectsNotTenantScoped}, called from the `syncSchema` /
+ * `syncSchemasBatch` paths.
+ *
+ * There is deliberately **no escape-hatch env var**: an override would restore
+ * exactly the silent non-isolation this guard exists to remove. Multi-tenant
+ * deployments use `@objectstack/driver-sql`, which implements driver-level
+ * tenant scoping. When real demand for Mongo multi-tenancy appears, the fix is
+ * to implement the isolation (option A in #3724) — not to weaken this gate.
+ */
+
+import { resolveTenancyPosture } from '@objectstack/types';
+
+/** Stable, matchable error code for the boot refusal. */
+export const MULTI_TENANT_UNSUPPORTED_CODE = 'MONGODB_MULTI_TENANT_UNSUPPORTED';
+
+const ISSUE_URL = 'https://github.com/objectstack-ai/objectstack/issues/3724';
+
+/**
+ * Thrown when the MongoDB driver is asked to run in a multi-tenant deployment.
+ *
+ * Carries {@link MULTI_TENANT_UNSUPPORTED_CODE} as `code` so hosts (CLI boot,
+ * runtime plugin loader, tests) can recognise it without string-matching the
+ * message or relying on cross-realm `instanceof`.
+ */
+export class MongoDBMultiTenantUnsupportedError extends Error {
+ public readonly code = MULTI_TENANT_UNSUPPORTED_CODE;
+
+ constructor(detected: string, remedy: string) {
+ super(
+ `[driver-mongodb] Refusing to start: this driver has NO row-level tenant isolation.\n` +
+ `\n` +
+ ` Detected: ${detected}\n` +
+ `\n` +
+ ` MongoDBDriver never reads \`DriverOptions.tenantId\` — reads carry no tenant\n` +
+ ` predicate and writes are not stamped with a tenant column, so queries would\n` +
+ ` read, update and delete OTHER tenants' documents. Rather than run unisolated,\n` +
+ ` the driver fails at startup.\n` +
+ `\n` +
+ ` Fix one of:\n` +
+ ` • Use @objectstack/driver-sql (PostgreSQL / MySQL / SQLite) for multi-tenant\n` +
+ ` deployments — it enforces tenant scoping at the driver level.\n` +
+ ` ${remedy}\n` +
+ `\n` +
+ ` Tracking: ${ISSUE_URL}`,
+ );
+ this.name = 'MongoDBMultiTenantUnsupportedError';
+ }
+}
+
+/** Minimal shape of an object definition this guard inspects. */
+export interface TenancyAwareSchema {
+ tenancy?: { enabled?: boolean } | null;
+}
+
+/**
+ * Whether an object definition asks for row-level tenant isolation.
+ *
+ * Only an **explicit** `tenancy.enabled === true` counts. An absent `tenancy`
+ * block is not treated as a multi-tenant signal here: platform-wide tenant
+ * scoping is driven by the deployment flag (checked separately by
+ * {@link assertMultiOrgDisabled}), and every object in a single-tenant
+ * deployment omits the block.
+ */
+export function declaresTenantScope(schema: unknown): boolean {
+ return (schema as TenancyAwareSchema | null | undefined)?.tenancy?.enabled === true;
+}
+
+/**
+ * Refuse to run unless the deployment's tenancy posture is `single`.
+ *
+ * Reads the posture through the shared `resolveTenancyPosture()` resolver
+ * (ADR-0105 D1) — the canonical knob, which also subsumes the legacy
+ * `OS_MULTI_ORG_ENABLED` boolean — so the driver, auth, the registry and the
+ * CLI can never disagree about the mode. Both walled postures (`group` and
+ * `isolated`) need an organization wall this driver cannot draw, so both are
+ * refused; only `single` passes.
+ */
+export function assertSingleTenantPosture(): void {
+ const posture = resolveTenancyPosture();
+ if (posture === 'single') return;
+ throw new MongoDBMultiTenantUnsupportedError(
+ `tenancy posture \`${posture}\` — a multi-tenant deployment ` +
+ '(from `OS_TENANCY_POSTURE`, or derived from `OS_MULTI_ORG_ENABLED`)',
+ '• Run this deployment single-tenant: `OS_TENANCY_POSTURE=single` (and unset\n' +
+ ' `OS_MULTI_ORG_ENABLED`, or set it to `false`).',
+ );
+}
+
+/**
+ * Refuse to sync object schemas that declare row-level tenant isolation.
+ *
+ * Reports **every** offending object in one message so an operator fixes the
+ * whole set in one pass instead of rediscovering them one boot at a time.
+ */
+export function assertObjectsNotTenantScoped(
+ schemas: Array<{ object: string; schema: unknown }>,
+): void {
+ const offenders = schemas
+ .filter(({ schema }) => declaresTenantScope(schema))
+ .map(({ object }) => object);
+
+ if (offenders.length === 0) return;
+
+ const list = offenders.map((name) => `\`${name}\``).join(', ');
+ throw new MongoDBMultiTenantUnsupportedError(
+ `object${offenders.length > 1 ? 's' : ''} declaring \`tenancy.enabled: true\`: ${list}`,
+ '• Drop the `tenancy` block from ' +
+ (offenders.length > 1 ? 'these objects' : 'this object') +
+ ' if the data is genuinely single-tenant.',
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 13d1025287..38c1ab908a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1076,6 +1076,9 @@ importers:
'@objectstack/spec':
specifier: workspace:*
version: link:../../spec
+ '@objectstack/types':
+ specifier: workspace:*
+ version: link:../../types
mongodb:
specifier: ^7.5.0
version: 7.5.0(socks@2.8.9)