Skip to content

Commit f243a29

Browse files
fix(objectql): the cascade skips a federated object's injected tenant anchor (#21917)
Fixes #21910 Clause-②: no ## What was wrong Deleting an organization runs the engine's referential cascade (`ObjectQL.cascadeDeleteRelations`), which probes every registered `lookup` / `master_detail` field that references the deleted object. The registry injects the tenant anchor `organization_id` (a lookup to `sys_organization`) into every object it registers, federated (ADR-0015 `external`) ones included, and the platform provisions no storage for a federated object. The scan read that injected field as a real reference and probed the showcase's remote `customers` table on `organization_id`. The SQL driver refused the unknown column (`INVALID_FILTER`), the probe's catch propagated it as #8895 rules for a missing column, and the organization delete answered 500. ## What changed - `packages/objectql/src/federated-object.ts`: a new predicate, `isFederatedInjectedTenantAnchor(schema, fieldName)`. It is true only when all three hold: the field is `organization_id`; the object is federated by `isFederatedObject`, the predicate `buildDriverOptions` and the related-record read already ask; and the injected-column provenance marker (`resolveInjectedColumnProvenance`, the #7865 ruling) answers `injected-unprovisioned`. An `organization_id` the author declared answers `author` and stays a relation. - `packages/objectql/src/engine.ts`, `cascadeDeleteRelations`: the scan skips a field the predicate accepts, right after the reference match and before the elevation record and the probe. The probe's catch is unchanged. It is NOT widened to pass a missing column as benign. - `packages/objectql/src/engine.ts`, `planCascadeAtomicity`: the atomicity plan asks the same predicate. That method's own comment requires its participant test to be the scan's ("so the two cannot disagree about who participates"), and a scan-only change would have made that sentence false. This is a bounded in-place fix, named here with its evidence below. - `.changeset/21910-cascade-federated-tenant-anchor.md`: `@objectstack/objectql` patch, `Clause-②: no`. The fix is in the scan, the consumer of the injected anchor. The producer side is ruled: the #7865 ruling (direction B) keeps the injection for `external` objects and supplies the provenance marker this predicate reads. No `packages/spec` edit. ## Pins - Unit, `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts` (5 tests, a two-driver engine, all through `engine.delete`): - the scan never reads a federated object on its injected `organization_id`, so the organization delete lands while that read would be refused. A local object's injected anchor IS read on the same delete (control); - an `organization_id` the author declared on a federated object is still probed, and the probe's failure propagates with its envelope (`code` `INVALID_FILTER`, `status` 400, same error object); - another lookup the author declared on a federated object (`org_ref`) is still probed, and its failure propagates the same way; - the atomicity plan opens one transaction when the injected anchor was the only cross-datasource reference. The control: an author-declared federated lookup still makes the plan cross-datasource, and it logs the split warning once. - Door, `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`: boots the showcase with `orgContext`, provisions the federated fixture with `onEnable` in its own `mkdtemp` working directory, and asserts its premises on the same boot. The remote rows are served, the injected anchor answers `injected-unprovisioned`, and a SYSTEM read filtered on `organization_id` is refused `INVALID_FILTER`. Then the owner's `POST /api/v1/auth/organization/delete` answers 200, the row is gone, and the federated rows are untouched. It neither reads nor writes `packages/qa/dogfood/.objectstack/data/showcase_external.db`: after the runs that directory does not exist in this worktree. - #8895's pins stay green: `engine-cascade-delete-probe-failure.test.ts` with the rest of the cascade files (8 files, 90 tests), and the whole `@objectstack/objectql` suite (375 files, 7469 tests). ## Measured readings - **Before, with the fixture** (base `e6dc7a2406`, the door pin): `expected 500 to be 200`. The server log shows `[reference-cleanup] referential integrity check on 'showcase_ext_customer' ... relationField organization_id`, then `[sql-driver] INVALID_FILTER — a WHERE column could not be resolved on 'showcase_ext_customer' ('organization_id')`, `Delete operation failed`, better-auth `SERVER_ERROR`, and `[AuthPlugin] ... HTTP 500`. - **Before, with no fixture** (same file, `onEnable` not run, the federated reads dropped): 200. The probe on `showcase_ext_customer` fails with `no such table: customers`, and the probe's missing-table branch passes it. - **After** (`99eb366577`): the door pin and the unit pin are green. - **The atomicity plan on the showcase.** Before and after, an organization delete logs `Cascade delete of 'sys_organization' cannot run as one unit of work`. A walk of the plan's closure on the booted showcase shows why. At depth 1 the plan reaches `showcase_ext_customer` and `showcase_ext_order` through their injected `owning_business_unit_id` (a lookup to `sys_business_unit`, which is itself at depth 0). So on the showcase the plan change moves no verdict. It keeps the plan's participant test equal to the scan's, which the unit pin and the reverse verification below measure. ## Reverse verification (on committed HEAD `99eb366577`) - **Leg A: the scan's skip line deleted.** The deletion went through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `2073a1d4b84d` to `03dfd65888d8`). Then `@objectstack/objectql` was rebuilt, and `ablation-dist-preflight --absent` confirmed the marker is absent from all 14 built files. Unit pin: 1 failed, 4 passed (the scan test). Door pin: `expected 500 to be 200`. - **Leg A restore.** The restore proved blob `2073a1d4b84d` equals HEAD and `git diff HEAD` is empty. After a rebuild, the preflight in present mode found the marker in 4 built files, with the tree clean. - **Leg B: the plan's skip line deleted.** Same tool, blob `2073a1d4b84d` to `351409525155`. Unit pin: 1 failed, 4 passed (the plan test). The unit pin imports `./engine.js` relatively, so it reads source, never `dist/`. The restore proved blob equals HEAD, `git diff HEAD` 0 bytes, and an empty porcelain. ## Gates (at `99eb366577`) - `node scripts/pm/dispatch-gates.mjs --commands` over the branch derived the same 71 commands as the dispatch. All 71 exit 0, and `--ran` reports `71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN`. - Artifact-roster block, 52 commands: 49 exit 0. Three need a pull request in their environment and answer exit 2, NOT WIRED / NOT MEASURED: `check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs` and `check-single-claim-paths.mjs`. Their CI workflows run them. - Symbol-anchor sweeps: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exit 0. - The 11 declared-wide families: all exit 0 (extra). - `check:objectql-double-limit`: the new stub driver's `find` applies the caller's `limit` after the filter, by presence. The gate grades it as applying the bound (452 graded, 255 apply, none new). - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/dogfood typecheck` are green, and `tsc --listFiles` includes both new test files. - ESLint, narrowed to the 4 touched sources: - population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside `NEVER_LINTED`, so the changeset is not in it; - count: `--format json` reports 4 files, 0 errors and 0 warnings; - invariance: the config "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules)". So this diff moves no verdict on an untouched file through type information. The full `pnpm lint` is CI's. ## Acceptance notes - **Conflict with the dispatch, stated.** The dispatch said to report other readers of the tenant field and not fix them here. `planCascadeAtomicity` is fixed anyway, for two reasons. It is the scan's documented twin, whose participant test the code requires to equal the scan's. And a scan-only change would have made that comment false. On the showcase it changes no verdict (see Measured readings). - **Other readers of a federated object's injected anchors, not covered here:** - **The cascade scan on the OTHER injected anchors** (`owning_business_unit_id`, `owner_id`, `created_by`, `updated_by`). Measured on the showcase with the fixture: an admin's `DELETE /api/v1/data/sys_business_unit/:id` answers 400. The body reads "A filter on object 'showcase_ext_customer' names a column the database could not resolve", and the log has `[sql-driver] INVALID_FILTER ... ('owning_business_unit_id')`. Triage's ruling scopes this card to the tenant field, so the predicate is not widened here. This is reported to the seat for the family's closing card. - **A user delete.** The same mechanism applies through `created_by`, `updated_by` and `owner_id`, but it is NOT MEASURED: `POST /api/v1/auth/admin/remove-user` answered 404 in the verify harness. - **`lifecycle/lifecycle-service.ts`.** Its per-tenant archive and reap passes filter `organization_id` with no federated branch. Read-only inference, unmeasured. It is reachable only if a lifecycle policy is declared on a federated object. - **`eventOrganizationId` in `engine.ts`.** It reads the row's tenant column value, not the remote's schema. On a federated row the column is absent and the key is omitted. Inference, unmeasured. - **Not in this change:** the dogfood fixture leak in the package directory, which is #21914's. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be97cf3 commit f243a29

5 files changed

Lines changed: 485 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
Deleting an organization no longer fails with a 500 on a deployment that has a federated (ADR-0015 `external`) object bound. The engine's referential cascade no longer treats the `organization_id` the platform injects into a federated object as a reference to `sys_organization`.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** The registry injects `organization_id` into every object, federated ones included, and the platform provisions no storage for a federated object. The cascade's dependents probe filtered the remote table on that column, the SQL driver refused the unknown column (`INVALID_FILTER`), and the probe's failure propagated, so the delete failed. The showcase, with its federated fixture provisioned, answered every organization delete with 500.
10+
- **What changed.** The cascade scan skips a federated object's injected tenant anchor. It asks the same `isFederatedObject` predicate as the driver-option builder and the related-record read, plus the injected-column provenance marker, so an `organization_id` the author declared on a federated object is still probed. The cascade's atomicity plan asks the same question, so it keeps counting exactly the relations the scan probes.
11+
- **What did not change.** Any lookup an author declares on a federated object is still probed, and a probe that cannot run still fails the delete. Only a missing child table is passed over as having no dependents.
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21910] The referential cascade does not treat a federated object's
5+
* platform-INJECTED `organization_id` as a reference to `sys_organization`,
6+
* and treats nothing else that way.
7+
*
8+
* The registry injects the tenant anchor (`organization_id`, a lookup to
9+
* `sys_organization`) into every object it registers, ADR-0015 `external` ones
10+
* included, and the platform provisions no storage for a federated object. On
11+
* the showcase, deleting an organization ran the cascade scan's dependents
12+
* probe against the remote `customers` table on that column. The SQL driver
13+
* refused it (`INVALID_FILTER`, no such column), the probe's #8895 catch
14+
* propagated the refusal, and the organization delete answered 500.
15+
*
16+
* What this file pins, all through `engine.delete` on a two-driver engine (the
17+
* default one, and the remote a federated object is bound to by `datasource`):
18+
*
19+
* 1. the scan never reads a federated object on its injected anchor, so an
20+
* organization delete lands while that read would be refused. A local
21+
* object's injected anchor IS read on the same delete, which proves the
22+
* scan ran;
23+
* 2. an `organization_id` the AUTHOR declared on a federated object is still
24+
* probed, and a probe failure still propagates (#8895);
25+
* 3. any other lookup the author declares on a federated object is still
26+
* probed, and a probe failure still propagates (#8895);
27+
* 4. the cascade's atomicity plan agrees with the scan: an organization delete
28+
* whose only cross-datasource "participant" was the injected anchor runs
29+
* as one transaction, while an author-declared federated lookup still
30+
* makes the plan cross-datasource.
31+
*
32+
* The seed rows are written straight into the stub's store, so no write path
33+
* other than the delete under test runs. The door pin is
34+
* `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`.
35+
*/
36+
37+
import { describe, it, expect } from 'vitest';
38+
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
39+
import { ObjectQL } from './engine.js';
40+
41+
/** The remote datasource every federated fixture below is bound to. */
42+
const REMOTE = 'remote_ds';
43+
const PACKAGE_ID = 'test-21910';
44+
45+
type Row = Record<string, unknown>;
46+
47+
/**
48+
* A stub driver that records every read into a shared log and can be told to
49+
* refuse reads of one object with an exact error object. Its `find` applies
50+
* the caller's `limit` after the filter, by presence.
51+
*/
52+
function makeDriver(name: string, log: { reads: string[]; begun: number }) {
53+
const tables: Record<string, Row[]> = {};
54+
const failReads = new Map<string, unknown>();
55+
const rowsOf = (o: string): Row[] => (tables[o] ??= []);
56+
const matches = (row: Row, where: any): boolean => {
57+
if (!where || typeof where !== 'object') return true;
58+
for (const [k, v] of Object.entries(where)) {
59+
if (k.startsWith('$')) continue;
60+
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
61+
if ((row[k] ?? null) !== (exp ?? null)) return false;
62+
}
63+
return true;
64+
};
65+
const driver: any = {
66+
name, version: '0.0.0', supports: {},
67+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
68+
async syncSchema() {},
69+
registerExternalObject() {},
70+
async find(o: string, ast: any) {
71+
log.reads.push(o);
72+
const failure = failReads.get(o);
73+
if (failure !== undefined) throw failure;
74+
const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where));
75+
return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit;
76+
},
77+
async findOne(o: string, ast: any) {
78+
const [first] = await this.find(o, { ...ast, limit: 1 });
79+
return first ?? null;
80+
},
81+
async count(o: string, ast: any) {
82+
return (await this.find(o, { where: ast?.where })).length;
83+
},
84+
async create(o: string, data: Row) {
85+
const row = { ...data, id: String(data.id) };
86+
rowsOf(o).push(row);
87+
return row;
88+
},
89+
async update(o: string, id: string, data: Row) {
90+
const rows = rowsOf(o);
91+
const at = rows.findIndex((r) => r.id === String(id));
92+
if (at < 0) throw new Error(`not found ${o}/${id}`);
93+
rows[at] = { ...rows[at], ...data, id: String(id) };
94+
return rows[at];
95+
},
96+
async upsert(o: string, data: Row) { return this.create(o, data); },
97+
async delete(o: string, id: string) {
98+
const rows = rowsOf(o);
99+
const at = rows.findIndex((r) => r.id === String(id));
100+
if (at < 0) return false;
101+
rows.splice(at, 1);
102+
return true;
103+
},
104+
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
105+
async beginTransaction() { log.begun += 1; return { id: `trx_${log.begun}` }; },
106+
async commit() {}, async rollback() {},
107+
};
108+
return {
109+
driver,
110+
failReads,
111+
has: (o: string, id: string) => rowsOf(o).some((r) => r.id === id),
112+
seed: (o: string, row: Row) => void rowsOf(o).push(row),
113+
};
114+
}
115+
116+
/** The deleted object. Its own injected anchor makes it self-referencing, harmlessly. */
117+
const ORGANIZATION = {
118+
name: 'sys_organization',
119+
label: 'Organization',
120+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
121+
};
122+
123+
/** A LOCAL object: the registry injects `organization_id`, and storage backs it. */
124+
const LOCAL = {
125+
name: 'acct',
126+
label: 'Account',
127+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
128+
};
129+
130+
/** Federated, as the showcase declares it: no `organization_id` of its own. */
131+
const FEDERATED = {
132+
name: 'ext_customer',
133+
label: 'External Customer',
134+
datasource: REMOTE,
135+
external: { remoteName: 'customers' },
136+
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
137+
};
138+
139+
/** Federated, with an `organization_id` the AUTHOR declared: it maps a real remote column. */
140+
const FEDERATED_DECLARED_ANCHOR = {
141+
name: 'ext_tenant_customer',
142+
label: 'External Tenant Customer',
143+
datasource: REMOTE,
144+
external: { remoteName: 'tenant_customers' },
145+
fields: {
146+
name: { name: 'name', label: 'Name', type: 'text' as const },
147+
organization_id: {
148+
name: 'organization_id',
149+
label: 'Remote Organization',
150+
type: 'lookup' as const,
151+
reference: 'sys_organization',
152+
},
153+
},
154+
};
155+
156+
/** Federated, with another lookup the author declared against the organization. */
157+
const FEDERATED_AUTHOR_LOOKUP = {
158+
name: 'ext_order',
159+
label: 'External Order',
160+
datasource: REMOTE,
161+
external: { remoteName: 'orders' },
162+
fields: {
163+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
164+
org_ref: { name: 'org_ref', label: 'Organization', type: 'lookup' as const, reference: 'sys_organization' },
165+
},
166+
};
167+
168+
const ORG_ID = 'org_21910';
169+
170+
/** The refusal the SQL driver answers for a filter on a column the remote does not have. */
171+
function unknownColumnRefusal(object: string, column: string) {
172+
return Object.assign(
173+
new Error(`A filter on object '${object}' names a column the database could not resolve (${column}).`),
174+
{ code: 'INVALID_FILTER', status: 400 },
175+
);
176+
}
177+
178+
async function makeEngine(objects: any[]) {
179+
const log = { reads: [] as string[], begun: 0 };
180+
const warnings: string[] = [];
181+
const logger = {
182+
debug() {}, info() {}, error() {},
183+
warn: (message: unknown) => void warnings.push(String(message)),
184+
};
185+
const engine = new ObjectQL({ logger } as any);
186+
const local = makeDriver('memory', log);
187+
const remote = makeDriver(REMOTE, log);
188+
engine.registerDriver(local.driver, true);
189+
engine.registerDriver(remote.driver);
190+
await engine.init();
191+
for (const o of objects) engine.registry.registerObject(o, PACKAGE_ID);
192+
local.seed('sys_organization', { id: ORG_ID, name: 'Doomed Org' });
193+
return { engine, local, remote, log, warnings };
194+
}
195+
196+
const NOT_ATOMIC = 'cannot run as one unit of work';
197+
198+
describe('[#21910] the cascade scan skips a federated object\'s injected tenant anchor, and nothing else', () => {
199+
it('never probes a federated object on its injected organization_id, so the organization delete lands', async () => {
200+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
201+
// PREMISE: the registered schema carries the platform's injected anchor.
202+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'organization_id'))
203+
.toBe('injected-unprovisioned');
204+
// Any read of the remote table on that column is refused, as the showcase measured.
205+
remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'organization_id'));
206+
207+
log.reads.length = 0;
208+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
209+
210+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
211+
expect(log.reads).not.toContain('ext_customer');
212+
// CONTROL: the scan ran for this delete, and probed the local object's injected anchor.
213+
expect(log.reads).toContain('acct');
214+
});
215+
216+
it('still probes an organization_id the AUTHOR declared on a federated object, and its failure propagates (#8895)', async () => {
217+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_DECLARED_ANCHOR]);
218+
expect(resolveInjectedColumnProvenance(engine.getSchema('ext_tenant_customer'), 'organization_id'))
219+
.toBe('author');
220+
const injected = unknownColumnRefusal('ext_tenant_customer', 'organization_id');
221+
remote.failReads.set('ext_tenant_customer', injected);
222+
223+
log.reads.length = 0;
224+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
225+
226+
expect(err).toBe(injected);
227+
expect(err.code).toBe('INVALID_FILTER');
228+
expect(err.status).toBe(400);
229+
expect(log.reads).toContain('ext_tenant_customer');
230+
expect(local.has('sys_organization', ORG_ID)).toBe(true);
231+
});
232+
233+
it('still probes any other lookup the author declared on a federated object, and its failure propagates (#8895)', async () => {
234+
const { engine, local, remote, log } = await makeEngine([ORGANIZATION, FEDERATED_AUTHOR_LOOKUP]);
235+
const injected = unknownColumnRefusal('ext_order', 'org_ref');
236+
remote.failReads.set('ext_order', injected);
237+
238+
log.reads.length = 0;
239+
const err: any = await engine.delete('sys_organization', { where: { id: ORG_ID } } as any).catch((e) => e);
240+
241+
expect(err).toBe(injected);
242+
expect(err.code).toBe('INVALID_FILTER');
243+
expect(err.status).toBe(400);
244+
expect(log.reads).toContain('ext_order');
245+
expect(local.has('sys_organization', ORG_ID)).toBe(true);
246+
});
247+
});
248+
249+
describe('[#21910] the cascade atomicity plan agrees with the scan about who takes part', () => {
250+
it('runs the organization delete as one transaction when the injected anchor was its only cross-datasource reference', async () => {
251+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED]);
252+
253+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
254+
255+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
256+
expect(log.begun).toBe(1);
257+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]);
258+
});
259+
260+
it('CONTROL: an author-declared lookup on a federated object still makes the plan cross-datasource', async () => {
261+
const { engine, local, log, warnings } = await makeEngine([ORGANIZATION, LOCAL, FEDERATED_AUTHOR_LOOKUP]);
262+
263+
await engine.delete('sys_organization', { where: { id: ORG_ID } } as any);
264+
265+
expect(local.has('sys_organization', ORG_ID)).toBe(false);
266+
expect(log.reads).toContain('ext_order');
267+
expect(log.begun).toBe(0);
268+
expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1);
269+
});
270+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ import {
304304
withDeclaredColumnsOnly,
305305
} from './declared-read-columns.js';
306306
// [#21777] "Is this schema the remote's?" One predicate, shared with the boot sync.
307-
import { isFederatedObject } from './federated-object.js';
307+
// [#21910] And its tenant-anchor refinement, which both cascade walks ask.
308+
import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js';
308309
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
309310
import {
310311
resolveEngineDeleteDispatch,
@@ -15832,7 +15833,7 @@ export class ObjectQL implements IObjectQLEngine {
1583215833
const childName = (child as any)?.name as string | undefined;
1583315834
const fields = (child as any)?.fields as Record<string, any> | undefined;
1583415835
if (!childName || !fields) continue;
15835-
for (const fdef of Object.values(fields)) {
15836+
for (const [fieldName, fdef] of Object.entries(fields)) {
1583615837
if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue;
1583715838
// [#18550] The carrier is read through the ONE arbiter, so a
1583815839
// `reference` no reader can read REFUSES here instead of reading as
@@ -15856,6 +15857,12 @@ export class ObjectQL implements IObjectQLEngine {
1585615857
let resolvedRef: string | undefined;
1585715858
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1585815859
if (ref !== name && resolvedRef !== name) continue;
15860+
// [#21910] The scan skips a federated object's injected tenant
15861+
// anchor, so this walk does too: the participant test stays the
15862+
// scan's own, as the comment above requires. A federated object this
15863+
// walk still reaches through any other relation keeps the verdict
15864+
// `'split'`, because the scan probes that relation.
15865+
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
1585915866
out.push(childName);
1586015867
break;
1586115868
}
@@ -16324,6 +16331,26 @@ export class ObjectQL implements IObjectQLEngine {
1632416331
try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; }
1632516332
if (ref !== object && resolvedRef !== object) continue;
1632616333

16334+
// [#21910] A federated object's platform-INJECTED tenant anchor is not
16335+
// a reference to `sys_organization`, so it is not a relation to probe.
16336+
// On a federated object that column exists in the registered schema
16337+
// and nowhere else: the probe below was refused by the driver
16338+
// (`INVALID_FILTER`, no such column), its catch propagated the refusal
16339+
// as #8895 rules for a missing column, and every organization delete
16340+
// answered 500 on a deployment with a federated object bound.
16341+
// `buildDriverOptions` and the related-record read already refuse this
16342+
// reading of the same column. {@link isFederatedInjectedTenantAnchor}
16343+
// says why it is exactly that column, and
16344+
// {@link ObjectQL.planCascadeAtomicity} asks it too.
16345+
//
16346+
// ⛔ The catch below is deliberately NOT widened to pass a missing
16347+
// column as benign. That would invert #8895's discriminate or
16348+
// propagate for every object, not just this injected column: an
16349+
// `organization_id` the author declared on a federated object, and any
16350+
// other lookup the author declares on one, stay in the scan, and their
16351+
// probe failures still propagate.
16352+
if (isFederatedInjectedTenantAnchor(child, fieldName)) continue;
16353+
1632716354
// A master-detail parent owns its children: cascade by default (the
1632816355
// child FK is typically required, so set_null would be invalid). Only
1632916356
// an explicit `restrict` deviates. A plain lookup honors its

‎packages/objectql/src/federated-object.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
4+
import { DEFAULT_TENANT_FIELD } from './tenancy/system-write-organization.js';
5+
36
/**
47
* Is `schema` a federated object (ADR-0015 `external`), one whose schema is
58
* owned by the REMOTE database?
@@ -31,3 +34,42 @@
3134
export function isFederatedObject(schema: unknown): boolean {
3235
return (schema as { external?: unknown } | null | undefined)?.external != null;
3336
}
37+
38+
/**
39+
* [#21910] Is `fieldName` a federated object's platform-INJECTED tenant
40+
* anchor: the `organization_id` lookup to `sys_organization` that the
41+
* registry adds and the remote table does not have?
42+
*
43+
* `applySystemFields` injects `organization_id` into every object it
44+
* registers, ADR-0015 `external` ones included (the #7865 ruling, direction
45+
* B), and the platform provisions no storage for a federated object. So on
46+
* one, that column exists in the registered schema and nowhere else, and it
47+
* is never a reference to an organization: no remote row can hold one. The
48+
* engine's referential cascade asks this in both of its walks, so the two
49+
* cannot disagree about which objects take part in an organization delete:
50+
*
51+
* - `ObjectQL.cascadeDeleteRelations` does not probe the remote table on it
52+
* (the probe was refused as an unknown column, and every organization
53+
* delete answered 500);
54+
* - `ObjectQL.planCascadeAtomicity` does not count that column as making the
55+
* federated object a participant, so its participant test stays the scan's.
56+
*
57+
* Three conjuncts, and each one is the narrowing:
58+
*
59+
* - the column is the tenant anchor, `organization_id`. The other anchors
60+
* the registry injects (`owner_id`, `created_by`, ...) are not this
61+
* question;
62+
* - the object is federated, by {@link isFederatedObject}, the same predicate
63+
* `buildDriverOptions` and the related-record read ask;
64+
* - the field is the platform's own definition, by the #7865 provenance
65+
* marker. An `organization_id` the author declared answers `'author'` and
66+
* stays a relation: it may map a real remote column, and its probe keeps
67+
* #8895's discriminate or propagate.
68+
*/
69+
export function isFederatedInjectedTenantAnchor(schema: unknown, fieldName: string): boolean {
70+
return (
71+
fieldName === DEFAULT_TENANT_FIELD &&
72+
isFederatedObject(schema) &&
73+
resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned'
74+
);
75+
}

0 commit comments

Comments
 (0)