diff --git a/.changeset/mongodb-refuse-rejected-reference-to-alias.md b/.changeset/mongodb-refuse-rejected-reference-to-alias.md
new file mode 100644
index 0000000000..6a3ec93e33
--- /dev/null
+++ b/.changeset/mongodb-refuse-rejected-reference-to-alias.md
@@ -0,0 +1,57 @@
+---
+"@objectstack/driver-mongodb": minor
+---
+
+fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)
+
+`syncCollectionSchema` gated its field-level join index on `field.reference_to`.
+`reference` is the only relationship spelling `@objectstack/spec` declares —
+`reference_to` is a **rejected alias**, answered by `FieldSchema` with
+`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
+key had two doors with opposite answers, and the silent one was the one that
+touched the database.
+
+A field still carrying `reference_to` when it reaches schema sync now throws
+`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
+words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
+ahead of every per-field branch, because the spec's verdict is gated on neither
+the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
+refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
+alike. One key, one answer, on both doors — this is the same door
+`@objectstack/driver-sql` grew in #11567.
+
+**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
+which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
+half `patch` on "no authored deployment could reach the branch". That reasoning
+does **not** transfer here: this package's own published `README.md` taught
+`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —
+
+```typescript
+company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
+```
+
+— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
+**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
+in the package's `files` array, so it shipped to npm at
+`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
+that copied that sample boots today and, after this release, is refused at the
+schema-sync door. The affected population is therefore non-zero **by
+construction**, not by speculation — and it is not measurable from inside this
+repo. There is deliberately **no deprecation window**: a warn-and-continue
+release would be a third answer to a key the schema has always refused.
+
+Fix, if you have such metadata — the same rename the schema has always asked for:
+
+| Wrote | Write instead |
+|---|---|
+| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |
+
+The README no longer teaches the key; its remaining mention is prose recording
+that the spelling is refused.
+
+**What this does NOT change.** No index is added, removed or renamed. A `user`
+field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
+still gets none. Renaming the key therefore does not, by itself, produce a join
+index — whether it should is a separate open question, because starting to build
+that index changes boot behaviour for deployments already holding large
+collections. It is tracked apart from this release on purpose.
diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx
index 9a58524b20..a79d8c84dd 100644
--- a/content/docs/protocol/objectql/types.mdx
+++ b/content/docs/protocol/objectql/types.mdx
@@ -731,11 +731,26 @@ contacts:
A relationship field authored with `reference:` gets **no database-level
-`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
-property that the spec's `reference` never populates, and `master_detail` /
-`tree` do not reach that branch at all. Referential integrity is enforced by the
-**engine** instead: `deleteBehavior` is applied on delete, which is what produces
-the `409 DELETE_RESTRICTED` above.
+`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
+root cause: the driver branches that would have built them were gated on
+`reference_to` — a key the spec REFUSES (`FieldSchema` answers
+`unrecognized_keys` for it, on any field type) and one that `reference` never
+populates. `master_detail` / `tree` did not reach either branch at all.
+
+- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
+ `reference_to` when it reaches DDL is refused at the driver's door, in the
+ schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
+ physical schema.
+- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
+ same refused key, so a canonically-spelled `reference` lookup is **not
+ indexed**. A `user` field still is — that arm needs no relationship key, which
+ is why the feature looked healthy. `reference_to` is refused at this driver's
+ door too, with the same verdict (#13222); whether a canonical `reference`
+ lookup should start building the index is tracked separately, because it
+ changes boot behaviour for deployments that already hold large collections.
+
+Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
+applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
---
diff --git a/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts
new file mode 100644
index 0000000000..da1a9cd486
--- /dev/null
+++ b/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts
@@ -0,0 +1,226 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
+//
+// `reference` is the only relationship spelling `@objectstack/spec` declares.
+// `reference_to` is a REJECTED ALIAS: `FieldSchema` answers `unrecognized_keys`
+// for it on any field type, carrying any value. Until this door, this driver
+// read `reference_to` and only `reference_to` as the gate on its field-level
+// join index — so one key had two doors with opposite answers, and the silent
+// one was the one that touched the database.
+//
+// Driven against a fake `Db`, deliberately: this package's real-server suite is
+// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
+// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
+// lane — which is exactly the lane that has to notice if this regresses. The
+// recorder below is the same narrow slice of `Db` that
+// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
+// rather than imported because neither file exports it, and a shared fixture
+// module between two suites that pin OPPOSITE halves of one arm would couple
+// them for no gain.
+//
+// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
+// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
+// ruling (it is a boot-time behaviour change for existing deployments: index
+// builds on large collections). The last case below is this PR's own NO-CHANGE
+// control for it, and is expected to flip in the PR that takes part (2),
+// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
+// the fact.
+
+import { describe, it, expect } from 'vitest';
+import type { Db } from 'mongodb';
+import { syncCollectionSchema } from './mongodb-schema.js';
+
+interface CreatedIndex {
+ spec: Record;
+ options: Record;
+}
+
+/**
+ * The narrow slice of `Db` `syncCollectionSchema` touches, recording every
+ * `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
+ * that slice — the function under test runs verbatim.
+ */
+function fakeDb(existingCollections: string[] = []) {
+ const created: CreatedIndex[] = [];
+ const collectionsCreated: string[] = [];
+ const db = {
+ listCollections: ({ name }: { name: string }) => ({
+ toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
+ }),
+ createCollection: async (name: string) => {
+ collectionsCreated.push(name);
+ },
+ collection: () => ({
+ createIndex: async (spec: Record, options: Record) => {
+ created.push({ spec, options });
+ },
+ }),
+ } as unknown as Db;
+ return { db, created, collectionsCreated };
+}
+
+/** Every index name the sync asked MongoDB to create, core indexes included. */
+const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);
+
+/** The ADR-0112 envelope this refusal is required to speak. */
+interface CodedError {
+ code?: string;
+ status?: number;
+ message: string;
+}
+
+/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
+async function refusalFrom(fields: Record) {
+ const { db, created, collectionsCreated } = fakeDb();
+ let caught: CodedError | undefined;
+ try {
+ await syncCollectionSchema(db, 'lead', {
+ name: 'lead',
+ fields: fields as Parameters[2]['fields'],
+ });
+ } catch (error) {
+ caught = error as CodedError;
+ }
+ expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
+ return { err: caught as CodedError, created, collectionsCreated };
+}
+
+describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
+ it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
+ // ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
+ // `toThrow()` would stay green against an unrelated `Error` from anywhere
+ // else in the sync — including the very silence this door replaces, had it
+ // failed for some other reason.
+ const { err } = await refusalFrom({
+ company_id: { type: 'lookup', reference_to: 'company' },
+ });
+
+ expect(err.code).toBe('VALIDATION_ERROR');
+ expect(err.status).toBe(400);
+ });
+
+ it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
+ // The wording IS the contract here: the ruling is "one key, one answer, on
+ // both doors", so this door has to hand back the same verdict and the same
+ // one-word remedy the authoring door does — not a driver-flavoured paraphrase.
+ const { err } = await refusalFrom({
+ company_id: { type: 'lookup', reference_to: 'company' },
+ });
+
+ expect(err.message).toContain('[driver-mongodb]');
+ expect(err.message).toContain("field 'company_id' on 'lead'");
+ expect(err.message).toContain('rejected alias');
+ expect(err.message).toContain('reference_to` -> `reference');
+ // The spec's own verdict word, so a reader can match this against the
+ // `FieldSchema` failure they may already be holding.
+ expect(err.message).toContain('unrecognized_keys');
+ });
+
+ it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
+ // Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
+ // draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
+ // door gated on `type === 'lookup'` would answer differently from the schema
+ // for every other type. `sql-driver.ts` states its copy before the type
+ // switch for exactly this reason; this file has no type switch, so the
+ // equivalent placement is ahead of the whole field loop.
+ for (const type of ['text', 'string', 'user', 'number', undefined]) {
+ const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
+ expect(err.code, String(type)).toBe('VALIDATION_ERROR');
+ expect(err.status, String(type)).toBe(400);
+ }
+ });
+
+ it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
+ // The SQL door's stated hazard, transplanted: a multi-value lookup returned
+ // from `createColumn` immediately and used to carry the key straight past
+ // that seam. Nothing here may acquire the same shape.
+ const { err } = await refusalFrom({
+ company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
+ });
+
+ expect(err.code).toBe('VALIDATION_ERROR');
+ expect(err.status).toBe(400);
+ });
+
+ it('refuses every value the key can carry, including `null` and the empty string', async () => {
+ // The predicate is `!== undefined`, not truthiness. Measured on
+ // `FieldSchema`: `'company'`, `null` and `''` all draw one identical
+ // `unrecognized_keys` verdict — so a truthy gate would have let two of the
+ // three shapes the schema refuses walk through this door.
+ for (const value of ['company', null, '', 0, false]) {
+ const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
+ expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
+ expect(err.status, JSON.stringify(value)).toBe(400);
+ }
+ });
+
+ it('touches NOTHING on the database when it refuses', async () => {
+ // The refusal is stated ahead of `createCollection`, so a refused sync does
+ // not leave a collection (or a partial index set) behind for the next boot
+ // to find. "Before the collection exists, not after documents are in it."
+ const { created, collectionsCreated } = await refusalFrom({
+ name: { type: 'string', unique: true },
+ company_id: { type: 'lookup', reference_to: 'company' },
+ owner_id: { type: 'user' },
+ });
+
+ expect(collectionsCreated).toEqual([]);
+ expect(names(created)).toEqual([]);
+ });
+
+ it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
+ // `!== undefined` rather than `'reference_to' in field` — the narrower of
+ // two correct predicates, and BOTH doors take the same one. Measured:
+ // `FieldSchema`'s own canonical output does not carry `reference_to` as an
+ // own key, so a producer spreading canonical output can never trip this;
+ // a producer spreading an explicit `undefined` is not writing a
+ // relationship, and refusing it would be the two doors disagreeing again,
+ // in the other direction.
+ const { db, created, collectionsCreated } = fakeDb();
+ await syncCollectionSchema(db, 'lead', {
+ name: 'lead',
+ fields: { company_id: { type: 'lookup', reference_to: undefined } },
+ });
+
+ expect(collectionsCreated).toEqual(['lead']);
+ expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
+ });
+
+ it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
+ // ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
+ //
+ // Positive half: a `user` field still gets `idx_owner_id_lookup`, which
+ // proves the arm still executes and that the harness is wired to something —
+ // without it the negative half below would pass just as happily against a
+ // function that created no indexes at all.
+ //
+ // Negative half: a canonically-spelled `reference` lookup still gets NO join
+ // index. That is the divergence #12252 pinned and part (2) of #13222 owns.
+ // ⛔ This case records what the driver DOES, not what it should do: the door
+ // added in this PR makes the arm's `field.reference_to` conjunct unreachable
+ // but deliberately does not delete it, because deleting it would start
+ // building indexes on existing deployments' large collections — an unruled
+ // behaviour change. When part (2) lands, this case is expected to flip to
+ // `toContain`, in the same stroke as the #12252 pin in
+ // `mongodb-schema-declared-indexes.test.ts`.
+ //
+ // Bound through a variable rather than written inline: the driver's own
+ // `FieldDef` declares no `reference` key, so a fresh object literal carrying
+ // it trips TypeScript's excess-property check.
+ const canonicalLookup = { type: 'lookup', reference: 'company' };
+
+ const { db, created } = fakeDb();
+ await syncCollectionSchema(db, 'lead', {
+ name: 'lead',
+ fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
+ });
+
+ expect(names(created)).toEqual([
+ 'idx_id_unique',
+ 'idx_created_at',
+ 'idx_updated_at',
+ 'idx_owner_id_lookup',
+ ]);
+ });
+});
diff --git a/packages/drivers/driver-mongodb/src/mongodb-schema.ts b/packages/drivers/driver-mongodb/src/mongodb-schema.ts
index 7373499ec6..fd7d19a33d 100644
--- a/packages/drivers/driver-mongodb/src/mongodb-schema.ts
+++ b/packages/drivers/driver-mongodb/src/mongodb-schema.ts
@@ -9,6 +9,8 @@
import type { Db, CreateIndexesOptions, IndexSpecification } from 'mongodb';
+import { StandardErrorCode } from '@objectstack/spec/api';
+
/**
* ObjectStack field definition (subset needed for schema sync).
*/
@@ -34,7 +36,22 @@ interface FieldDef {
*/
unique?: boolean | 'global';
required?: boolean;
- reference_to?: string;
+ /**
+ * [#13222] ⛔ A REJECTED ALIAS of `reference`, declared here so the door in
+ * {@link syncCollectionSchema} can READ it — never so this driver can honour
+ * it. `reference` is the only relationship spelling `@objectstack/spec`
+ * declares, and `FieldSchema` answers `unrecognized_keys` for this key.
+ * See {@link refuseRejectedReferenceAlias}.
+ *
+ * Typed `unknown` rather than `string`: only the key's PRESENCE is ever read
+ * here, and the metadata reaching this seam went around Zod — `MongoDBDriver.
+ * syncSchema(object, schema: unknown)` casts and forwards verbatim — so
+ * `string` would be a claim about untrusted input nothing on this path
+ * checked. Measured on `FieldSchema`: `'company'`, `null` and `''` all draw
+ * the one identical `unrecognized_keys` verdict, so the value's shape carries
+ * no information the door needs.
+ */
+ reference_to?: unknown;
multiple?: boolean;
}
@@ -66,6 +83,105 @@ interface ObjectDef {
indexes?: IndexDef[];
}
+/**
+ * [#13222] A field reached schema sync carrying `reference_to` — a key
+ * `FieldSchema` REFUSES — so this face refuses it too, in the spec's own words.
+ *
+ * ## Why the DDL seam needs a door the schema already has
+ *
+ * `reference` is the only relationship spelling the spec declares;
+ * `reference_to` is a REJECTED ALIAS, not a normalised one. Measured against
+ * `@objectstack/spec` built from this tree:
+ *
+ * ```
+ * FieldSchema.safeParse({ name:'company_id', type:'lookup', reference_to:'company' })
+ * => success:false, issue.code = `unrecognized_keys`
+ * "Unrecognized key(s) on this field: `reference_to`.
+ * Did you mean `reference_to` -> `reference`? Until this shape was closed
+ * these were dropped silently ..."
+ * ```
+ *
+ * Until this door, the driver read `reference_to` and ONLY `reference_to`, as
+ * the gate on the field-level join index below. So one key had TWO doors with
+ * opposite answers: the authoring door refused it, while this one silently
+ * honoured it and built an index off it. The silent one was the one that
+ * touched the database.
+ *
+ * ⚠️ Unlike the SQL counterpart (`sql-driver.ts`, #11567), whose `patch` grade
+ * rested on "no authored deployment could reach the branch", the affected
+ * population HERE is non-zero by construction: this package's own published
+ * README taught `reference_to` in a sample calling `driver.syncSchema` directly
+ * — shipped at `@objectstack/driver-mongodb` 17.2.0 and earlier — and
+ * `syncSchema(object, schema: unknown)` casts and forwards verbatim with no Zod
+ * (`mongodb-driver.ts`). A deployment that copied that sample boots today and
+ * is refused here after this. That is why the change is graded `minor`. The
+ * README no longer teaches the key: its remaining mention is prose recording
+ * that the spelling is refused.
+ *
+ * ## Where the door sits, and why
+ *
+ * Stated BEFORE the collection is created and before every per-field branch,
+ * because the spec's refusal is gated on neither type nor value. Measured:
+ * `{ type:'text', reference_to:'company' }` draws the SAME `unrecognized_keys`
+ * verdict as the `lookup` fixture, and `'company'`, `null` and `''` are refused
+ * alike. `sql-driver.ts` states its copy before the `multiple` short-circuit and
+ * before the type switch for that same reason; this file has no type switch, so
+ * the equivalent placement is ahead of the whole field loop — which also puts it
+ * ahead of `db.createCollection`, so a refused sync leaves nothing behind.
+ * One key, one answer, wherever it appears.
+ *
+ * `!== undefined` rather than `'reference_to' in field`, matching the SQL door
+ * exactly: it refuses every value the key can actually carry — including the
+ * `null` and `''` the old truthy gate ignored — while staying immune to a
+ * producer that spreads an explicit `{ reference_to: undefined }`. Measured:
+ * `FieldSchema`'s own canonical output does NOT carry `reference_to` as an own
+ * key, so key presence would have been correct too; this is the narrower of two
+ * correct predicates, and both doors take the same one.
+ *
+ * ## Why refuse rather than ignore
+ *
+ * Ignoring the key would be a THIRD answer to one question. An author who wrote
+ * `reference_to` meant to point the field somewhere; a driver that drops it on
+ * the floor creates a collection pointing nowhere and says nothing. The refusal
+ * is stated at this seam because it is the last place the mistake is still
+ * cheap: before the collection exists, not after documents are in it.
+ *
+ * `VALIDATION_ERROR`/400 rather than a 500 (ADR-0112): the metadata is the
+ * caller's, the condition is decided entirely by what the caller wrote, and the
+ * fix is a one-word rename — the same envelope every other refusal in this
+ * package speaks.
+ *
+ * ## ⛔ What this deliberately does NOT change
+ *
+ * The field-level join-index arm below is left BYTE-IDENTICAL, and its
+ * `field.reference_to` conjunct is now unreachable — not oversight. Deleting
+ * that conjunct would make a canonically-spelled `reference` lookup start
+ * building `idx_FIELD_lookup`, which is a behaviour change for existing
+ * deployments (index builds on large collections) and is a SEPARATE, still-open
+ * ruling — part (2) of #13222, which the maintainer carries in a later batch.
+ * Whoever takes that ruling owns the arm, its comment, and the `#12252` pin in
+ * `mongodb-schema-declared-indexes.test.ts` in one stroke. Until then the arm's
+ * observable behaviour is exactly what it was: a `user` field is indexed, a
+ * canonical `reference` lookup is not.
+ */
+function refuseRejectedReferenceAlias(collectionName: string, fieldName: string): never {
+ const err = new Error(
+ `[driver-mongodb] field '${fieldName}' on '${collectionName}' declares \`reference_to\`, a ` +
+ `rejected alias of \`reference\`. Did you mean \`reference_to\` -> \`reference\`? ` +
+ `\`reference\` is the only relationship spelling @objectstack/spec declares, and ` +
+ `\`FieldSchema\` refuses this key with that same verdict (\`unrecognized_keys\`) on ANY field ` +
+ `type — so a field still carrying it at schema-sync time went around the schema ` +
+ `(\`syncSchema(object, schema: unknown)\` casts and forwards it verbatim, with no Zod). ` +
+ `Rename the key. Note that renaming does not, by itself, get the field a join index: this ` +
+ `driver's join-index arm still reads the refused spelling, so a canonical \`reference\` lookup ` +
+ `is unindexed here. That is a separate, still-open question — deliberately unchanged by the ` +
+ `door you just hit — and not something the rename above regresses.`,
+ ) as Error & { code?: string; status?: number };
+ err.code = StandardErrorCode.enum.VALIDATION_ERROR;
+ err.status = 400;
+ throw err;
+}
+
/**
* Synchronize a MongoDB collection to match an ObjectStack object definition.
*
@@ -81,6 +197,13 @@ export async function syncCollectionSchema(
collectionName: string,
schema: ObjectDef,
): Promise {
+ // [#13222] The door — see {@link refuseRejectedReferenceAlias}. Ahead of
+ // `createCollection` and of every per-field branch, because the spec's
+ // refusal is gated on neither the field's type nor the key's value.
+ for (const [fieldName, field] of Object.entries(schema.fields ?? {})) {
+ if (field.reference_to !== undefined) refuseRejectedReferenceAlias(collectionName, fieldName);
+ }
+
// Ensure collection exists
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {