From 725a71b493ecfab07ed6a2f7363434adafdeecac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:07:25 +0000 Subject: [PATCH 1/5] fix(objectql): skip every injected column a federated object does not provision The cascade scan, its atomicity plan and the lifecycle tenant partition now ask one predicate, isFederatedUnprovisionedInjectedColumn, which reads the registry's own provenance (resolveInjectedColumnProvenance) instead of naming organization_id alone. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/objectql/src/engine.ts | 50 +++++++------ packages/objectql/src/federated-object.ts | 70 ++++++++++++------- .../src/lifecycle/lifecycle-service.ts | 51 +++++++++++--- 3 files changed, 113 insertions(+), 58 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2073a1d4b84..393bca6bda1 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -304,8 +304,8 @@ import { withDeclaredColumnsOnly, } from './declared-read-columns.js'; // [#21777] "Is this schema the remote's?" One predicate, shared with the boot sync. -// [#21910] And its tenant-anchor refinement, which both cascade walks ask. -import { isFederatedObject, isFederatedInjectedTenantAnchor } from './federated-object.js'; +// [#21910, #21918] And its injected-column refinement, which both cascade walks ask. +import { isFederatedObject, isFederatedUnprovisionedInjectedColumn } from './federated-object.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { resolveEngineDeleteDispatch, @@ -15857,12 +15857,13 @@ export class ObjectQL implements IObjectQLEngine { let resolvedRef: string | undefined; try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } if (ref !== name && resolvedRef !== name) continue; - // [#21910] The scan skips a federated object's injected tenant - // anchor, so this walk does too: the participant test stays the - // scan's own, as the comment above requires. A federated object this - // walk still reaches through any other relation keeps the verdict + // [#21910, #21918] The scan skips every column the registry injected + // into a federated object and the object does not provision, so this + // walk does too: the participant test stays the scan's own, as the + // comment above requires. A federated object this walk still reaches + // through a relation its author declared keeps the verdict // `'split'`, because the scan probes that relation. - if (isFederatedInjectedTenantAnchor(child, fieldName)) continue; + if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) continue; out.push(childName); break; } @@ -16331,25 +16332,30 @@ export class ObjectQL implements IObjectQLEngine { try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } if (ref !== object && resolvedRef !== object) continue; - // [#21910] A federated object's platform-INJECTED tenant anchor is not - // a reference to `sys_organization`, so it is not a relation to probe. - // On a federated object that column exists in the registered schema - // and nowhere else: the probe below was refused by the driver - // (`INVALID_FILTER`, no such column), its catch propagated the refusal - // as #8895 rules for a missing column, and every organization delete - // answered 500 on a deployment with a federated object bound. + // [#21910, #21918] A lookup the registry INJECTED into a federated + // object, and the object does not provision, is not a reference to + // anything, so it is not a relation to probe. That is the tenant + // anchor `organization_id`, the ADR-0117 D1 anchor + // `owning_business_unit_id`, and the owner and audit lookups + // `owner_id` / `created_by` / `updated_by`. On a federated object each + // exists in the registered schema and nowhere else: the probe below + // was refused by the driver (`INVALID_FILTER`, no such column), its + // catch propagated the refusal as #8895 rules for a missing column, + // and deleting the organization, business unit or user it names was + // refused on a deployment with a federated object bound. // `buildDriverOptions` and the related-record read already refuse this - // reading of the same column. {@link isFederatedInjectedTenantAnchor} - // says why it is exactly that column, and - // {@link ObjectQL.planCascadeAtomicity} asks it too. + // reading of the tenant column. + // {@link isFederatedUnprovisionedInjectedColumn} reads which columns + // those are from the registry's own provenance, never from a list of + // names, and {@link ObjectQL.planCascadeAtomicity} asks it too. // // ⛔ The catch below is deliberately NOT widened to pass a missing // column as benign. That would invert #8895's discriminate or - // propagate for every object, not just this injected column: an - // `organization_id` the author declared on a federated object, and any - // other lookup the author declares on one, stay in the scan, and their - // probe failures still propagate. - if (isFederatedInjectedTenantAnchor(child, fieldName)) continue; + // propagate for every object, not just these injected columns: a + // lookup the author declared on a federated object, including an + // author's own `organization_id` or `owner_id`, stays in the scan, and + // its probe failure still propagates. + if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) continue; // A master-detail parent owns its children: cascade by default (the // child FK is typically required, so set_null would be invalid). Only diff --git a/packages/objectql/src/federated-object.ts b/packages/objectql/src/federated-object.ts index 8f5d20860c6..a432c5754eb 100644 --- a/packages/objectql/src/federated-object.ts +++ b/packages/objectql/src/federated-object.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; -import { DEFAULT_TENANT_FIELD } from './tenancy/system-write-organization.js'; /** * Is `schema` a federated object (ADR-0015 `external`), one whose schema is @@ -36,39 +35,58 @@ export function isFederatedObject(schema: unknown): boolean { } /** - * [#21910] Is `fieldName` a federated object's platform-INJECTED tenant - * anchor: the `organization_id` lookup to `sys_organization` that the - * registry adds and the remote table does not have? + * [#21910, generalized by #21918] Is `fieldName` a column the registry + * INJECTED into a federated object, and that the federated object does not + * provision? * - * `applySystemFields` injects `organization_id` into every object it + * `applySystemFields` injects the platform's columns into every object it * registers, ADR-0015 `external` ones included (the #7865 ruling, direction - * B), and the platform provisions no storage for a federated object. So on - * one, that column exists in the registered schema and nowhere else, and it - * is never a reference to an organization: no remote row can hold one. The - * engine's referential cascade asks this in both of its walks, so the two - * cannot disagree about which objects take part in an organization delete: + * B): the tenant anchor `organization_id`, the ADR-0117 D1 anchor + * `owning_business_unit_id`, the owner `owner_id`, and the audit columns + * `created_by` / `updated_by` / `created_at` / `updated_at`. The platform + * provisions no storage for a federated object, so on one, each such column + * exists in the registered schema and nowhere else. A lookup among them is + * never a reference to anything: no remote row can hold its value. * - * - `ObjectQL.cascadeDeleteRelations` does not probe the remote table on it - * (the probe was refused as an unknown column, and every organization - * delete answered 500); - * - `ObjectQL.planCascadeAtomicity` does not count that column as making the - * federated object a participant, so its participant test stays the scan's. + * WHICH columns those are is not decided here. It is the registry's own + * provenance, `resolveInjectedColumnProvenance` (the #7865 marker, + * `@objectstack/spec/data`), which answers `'injected-unprovisioned'` exactly + * for an injected column whose registered definition is the platform's own, + * on an object whose storage the platform does not provision. ⛔ No list of + * column names lives here or at any caller. #21910 drew the line at + * `organization_id` alone, and the business-unit and user deletes then failed + * on the next injected anchor. A list would only move the line to the next + * column the registry injects. * - * Three conjuncts, and each one is the narrowing: + * Two conjuncts: * - * - the column is the tenant anchor, `organization_id`. The other anchors - * the registry injects (`owner_id`, `created_by`, ...) are not this - * question; * - the object is federated, by {@link isFederatedObject}, the same predicate - * `buildDriverOptions` and the related-record read ask; - * - the field is the platform's own definition, by the #7865 provenance - * marker. An `organization_id` the author declared answers `'author'` and - * stays a relation: it may map a real remote column, and its probe keeps - * #8895's discriminate or propagate. + * `buildDriverOptions`, the related-record read and every schema-sync seam + * ask. The provenance below implies it (its `'injected-unprovisioned'` is + * only ever answered on an `external` object), so this conjunct changes no + * verdict. It comes first because the cascade asks this for every relation + * of every registered object on every delete, and it lets a local object + * answer without deriving its injection plan; + * - the provenance answers `'injected-unprovisioned'`. A column the author + * declared answers `'author'` and stays a column, including an author's own + * `organization_id` or `owner_id`: it may map a real remote column, and a + * reader keeps treating it as one (#7859's recorded reasoning). A column + * that is neither injected nor declared answers `'absent'`. + * + * Every engine reader of an injected column is enumerated, with its + * disposition, by `federated-injected-column-readers.test.ts`, which fails on + * a reader with none. The readers that ask this predicate: + * + * - `ObjectQL.cascadeDeleteRelations` does not probe a federated object on such + * a column. The probe was refused as an unknown column, so deleting the + * organization, business unit or user it names was refused; + * - `ObjectQL.planCascadeAtomicity` does not count such a column as making the + * federated object a participant, so its participant test stays the scan's; + * - `LifecycleService`'s reap and archive passes do not partition a federated + * object's rows per tenant on an `organization_id` that is such a column. */ -export function isFederatedInjectedTenantAnchor(schema: unknown, fieldName: string): boolean { +export function isFederatedUnprovisionedInjectedColumn(schema: unknown, fieldName: string): boolean { return ( - fieldName === DEFAULT_TENANT_FIELD && isFederatedObject(schema) && resolveInjectedColumnProvenance(schema, fieldName) === 'injected-unprovisioned' ); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.ts b/packages/objectql/src/lifecycle/lifecycle-service.ts index a3ff3c2d4d3..1b78524d3f2 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.ts @@ -5,6 +5,7 @@ import type { DriverQuery } from '@objectstack/spec/contracts'; import { isMissingTableError } from '@objectstack/metadata/errors'; import { redactPropagatedDriverFault } from '@objectstack/types'; import { parseLifecycleDuration } from './duration.js'; +import { isFederatedUnprovisionedInjectedColumn } from '../federated-object.js'; import type { DanglingReferenceAuditOptions, DanglingReferenceReport, @@ -1079,7 +1080,7 @@ export class LifecycleService { ); // [commit 801296050] `ttl.onlyWhen` rides the same argument `retention.onlyWhen` // does below — one reap path, one scope spread (see `reap()`'s `scope`). - outcomes.push(await this.reap(engine, object, lc, 'ttl', lc.ttl.field, windowMs, report, lc.ttl.onlyWhen)); + outcomes.push(await this.reap(engine, obj, lc, 'ttl', lc.ttl.field, windowMs, report, lc.ttl.onlyWhen)); } // Rotation (P2): physical time-sharding when the driver supports it — @@ -1143,7 +1144,7 @@ export class LifecycleService { report, ); outcomes.push( - await this.reap(engine, object, lc, 'retention', 'created_at', windowMs, report, lc.retention.onlyWhen), + await this.reap(engine, obj, lc, 'retention', 'created_at', windowMs, report, lc.retention.onlyWhen), ); } else if (lc.storage?.strategy === 'rotation' && !rotated && !lc.ttl) { // Rotation declared but the driver can't shard physically: the shard @@ -1157,7 +1158,7 @@ export class LifecycleService { 'global', report, ); - outcomes.push(await this.reap(engine, object, lc, 'rotation-fallback', 'created_at', windowMs, report)); + outcomes.push(await this.reap(engine, obj, lc, 'rotation-fallback', 'created_at', windowMs, report)); } return outcomes; @@ -1474,9 +1475,10 @@ export class LifecycleService { // a `stop()` observed mid-sweep ends the passes not yet begun as well as // the batches: the leg boundary widens with the loop instead of leaving a // new unchecked seam between passes. - const tenantWindows = (this.governance.tenantOverrides.get(object) ?? []).filter( - (t) => typeof t[overrideKey] === 'string', - ); + // + // [#21918] Which tenants get a pass of their own is {@link tenantWindowsFor}, + // the one decision `reap()` asks too. + const tenantWindows = this.tenantWindowsFor(obj, overrideKey); let archived = 0; if (tenantWindows.length === 0) { archived += await archivePass({ [dueField]: { $lt: cutoff } }); @@ -1538,9 +1540,39 @@ export class LifecycleService { return [archived]; } + /** + * [#21918] The per-tenant windows (ADR-0057 §3.2) that partition `obj`'s + * rows: each tenant with a genuinely tenant-scoped override for this object + * and this policy's key. The ONE decision `reap()` and `archiveObject()` + * both ask, so the two cannot partition the same object differently. + * + * A partition is a predicate on the row's `organization_id`, the column the + * passes below name. On a federated (ADR-0015 `external`) object that column + * is the registry's injection and the remote does not provision it + * ({@link isFederatedUnprovisionedInjectedColumn}, which reads the #7865 + * provenance), so every partitioned pass was refused by the driver as an + * unknown column (`INVALID_FILTER`) and the object's sweep failed before + * its global pass ran. No row of such an object carries an organization, so + * it has no tenant partition: it answers no windows, and the caller runs its + * one global pass. That is the window a provisioned object's + * no-organization rows get, by the same `$or` arm the partitioned global + * pass spells for them. A tenant override naming such an object has no row + * to select either way. An `organization_id` the author declared on a + * federated object maps a real remote column and keeps its partition. + */ + private tenantWindowsFor( + obj: LifecycleObjectLike, + overrideKey: 'maxAge' | 'expireAfter', + ): Array<{ tenantId: string; maxAge?: string; expireAfter?: string }> { + if (isFederatedUnprovisionedInjectedColumn(obj, 'organization_id')) return []; + return (this.governance.tenantOverrides.get(obj.name) ?? []).filter( + (t) => typeof t[overrideKey] === 'string', + ); + } + private async reap( engine: LifecycleEngineLike, - object: string, + obj: LifecycleObjectLike, lc: Lifecycle, policy: LifecycleSweepEntry['policy'], field: string, @@ -1548,11 +1580,10 @@ export class LifecycleService { report: LifecycleSweepReport, onlyWhen?: Record, ): Promise { + const object = obj.name; const cutoff = new Date(this.now() - windowMs).toISOString(); const overrideKey = policy === 'ttl' ? 'expireAfter' : 'maxAge'; - const tenantWindows = (this.governance.tenantOverrides.get(object) ?? []).filter( - (t) => typeof t[overrideKey] === 'string', - ); + const tenantWindows = this.tenantWindowsFor(obj, overrideKey); // `retention.onlyWhen` / `ttl.onlyWhen` [commit 801296050] narrow every delete to // the declared row filter — rows outside it (live workflow state, audit // tombstones) are retained regardless of age/expiry. From 7fbc39ef9f4f196a28262505e8013f44fbc0ec2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:17:51 +0000 Subject: [PATCH 2/5] test(objectql): pin the general predicate, the cascade and plan on every injected anchor, and the lifecycle partition Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...ne-cascade-federated-tenant-anchor.test.ts | 198 +++++++++++++++++- .../objectql/src/federated-object.test.ts | 149 +++++++++++++ .../src/lifecycle/lifecycle-service.test.ts | 131 ++++++++++++ 3 files changed, 471 insertions(+), 7 deletions(-) create mode 100644 packages/objectql/src/federated-object.test.ts diff --git a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts index 1429019ea91..2a37ead7c87 100644 --- a/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts +++ b/packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts @@ -29,9 +29,17 @@ * as one transaction, while an author-declared federated lookup still * makes the plan cross-datasource. * + * [#21918] The same four, for every OTHER anchor the registry injects into a + * federated object and the object does not provision, read from the + * registry's provenance rather than from a column name: `owning_business_unit_id` + * on a business-unit delete, and `owner_id` / `created_by` / `updated_by` on a + * user delete. Their blocks are at the foot of this file, and the predicate's + * own pin is `federated-object.test.ts`. + * * The seed rows are written straight into the stub's store, so no write path - * other than the delete under test runs. The door pin is - * `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts`. + * other than the delete under test runs. The door pins are + * `packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts` + * and `packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`. */ import { describe, it, expect } from 'vitest'; @@ -44,12 +52,26 @@ const PACKAGE_ID = 'test-21910'; type Row = Record; +/** Every field name a `where` filters on, at any depth, as `object.field`. */ +function filteredColumns(object: string, where: unknown, out: string[] = []): string[] { + if (Array.isArray(where)) { + for (const w of where) filteredColumns(object, w, out); + } else if (where && typeof where === 'object') { + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) filteredColumns(object, v, out); + else out.push(`${object}.${k}`); + } + } + return out; +} + /** - * A stub driver that records every read into a shared log and can be told to - * refuse reads of one object with an exact error object. Its `find` applies - * the caller's `limit` after the filter, by presence. + * A stub driver that records every read into a shared log, with the columns + * each read filters on, and can be told to refuse reads of one object with an + * exact error object. Its `find` applies the caller's `limit` after the + * filter, by presence. */ -function makeDriver(name: string, log: { reads: string[]; begun: number }) { +function makeDriver(name: string, log: { reads: string[]; probes: string[]; begun: number }) { const tables: Record = {}; const failReads = new Map(); const rowsOf = (o: string): Row[] => (tables[o] ??= []); @@ -69,6 +91,7 @@ function makeDriver(name: string, log: { reads: string[]; begun: number }) { registerExternalObject() {}, async find(o: string, ast: any) { log.reads.push(o); + log.probes.push(...filteredColumns(o, ast?.where)); const failure = failReads.get(o); if (failure !== undefined) throw failure; const hit = (tables[o] ?? []).filter((r) => matches(r, ast?.where)); @@ -176,7 +199,7 @@ function unknownColumnRefusal(object: string, column: string) { } async function makeEngine(objects: any[]) { - const log = { reads: [] as string[], begun: 0 }; + const log = { reads: [] as string[], probes: [] as string[], begun: 0 }; const warnings: string[] = []; const logger = { debug() {}, info() {}, error() {}, @@ -268,3 +291,164 @@ describe('[#21910] the cascade atomicity plan agrees with the scan about who tak expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// [#21918] Every other injected anchor of a federated object, read from the +// registry's provenance: `owning_business_unit_id` (ADR-0117 D1) on a business +// unit delete, and the owner and audit lookups `owner_id` / `created_by` / +// `updated_by` on a user delete. #21910's predicate named `organization_id` +// alone, so on the showcase with its federated fixture a business-unit delete +// answered 400 (`INVALID_FILTER` on `showcase_ext_customer.owning_business_unit_id`) +// and a user delete answered 500 (on `created_by`). The door pins are +// `packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts`. +// --------------------------------------------------------------------------- + +/** The business-unit object a federated object's injected `owning_business_unit_id` names. */ +const BUSINESS_UNIT = { + name: 'sys_business_unit', + label: 'Business Unit', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** The user object a federated object's injected `owner_id` / `created_by` / `updated_by` name. */ +const USER = { + name: 'sys_user', + label: 'User', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** + * Federated, with two lookups the AUTHOR declared on the anchors' targets: an + * `owner_id` of its own (it maps a real remote column) and `unit_ref`. + */ +const FEDERATED_AUTHOR_ANCHORS = { + name: 'ext_assignment', + label: 'External Assignment', + datasource: REMOTE, + external: { remoteName: 'assignments' }, + fields: { + title: { name: 'title', label: 'Title', type: 'text' as const }, + owner_id: { name: 'owner_id', label: 'Remote Owner', type: 'lookup' as const, reference: 'sys_user' }, + unit_ref: { name: 'unit_ref', label: 'Unit', type: 'lookup' as const, reference: 'sys_business_unit' }, + }, +}; + +const UNIT_ID = 'bu_21918'; +const USER_ID = 'usr_21918'; + +async function makeAnchorEngine(objects: any[]) { + const made = await makeEngine([ORGANIZATION, BUSINESS_UNIT, USER, ...objects]); + made.local.seed('sys_business_unit', { id: UNIT_ID, name: 'Doomed Unit' }); + made.local.seed('sys_user', { id: USER_ID, name: 'Doomed User' }); + return made; +} + +/** The columns a delete's scan probed on one object, deduplicated and sorted. */ +const probedOn = (log: { probes: string[] }, object: string): string[] => + [...new Set(log.probes.filter((p) => p.startsWith(`${object}.`)))].sort(); + +describe('[#21918] the cascade scan skips every injected anchor a federated object does not provision', () => { + it('a business-unit delete never probes a federated object on its injected owning_business_unit_id, and lands', async () => { + const { engine, local, remote, log } = await makeAnchorEngine([LOCAL, FEDERATED]); + // PREMISE: the registered anchor is the registry's own, and unprovisioned. + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_customer'), 'owning_business_unit_id')) + .toBe('injected-unprovisioned'); + remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'owning_business_unit_id')); + + log.reads.length = 0; + log.probes.length = 0; + await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any); + + expect(local.has('sys_business_unit', UNIT_ID)).toBe(false); + expect(log.reads).not.toContain('ext_customer'); + // CONTROL: the scan ran, and probed the LOCAL object's injected anchor of the same name. + expect(probedOn(log, 'acct')).toContain('acct.owning_business_unit_id'); + }); + + it('a user delete never probes a federated object on its injected owner_id, created_by or updated_by, and lands', async () => { + const { engine, local, remote, log } = await makeAnchorEngine([LOCAL, FEDERATED]); + const schema = engine.getSchema('ext_customer'); + for (const column of ['owner_id', 'created_by', 'updated_by']) { + expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-unprovisioned'); + } + remote.failReads.set('ext_customer', unknownColumnRefusal('ext_customer', 'created_by')); + + log.reads.length = 0; + log.probes.length = 0; + await engine.delete('sys_user', { where: { id: USER_ID } } as any); + + expect(local.has('sys_user', USER_ID)).toBe(false); + expect(log.reads).not.toContain('ext_customer'); + // CONTROL: the LOCAL object's injected owner and audit lookups ARE probed. + expect(probedOn(log, 'acct')).toEqual( + expect.arrayContaining(['acct.created_by', 'acct.owner_id', 'acct.updated_by']), + ); + }); + + it('still probes lookups the AUTHOR declared on a federated object, and a business-unit probe failure propagates (#8895)', async () => { + const { engine, local, remote, log } = await makeAnchorEngine([FEDERATED_AUTHOR_ANCHORS]); + const injected = unknownColumnRefusal('ext_assignment', 'unit_ref'); + remote.failReads.set('ext_assignment', injected); + + log.probes.length = 0; + const err: any = await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any).catch((e) => e); + + expect(err).toBe(injected); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + // The probe that ran is the author's column, never the injected anchor beside it. + expect(probedOn(log, 'ext_assignment')).toEqual(['ext_assignment.unit_ref']); + expect(local.has('sys_business_unit', UNIT_ID)).toBe(true); + }); + + it('still probes an owner_id the AUTHOR declared on a federated object, and a user probe failure propagates (#8895)', async () => { + const { engine, local, remote, log } = await makeAnchorEngine([FEDERATED_AUTHOR_ANCHORS]); + expect(resolveInjectedColumnProvenance(engine.getSchema('ext_assignment'), 'owner_id')).toBe('author'); + const injected = unknownColumnRefusal('ext_assignment', 'owner_id'); + remote.failReads.set('ext_assignment', injected); + + log.probes.length = 0; + const err: any = await engine.delete('sys_user', { where: { id: USER_ID } } as any).catch((e) => e); + + expect(err).toBe(injected); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(probedOn(log, 'ext_assignment')).toEqual(['ext_assignment.owner_id']); + expect(local.has('sys_user', USER_ID)).toBe(true); + }); +}); + +describe('[#21918] the cascade atomicity plan agrees with the scan for every injected anchor', () => { + it('runs a business-unit delete as one transaction when injected anchors were its only cross-datasource references', async () => { + const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED]); + + await engine.delete('sys_business_unit', { where: { id: UNIT_ID } } as any); + + expect(local.has('sys_business_unit', UNIT_ID)).toBe(false); + expect(log.begun).toBe(1); + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]); + }); + + it('runs a user delete as one transaction when injected anchors were its only cross-datasource references', async () => { + const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED]); + + await engine.delete('sys_user', { where: { id: USER_ID } } as any); + + expect(local.has('sys_user', USER_ID)).toBe(false); + expect(log.begun).toBe(1); + expect(warnings.filter((w) => w.includes(NOT_ATOMIC))).toEqual([]); + }); + + it('CONTROL: lookups the author declared on a federated object still make both plans cross-datasource', async () => { + for (const [object, id] of [['sys_business_unit', UNIT_ID], ['sys_user', USER_ID]] as const) { + const { engine, local, log, warnings } = await makeAnchorEngine([LOCAL, FEDERATED_AUTHOR_ANCHORS]); + + await engine.delete(object, { where: { id } } as any); + + expect(local.has(object, id), object).toBe(false); + expect(log.reads, object).toContain('ext_assignment'); + expect(log.begun, object).toBe(0); + expect(warnings.filter((w) => w.includes(NOT_ATOMIC)), object).toHaveLength(1); + } + }); +}); diff --git a/packages/objectql/src/federated-object.test.ts b/packages/objectql/src/federated-object.test.ts new file mode 100644 index 00000000000..3c8ca6f05fe --- /dev/null +++ b/packages/objectql/src/federated-object.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21918] `isFederatedUnprovisionedInjectedColumn` answers "the registry + * injected this column into a federated object, and the object does not + * provision it" for EVERY such column, and for nothing else. + * + * #21910 introduced the predicate for `organization_id` alone. The business + * unit and user deletes then failed on the next anchors the same registry pass + * injects (`owning_business_unit_id`, `owner_id`, `created_by`, `updated_by`), + * so the predicate now reads the registry's own provenance + * (`resolveInjectedColumnProvenance`, the #7865 marker) and names no column. + * + * Every schema below is the REGISTERED one, read back from a `SchemaRegistry` + * after `registerObject`, because the provenance verdict is about the + * definitions the injection actually stored, not about what the author wrote. + * + * The engine readers that ask the predicate are pinned at their seams: + * `engine-cascade-federated-tenant-anchor.test.ts` (the cascade scan and its + * plan) and `lifecycle/lifecycle-service.test.ts` (the tenant partition). The + * enumeration of every reader is `federated-injected-column-readers.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveInjectedColumnProvenance, unprovisionedInjectedColumns } from '@objectstack/spec/data'; +import { SchemaRegistry } from './registry.js'; +import { isFederatedObject, isFederatedUnprovisionedInjectedColumn } from './federated-object.js'; + +const REMOTE = 'remote_ds'; + +/** Federated, as the showcase declares its external objects: no column of the platform's own. */ +const FEDERATED = { + name: 'ext_customer', + label: 'External Customer', + datasource: REMOTE, + external: { remoteName: 'customers' }, + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** The same declaration with no `external` binding: the platform provisions its storage. */ +const LOCAL = { + name: 'acct', + label: 'Account', + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** + * Federated, with an `organization_id` and an `owner_id` the AUTHOR declared + * (each maps a real remote column), and one more lookup of the author's own. + */ +const FEDERATED_AUTHOR_COLUMNS = { + name: 'ext_tenant_customer', + label: 'External Tenant Customer', + datasource: REMOTE, + external: { remoteName: 'tenant_customers' }, + fields: { + name: { name: 'name', label: 'Name', type: 'text' as const }, + organization_id: { + name: 'organization_id', + label: 'Remote Organization', + type: 'lookup' as const, + reference: 'sys_organization', + }, + owner_id: { name: 'owner_id', label: 'Remote Owner', type: 'lookup' as const, reference: 'sys_user' }, + unit_ref: { name: 'unit_ref', label: 'Unit', type: 'lookup' as const, reference: 'sys_business_unit' }, + }, +}; + +function registered(...objects: any[]): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never); + for (const o of objects) registry.registerObject(o, 'test-21918'); + return registry; +} + +/** The injected lookups on a federated object, read off the registered schema's relation fields. */ +function relationFieldsOf(schema: any): string[] { + return Object.entries(schema?.fields ?? {}) + .filter(([, f]) => f?.type === 'lookup' || f?.type === 'master_detail') + .map(([name]) => name); +} + +describe('[#21918] isFederatedUnprovisionedInjectedColumn: every injected column a federated object does not provision', () => { + it('accepts every injected anchor of a federated object, the tenant anchor and every other one', () => { + const schema = registered(FEDERATED).getObject('ext_customer'); + expect(isFederatedObject(schema)).toBe(true); + + // PREMISE: the registry injected these lookups, and its provenance calls + // each one unprovisioned. The names are the measurement, not the decision. + const relations = relationFieldsOf(schema); + expect(relations).toEqual( + expect.arrayContaining(['organization_id', 'owning_business_unit_id', 'owner_id', 'created_by', 'updated_by']), + ); + + for (const column of relations) { + expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-unprovisioned'); + expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(true); + } + // The anchors past the tenant one, named, because they are what #21910's + // tenant-only predicate missed and the business-unit and user deletes hit. + expect(isFederatedUnprovisionedInjectedColumn(schema, 'owning_business_unit_id')).toBe(true); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'owner_id')).toBe(true); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_by')).toBe(true); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'updated_by')).toBe(true); + // And the non-lookup audit columns, which the same pass injects. + expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_at')).toBe(true); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'updated_at')).toBe(true); + }); + + it('agrees with the registry provenance on every field of every registered object, and asks it for nothing else', () => { + const registry = registered(FEDERATED, LOCAL, FEDERATED_AUTHOR_COLUMNS); + for (const name of ['ext_customer', 'acct', 'ext_tenant_customer']) { + const schema = registry.getObject(name); + const unprovisioned = new Set(unprovisionedInjectedColumns(schema)); + for (const column of [...Object.keys((schema as any)?.fields ?? {}), 'id', 'no_such_column']) { + expect(isFederatedUnprovisionedInjectedColumn(schema, column), `${name}.${column}`).toBe(unprovisioned.has(column)); + } + } + }); + + it('refuses a column the author declared on a federated object, including its own organization_id and owner_id', () => { + const schema = registered(FEDERATED_AUTHOR_COLUMNS).getObject('ext_tenant_customer'); + for (const column of ['organization_id', 'owner_id', 'unit_ref', 'name']) { + expect(resolveInjectedColumnProvenance(schema, column), column).toBe('author'); + expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(false); + } + // The anchors the author did NOT declare are still the registry's, and still unprovisioned. + expect(isFederatedUnprovisionedInjectedColumn(schema, 'owning_business_unit_id')).toBe(true); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'created_by')).toBe(true); + }); + + it('refuses every injected column of a LOCAL object: the platform provisions its storage', () => { + const schema = registered(LOCAL).getObject('acct'); + expect(isFederatedObject(schema)).toBe(false); + for (const column of relationFieldsOf(schema)) { + expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-provisioned'); + expect(isFederatedUnprovisionedInjectedColumn(schema, column), column).toBe(false); + } + }); + + it('refuses a column that is neither injected nor declared, and any input that is not an object', () => { + const schema = registered(FEDERATED).getObject('ext_customer'); + expect(resolveInjectedColumnProvenance(schema, 'id')).toBe('absent'); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'id')).toBe(false); + expect(isFederatedUnprovisionedInjectedColumn(schema, 'no_such_column')).toBe(false); + for (const input of [undefined, null, 'ext_customer', 42, []]) { + expect(isFederatedUnprovisionedInjectedColumn(input, 'organization_id')).toBe(false); + } + }); +}); diff --git a/packages/objectql/src/lifecycle/lifecycle-service.test.ts b/packages/objectql/src/lifecycle/lifecycle-service.test.ts index 7ca7213ee6f..b29220daad9 100644 --- a/packages/objectql/src/lifecycle/lifecycle-service.test.ts +++ b/packages/objectql/src/lifecycle/lifecycle-service.test.ts @@ -1634,6 +1634,55 @@ describe('LifecycleService.sweep — Archiver governance (#10528)', () => { expect(stores.readsFor('sys_audit_log')).toEqual([{ created_at: { $lt: isoCutoff('90d') } }]); expect(stores.archivedFrom('sys_audit_log')).toEqual(['audit-200d']); }); + + /* ---------------- [#21918] a federated object has no tenant partition ---------------- */ + + it('[#21918] archives a federated object in ONE global pass, never filtering on its injected organization_id', async () => { + // `ttl` on a column the author declared, beside `archive`: a federated + // object's `created_at` is the registry's injection too, so the due field + // is the author's here. Its `organization_id` is the injection, and the + // remote does not have it: the hot read refuses any filter naming it. + const FEDERATED_ARCHIVE_OBJ = { + name: 'ext_event_log', + datasource: 'remote_ds', + external: { remoteName: 'event_log' }, + fields: { expires_at: { name: 'expires_at', label: 'Expires At', type: 'datetime' } }, + lifecycle: { + class: 'audit', + ttl: { field: 'expires_at', expireAfter: '90d' }, + archive: { after: '90d', to: 'archive', keep: '7y' }, + }, + } as unknown as LifecycleObjectLike; + const stores = governedStores({ + ext_event_log: [ + { id: 'ev-200d', expires_at: at(-200 * DAY) }, + { id: 'ev-2d', expires_at: at(-2 * DAY) }, + ], + }); + const hotFind = stores.hot.find; + stores.hot.find = async (object: string, query: any) => { + if (JSON.stringify(query?.where ?? {}).includes('organization_id')) { + throw Object.assign(new Error(`unknown column organization_id on ${object}`), { code: 'INVALID_FILTER', status: 400 }); + } + return hotFind(object, query); + }; + const { engine } = captureEngine([FEDERATED_ARCHIVE_OBJ], { + driver: stores.hot, + datasources: { archive: stores.cold }, + findImpl: (object) => (object === 'sys_organization' ? [{ id: 'org_reg' }] : []), + }); + const settings = fakeSettings( + {}, + { org_reg: { retention_overrides: { ext_event_log: { expireAfter: '2y' } } } }, + ); + + const report = await service(engine, { getSettings: () => settings }).sweep(); + + expect(report.errors).toEqual([]); + expect(stores.readsFor('ext_event_log')).toEqual([{ expires_at: { $lt: isoCutoff('90d') } }]); + expect(stores.archivedFrom('ext_event_log')).toEqual(['ev-200d']); + expect(stores.remaining('ext_event_log')).toEqual(['ev-2d']); + }); }); describe('LifecycleService.sweep — space reclaim', () => { @@ -1867,6 +1916,88 @@ describe('LifecycleService.sweep — governance (P4)', () => { expect(report.alerts).toEqual([{ type: 'quota-exceeded', object: 'sys_job_run', rowCount: 50, quota: 10 }]); }); + + // [#21918] A tenant partition is a predicate on the row's `organization_id`. + // On a federated (ADR-0015 `external`) object that column is the registry's + // injection, which the remote does not provision, so a partitioned pass is + // refused by the driver as an unknown column. The spec accepts a `lifecycle` + // block beside `external`, so an operator's tenant-scoped override reaches it. + describe('[#21918] a federated object\'s injected organization_id is not a tenant partition', () => { + const FEDERATED_TTL_OBJ = { + name: 'ext_event', + datasource: 'remote_ds', + external: { remoteName: 'events' }, + fields: { expires_at: { name: 'expires_at', label: 'Expires At', type: 'datetime' } }, + lifecycle: { class: 'transient', ttl: { field: 'expires_at', expireAfter: '1d' } }, + } as unknown as LifecycleObjectLike; + /** The same declaration with no `external` binding: the platform provisions its storage. */ + const LOCAL_TTL_OBJ = { + name: 'ext_event', + fields: { expires_at: { name: 'expires_at', label: 'Expires At', type: 'datetime' } }, + lifecycle: { class: 'transient', ttl: { field: 'expires_at', expireAfter: '1d' } }, + } as unknown as LifecycleObjectLike; + const FEDERATED_DECLARED_TENANT_OBJ = { + ...FEDERATED_TTL_OBJ, + fields: { + ...(FEDERATED_TTL_OBJ as any).fields, + organization_id: { name: 'organization_id', label: 'Remote Org', type: 'lookup', reference: 'sys_organization' }, + }, + } as unknown as LifecycleObjectLike; + + /** The refusal the SQL driver answers for a filter on a column the remote does not have. */ + const unknownColumn = Object.assign( + new Error("A filter on object 'ext_event' names a column the database could not resolve (organization_id)."), + { code: 'INVALID_FILTER', status: 400 }, + ); + + function sweepWithTenantOverride(obj: LifecycleObjectLike, remoteLacksTenantColumn: boolean) { + const { engine, deletes, finds } = captureEngine([obj], { + findImpl: (object, options) => { + if (object === 'sys_organization') return [{ id: 'org_reg' }]; + if (remoteLacksTenantColumn && JSON.stringify(options?.where ?? {}).includes('organization_id')) { + throw unknownColumn; + } + return [{ id: 'ev_row' }]; + }, + }); + const settings = fakeSettings( + {}, + { org_reg: { retention_overrides: { ext_event: { expireAfter: '2y' } } } }, + ); + return service(engine, { getSettings: () => settings }) + .sweep() + .then((report) => ({ report, deletes, reads: finds.filter((f) => f.object === 'ext_event') })); + } + + it('reaps a federated object in ONE global pass, never filtering on its injected organization_id', async () => { + const { report, deletes, reads } = await sweepWithTenantOverride(FEDERATED_TTL_OBJ, true); + + expect(report.errors).toEqual([]); + expect(reads.map((r) => r.where)).toEqual([{ expires_at: { $lt: isoCutoff('1d') } }]); + expect(deletes.map((d) => d.where)).toEqual([{ id: 'ev_row' }]); + }); + + it('CONTROL: the same declaration on a LOCAL object keeps its per-tenant partition', async () => { + const { report, reads } = await sweepWithTenantOverride(LOCAL_TTL_OBJ, false); + + expect(report.errors).toEqual([]); + expect(reads.map((r) => r.where)).toEqual([ + { expires_at: { $lt: isoCutoff('2y') }, organization_id: 'org_reg' }, + { + expires_at: { $lt: isoCutoff('1d') }, + $or: [{ organization_id: { $nin: ['org_reg'] } }, { organization_id: null }], + }, + ]); + }); + + it('CONTROL: an organization_id the AUTHOR declared on a federated object keeps its partition', async () => { + const { report, reads } = await sweepWithTenantOverride(FEDERATED_DECLARED_TENANT_OBJ, false); + + expect(report.errors).toEqual([]); + expect(reads[0]?.where).toEqual({ expires_at: { $lt: isoCutoff('2y') }, organization_id: 'org_reg' }); + expect(reads).toHaveLength(2); + }); + }); }); // #8906 — the governance row-count probe used to fail into `catch { continue }`, From 1a131e4b4b8e0d248edfe616876e11857059f005 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:22:48 +0000 Subject: [PATCH 3/5] test: enumerate every engine reader of an injected column, and pin the business-unit and user delete doors Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../federated-injected-column-readers.test.ts | 549 ++++++++++++++++++ ...r-delete-federated-fixture.dogfood.test.ts | 179 ++++++ 2 files changed, 728 insertions(+) create mode 100644 packages/objectql/src/federated-injected-column-readers.test.ts create mode 100644 packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts new file mode 100644 index 00000000000..4bca2fad97b --- /dev/null +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -0,0 +1,549 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21918] The closing pin of the federated injected-anchor family: every + * engine reader of an injected column, each with its disposition toward a + * federated object, and a failure the day a reader appears without one. + * + * ## The family + * + * The registry injects the platform's columns into every object, ADR-0015 + * `external` ones included (the #7865 ruling, direction B), and the platform + * provisions no storage for a federated object. Each engine reader that read + * one of those columns as real storage failed on a federated object, one at a + * time: the read path (#7738), the cascade scan on the tenant anchor (#21910), + * then the cascade scan on every other anchor (#21918). Each fix found the + * next face only when a door refused. This file turns the remaining faces + * into a table, so the next one is a review question instead of an outage. + * + * ## The mechanism: a source scan over named seams + * + * An engine reader reaches an injected column of an arbitrary registered + * object through a small, closed set of seams, and this file scans every + * non-test source of `@objectstack/objectql` for each use of one: + * + * 1. the federated decisions and the provenance they read: + * `isFederatedObject`, `isFederatedUnprovisionedInjectedColumn`, + * `resolveInjectedColumnProvenance`, `unprovisionedInjectedColumns`, + * `platformProvisionsStorage`; + * 2. the relation-carrier arbiters, which are how engine code finds a relation + * field, and so how it reaches an injected lookup: `referenceCarrierOf`, + * `referenceTargetOf`; + * 3. the tenant-column resolver, `resolveTenantFieldName`, and the constant + * it resolves to, `DEFAULT_TENANT_FIELD`; + * 4. every spelling of an injected column's NAME: a string literal, an object + * literal key, or a `SystemFieldName` member. The names are not listed + * here. They are `injectedSystemColumnDefs` (`@objectstack/spec/data`) + * answered for a federated document, the same table the registry spreads. + * + * Each use is keyed `# :: `, which survives line + * churn and moves only when the code that reads moves. Every key the scan finds + * must have a row in {@link READERS}, and every row must still be found. A row + * whose disposition says the site ASKS a federated predicate is checked against + * the source: the site's own function calls it, or calls the one same-file + * helper the row names, which calls it. + * + * Why a scan and not a registry the readers call into: a registry only lists + * the readers that remembered to register, which is the population that was + * never the problem. The failures in this family were readers that did not + * know the question existed. A scan finds them by the seam they cannot avoid. + * + * What it cannot see, stated rather than implied: a reader that reaches an + * injected column through none of these seams, such as a column name built + * from a template or read out of a field map without a carrier arbiter. Such a + * reader is also one no other check in this package can find, and it adds no + * hole this file pretends to close. + * + * ## The dispositions + * + * Closed, in {@link Disposition}. Only the first three say what the site does + * on a federated object by asking a predicate, and they are the ones checked + * against the source. The rest say why the seam is not a storage read of a + * federated object's injected column at all, and their `why` is the claim a + * reviewer checks. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import ts from 'typescript'; +import { injectedSystemColumnDefs, ObjectSchema } from '@objectstack/spec/data'; +import { SystemFieldName } from '@objectstack/spec/system'; + +type Disposition = + /** Asks `isFederatedUnprovisionedInjectedColumn` and does not read the column on a federated object. */ + | 'skips' + /** Asks `isFederatedObject` and issues no injected-column predicate against a federated object. */ + | 'exempt' + /** Asks the provenance (`unprovisionedInjectedColumns`) and leaves unprovisioned columns out. */ + | 'excludes' + /** A column VALUE read off a row in hand selects the TARGET object's rows by id. */ + | 'target-by-id' + /** Reads the column's value off a row already in hand. No storage read. */ + | 'row-value' + /** Lowers, validates or evaluates a predicate the caller or the author wrote. */ + | 'caller-predicate' + /** Reads a relation an author's own declaration names (and infers from it). */ + | 'author-declared' + /** The column is the declared lifecycle policy's own subject, not a partition of it. */ + | 'policy-subject' + /** Stamps the column onto a record the caller writes. */ + | 'writer' + /** Names the column without reading any row: a vocabulary, an injection, a sync route, refusal text. */ + | 'not-a-read' + /** The predicate or resolver itself. */ + | 'definition'; + +/** The dispositions whose claim is a call in the source, and the calls that satisfy it. */ +const ASKS: Partial> = { + skips: ['isFederatedUnprovisionedInjectedColumn'], + exempt: ['isFederatedObject'], + excludes: ['unprovisionedInjectedColumns', 'resolveInjectedColumnProvenance'], +}; + +interface Row { + disposition: Disposition; + why: string; + /** For an ASKS disposition: the same-file function the site calls, which asks. */ + via?: string; +} + +/** The calls that are seams by name. */ +const SEAM_CALLS = new Set([ + 'isFederatedObject', + 'isFederatedUnprovisionedInjectedColumn', + 'resolveInjectedColumnProvenance', + 'unprovisionedInjectedColumns', + 'platformProvisionsStorage', + 'referenceCarrierOf', + 'referenceTargetOf', + 'resolveTenantFieldName', +]); + +const VOCABULARY = 'names the column in a name vocabulary; it reads no row'; +const SYNC_ROUTE = 'routes a federated object to the DDL-free binding; it reads no row'; +const PLATFORM_ROW = 'stamps a row of a platform table the engine itself writes'; +const VALIDATION_RULE = "a validation rule's own relation path, which the author wrote"; + +const READERS: Record = { + // ── The readers this family fixed, and the one decision they ask ───────── + 'engine.ts#cascadeDeleteRelations :: isFederatedUnprovisionedInjectedColumn()': { + disposition: 'skips', + why: 'the dependents probe never filters a federated object on a column it does not provision', + }, + 'engine.ts#cascadeDeleteRelations :: referenceCarrierOf()': { + disposition: 'skips', + why: 'finds the relations a delete probes; the skip above follows the reference match', + }, + 'engine.ts#planCascadeAtomicity :: isFederatedUnprovisionedInjectedColumn()': { + disposition: 'skips', + why: "the participant test is the scan's own, so the plan and the scan agree", + }, + 'engine.ts#planCascadeAtomicity :: referenceCarrierOf()': { + disposition: 'skips', + why: 'finds the participants the scan would probe; the same skip follows the reference match', + }, + 'lifecycle/lifecycle-service.ts#tenantWindowsFor :: isFederatedUnprovisionedInjectedColumn()': { + disposition: 'skips', + why: 'a federated object whose organization_id is the injection has no tenant partition', + }, + 'lifecycle/lifecycle-service.ts#tenantWindowsFor :: organization_id': { + disposition: 'skips', + why: 'the column the partition predicates name, asked about before any partition is built', + }, + 'lifecycle/lifecycle-service.ts#reap :: organization_id': { + disposition: 'skips', + via: 'tenantWindowsFor', + why: 'the per-tenant reap passes, built only from the windows the shared decision returns', + }, + 'lifecycle/lifecycle-service.ts#archiveObject :: organization_id': { + disposition: 'skips', + via: 'tenantWindowsFor', + why: 'the per-tenant archive passes, built only from the windows the shared decision returns', + }, + + // ── Readers that already asked whether the object is federated ────────── + 'engine.ts#buildDriverOptions :: isFederatedObject()': { + disposition: 'exempt', + why: 'the organization wall a read carries is withheld from a federated object', + }, + 'engine.ts#buildDriverOptions :: resolveTenantFieldName()': { + disposition: 'exempt', + why: 'resolves the wall column only after the federated exemption', + }, + 'engine.ts#resolvePredicateRelated :: isFederatedObject()': { + disposition: 'exempt', + why: "a federated related object is read through the caller's own read, never through a wall", + }, + 'engine.ts#resolvePredicateRelated :: referenceTargetOf()': { + disposition: 'exempt', + why: 'reads the related object by id; a federated one is routed through the caller', + }, + 'engine.ts#resolvePredicateRelated :: resolveTenantFieldName()': { + disposition: 'exempt', + why: 'decides the routing beside the federated test, on the related object, never as a filter', + }, + 'engine.ts#resolveSystemInsertOrganization :: isFederatedObject()': { + disposition: 'exempt', + why: 'a system insert into a federated object is never stamped with an organization', + }, + 'engine.ts#resolveSystemInsertOrganization :: resolveTenantFieldName()': { + disposition: 'exempt', + why: 'resolves the stamp column only after the federated exemption', + }, + + // ── Readers that read the provenance directly ─────────────────────────── + 'integrity/dangling-reference-audit.ts#auditableReferenceFields :: referenceTargetOf()': { + disposition: 'excludes', + why: 'the audit never counts an unprovisioned injected lookup as a reference', + }, + 'integrity/dangling-reference-audit.ts#auditableReferenceFields :: unprovisionedInjectedColumns()': { + disposition: 'excludes', + why: 'the provenance the exclusion reads', + }, + 'integrity/dangling-reference-audit.ts#organizationFieldOf :: resolveTenantFieldName()': { + disposition: 'excludes', + why: 'the audit partitions by organization only on a provisioned tenant column', + }, + 'integrity/dangling-reference-audit.ts#organizationFieldOf :: unprovisionedInjectedColumns()': { + disposition: 'excludes', + why: 'the provenance the exclusion reads', + }, + + // ── Seams that are not a storage read of a federated object's column ───── + 'engine.ts#assertReferencesResolve :: referenceTargetOf()': { + disposition: 'target-by-id', + why: "the id a caller wrote into a reference field is looked up on the TARGET object", + }, + 'engine.ts#expandRelatedRecords :: referenceTargetOf()': { + disposition: 'target-by-id', + why: 'the ids read off the rows in hand load the TARGET rows; a federated row carries none', + }, + 'record-title.ts#resolveRelatedTitleTarget :: referenceTargetOf()': { + disposition: 'target-by-id', + why: "resolves which object a related record's title is read from", + }, + 'engine.ts#eventOrganizationId :: resolveTenantFieldName()': { + disposition: 'row-value', + why: 'reads the tenant column off the written row; a federated row has none, so the event omits it', + }, + 'relation-filter-lowering.ts#admitRelationCondition :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: "lowers a relation condition the caller wrote; it names an injected column only when the caller did", + }, + 'validation/rule-validator.ts#collectPredicateRelationships :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: VALIDATION_RULE, + }, + 'validation/rule-validator.ts#readsAnOwnColumnItLacks :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: VALIDATION_RULE, + }, + 'validation/rule-validator.ts#referentialClearRefusal :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: VALIDATION_RULE, + }, + 'validation/rule-validator.ts#resolveTraversalScope :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: VALIDATION_RULE, + }, + 'validation/rule-validator.ts#unevaluableFieldRuleError :: referenceTargetOf()': { + disposition: 'caller-predicate', + why: VALIDATION_RULE, + }, + 'engine.ts#buildSummaryIndex :: referenceCarrierOf()': { + disposition: 'author-declared', + why: + "infers the foreign key of a roll-up an author declared, from the child's first relation to the " + + 'parent. Injected anchors point only at sys_organization, sys_business_unit and sys_user, so it can ' + + 'meet one only for a roll-up declared on one of those over a federated child with no relationshipField', + }, + 'lifecycle/lifecycle-service.ts#reapObject :: created_at': { + disposition: 'policy-subject', + why: + 'the age a declared retention reaps by. On a federated object the registry injects it, so a remote ' + + "without it refuses the filter and the sweep reports the object in its errors, loudly, every sweep", + }, + 'lifecycle/lifecycle-service.ts#archiveObject :: created_at': { + disposition: 'policy-subject', + why: "the age a declared archive selects by (when no ttl names another column), and the cold store's keep prune", + }, + 'plugin.ts#registerAuditHooks :: created_by': { + disposition: 'writer', + why: 'stamps the acting user onto a record the caller writes; it reads nothing', + }, + 'plugin.ts#registerAuditHooks :: updated_by': { + disposition: 'writer', + why: 'stamps the acting user onto a record the caller writes; it reads nothing', + }, + 'engine.ts#encryptSecretFields :: created_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, + 'engine.ts#recordObservedDeviation :: updated_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, + 'engine.ts#retractCreationAttestation :: updated_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, + 'engine.ts#syncObjectSchema :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, + 'engine.ts#syncSchemas :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, + 'plugin.ts#reconcileFederatedBindings :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, + 'plugin.ts#registerSchemasWithoutDdl :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, + 'plugin.ts#syncRegisteredSchemas :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, + 'declared-read-columns.ts# :: created_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'declared-read-columns.ts# :: updated_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'having-filter.ts#declaredReferenceNames :: created_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'having-filter.ts#declaredReferenceNames :: updated_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'no-operator-object-door.ts# :: created_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'no-operator-object-door.ts# :: updated_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'validation/record-validator.ts# :: created_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'validation/record-validator.ts# :: created_by': { disposition: 'not-a-read', why: VOCABULARY }, + 'validation/record-validator.ts# :: updated_at': { disposition: 'not-a-read', why: VOCABULARY }, + 'validation/record-validator.ts# :: updated_by': { disposition: 'not-a-read', why: VOCABULARY }, + 'util.ts#convertIntrospectedSchemaToObjects :: created_at': { + disposition: 'not-a-read', + why: "drafts objects from a datasource's introspected tables, skipping the platform's own columns", + }, + 'util.ts#convertIntrospectedSchemaToObjects :: updated_at': { + disposition: 'not-a-read', + why: "drafts objects from a datasource's introspected tables, skipping the platform's own columns", + }, + 'no-operator-object-door.ts#relationWords :: referenceTargetOf()': { + disposition: 'not-a-read', + why: 'names the related object in refusal text', + }, + 'registry.ts# :: organization_id': { + disposition: 'not-a-read', + why: 'the tenant index the registry declares on a provisioned tenant column', + }, + 'registry.ts# :: owning_business_unit_id': { + disposition: 'not-a-read', + why: 'the name the injection writes the ADR-0117 D1 anchor under', + }, + 'registry.ts#declaresTenantIndex :: organization_id': { + disposition: 'not-a-read', + why: 'reads an index declaration, not a row', + }, + 'tenancy/system-write-organization.ts# :: organization_id': { + disposition: 'not-a-read', + why: 'the declaration of the default tenant column name', + }, + 'tenancy/system-write-organization.ts#buildRefusalMessage :: organization_id': { + disposition: 'not-a-read', + why: 'names the column in refusal text', + }, + 'tenancy/system-write-organization.ts#resolveTenantFieldName :: organization_id': { + disposition: 'definition', + why: 'the tenant-column resolver every tenant reader above asks; it reads a schema, never a row', + }, + 'federated-object.ts#isFederatedUnprovisionedInjectedColumn :: isFederatedObject()': { + disposition: 'definition', + why: 'the general predicate', + }, + 'federated-object.ts#isFederatedUnprovisionedInjectedColumn :: resolveInjectedColumnProvenance()': { + disposition: 'definition', + why: 'the general predicate reads the registry provenance and names no column', + }, +}; + +// ── The scan ───────────────────────────────────────────────────────────── + +interface Scan { + /** Every seam use, keyed `# :: `. */ + seams: Set; + /** Every callee name each `#` calls. */ + calls: Map>; + files: string[]; + columns: ReadonlySet; +} + +/** + * The injected column names, read from the spec's own definition table for a + * federated document: the same table `applySystemFields` spreads, so a column + * the registry starts injecting tomorrow is scanned for tomorrow. + */ +function injectedColumnNames(): ReadonlySet { + return new Set(Object.keys(injectedSystemColumnDefs({ name: 'probe', external: { remoteName: 'probe' }, fields: {} }))); +} + +/** The name of the function a node sits in, or `` for top-level code. */ +function siteOf(node: ts.Node): string { + for (let p: ts.Node | undefined = node.parent; p; p = p.parent) { + if ( + (ts.isMethodDeclaration(p) || ts.isFunctionDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) && + p.name + ) { + return p.name.getText(); + } + if (ts.isConstructorDeclaration(p)) return 'constructor'; + const fnInit = (init: ts.Expression | undefined) => !!init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); + if (ts.isPropertyDeclaration(p) && fnInit(p.initializer)) return p.name.getText(); + if ( + ts.isVariableDeclaration(p) && + fnInit(p.initializer) && + ts.isVariableDeclarationList(p.parent) && + ts.isVariableStatement(p.parent.parent) && + ts.isSourceFile(p.parent.parent.parent) + ) { + return p.name.getText(); + } + } + return ''; +} + +function calleeName(call: ts.CallExpression): string | undefined { + const c = call.expression; + if (ts.isIdentifier(c)) return c.text; + if (ts.isPropertyAccessExpression(c)) return c.name.text; + return undefined; +} + +function scanObjectqlSources(): Scan { + // Located from THIS test file's own path, as `engine-middleware-operation-vocabulary.test.ts` + // does: the package's build config targets CommonJS, where `import.meta` is TS1470. + const testPath = expect.getState().testPath; + if (!testPath) throw new Error('vitest reported no testPath, so the reader scan cannot locate src/.'); + const srcDir = dirname(testPath); + const columns = injectedColumnNames(); + const fieldNameOf = SystemFieldName as unknown as Record; + + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (path.endsWith('.ts') && !path.endsWith('.test.ts') && !path.endsWith('.d.ts')) files.push(path); + } + }; + walk(srcDir); + + const seams = new Set(); + const calls = new Map>(); + for (const file of files) { + const rel = relative(srcDir, file).split(sep).join('/'); + const sf = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); + const visit = (n: ts.Node): void => { + const site = `${rel}#${siteOf(n)}`; + if (ts.isCallExpression(n)) { + const name = calleeName(n); + if (name) { + if (!calls.has(site)) calls.set(site, new Set()); + calls.get(site)!.add(name); + if (SEAM_CALLS.has(name)) seams.add(`${site} :: ${name}()`); + } + } + if ( + (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) && + columns.has(n.text) && + !ts.isImportDeclaration(n.parent) && + !ts.isExportDeclaration(n.parent) + ) { + seams.add(`${site} :: ${n.text}`); + } + if (ts.isIdentifier(n) && columns.has(n.text) && ts.isPropertyAssignment(n.parent) && n.parent.name === n) { + seams.add(`${site} :: ${n.text}`); + } + if ( + ts.isPropertyAccessExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === 'SystemFieldName' && + columns.has(fieldNameOf[n.name.text]) + ) { + seams.add(`${site} :: ${fieldNameOf[n.name.text]}`); + } + if ( + ts.isIdentifier(n) && + n.text === 'DEFAULT_TENANT_FIELD' && + !ts.isImportSpecifier(n.parent) && + !ts.isExportSpecifier(n.parent) && + !(ts.isVariableDeclaration(n.parent) && n.parent.name === n) + ) { + seams.add(`${site} :: organization_id`); + } + ts.forEachChild(n, visit); + }; + visit(sf); + } + return { seams, calls, files: files.map((f) => relative(srcDir, f).split(sep).join('/')), columns }; +} + +const siteKeyOf = (seamKey: string): string => seamKey.slice(0, seamKey.indexOf(' :: ')); + +describe('[#21918] every engine reader of an injected column has a disposition toward a federated object', () => { + it('scans the population it claims: the whole package source, for the injected columns the spec declares', () => { + const scan = scanObjectqlSources(); + expect(scan.files).toEqual( + expect.arrayContaining(['engine.ts', 'federated-object.ts', 'lifecycle/lifecycle-service.ts', 'registry.ts']), + ); + expect(scan.files.some((f) => f.endsWith('.test.ts'))).toBe(false); + // The column names come from the spec, so a vacuous answer would be empty or tenant-only. + expect([...scan.columns]).toEqual( + expect.arrayContaining(['organization_id', 'owning_business_unit_id', 'owner_id', 'created_by', 'updated_by']), + ); + }); + + it('has a row for every seam use in the source, and no row for a use that is gone', () => { + const scan = scanObjectqlSources(); + const unlisted = [...scan.seams].filter((k) => !(k in READERS)).sort(); + const stale = Object.keys(READERS).filter((k) => !scan.seams.has(k)).sort(); + expect( + unlisted, + 'An engine reader of an injected column has no disposition. Decide what it does on a federated object ' + + '(ADR-0015 `external`), whose injected columns the platform does not provision: ask ' + + '`isFederatedUnprovisionedInjectedColumn` and skip, or record why the seam reads no such column. Then add ' + + 'its row to READERS in this file.', + ).toEqual([]); + expect(stale, 'A READERS row names a seam use the source no longer has: delete or re-key the row.').toEqual([]); + }); + + it('a disposition that says the site asks a federated predicate is true of the source', () => { + const scan = scanObjectqlSources(); + for (const [key, row] of Object.entries(READERS)) { + const asks = ASKS[row.disposition]; + if (!asks) { + expect(row.via, `${key}: only an asking disposition names a via`).toBeUndefined(); + continue; + } + const site = siteKeyOf(key); + const own = scan.calls.get(site) ?? new Set(); + if (row.via) { + const file = site.slice(0, site.indexOf('#')); + const helper = scan.calls.get(`${file}#${row.via}`) ?? new Set(); + expect(own.has(row.via), `${key}: the site does not call ${row.via}`).toBe(true); + expect(asks.some((p) => helper.has(p)), `${key}: ${row.via} asks none of ${asks.join(', ')}`).toBe(true); + } else { + expect(asks.some((p) => own.has(p)), `${key}: the site asks none of ${asks.join(', ')}`).toBe(true); + } + } + }); + + it('the sites that skip are exactly the cascade scan, its plan, and the lifecycle tenant partition', () => { + const skipping = new Set( + Object.entries(READERS) + .filter(([, row]) => row.disposition === 'skips') + .map(([key]) => siteKeyOf(key)), + ); + expect([...skipping].sort()).toEqual([ + 'engine.ts#cascadeDeleteRelations', + 'engine.ts#planCascadeAtomicity', + 'lifecycle/lifecycle-service.ts#archiveObject', + 'lifecycle/lifecycle-service.ts#reap', + 'lifecycle/lifecycle-service.ts#tenantWindowsFor', + ]); + }); + + it('records why the lifecycle passes are in scope: the spec accepts a lifecycle policy on a federated object', () => { + // The lifecycle rows above are readers only because this parse succeeds. If + // the spec ever refuses `lifecycle` beside `external`, this fails and the + // rows can be re-judged as unreachable. + const federated = { + name: 'ext_event', + label: 'External Event', + datasource: 'remote_ds', + external: { remoteName: 'events' }, + fields: { expires_at: { type: 'datetime' as const, label: 'Expires At' } }, + }; + for (const lifecycle of [ + { class: 'telemetry', retention: { maxAge: '30d' } }, + { class: 'transient', ttl: { field: 'expires_at', expireAfter: '1d' } }, + { class: 'audit', retention: { maxAge: '90d' }, archive: { after: '90d', to: 'cold' } }, + ]) { + const parsed = ObjectSchema.safeParse({ ...federated, lifecycle }); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + } + }); +}); diff --git a/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts new file mode 100644 index 00000000000..ca364c0695e --- /dev/null +++ b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21918] With a federated object provisioned, an admin deletes a business + * unit and a user through the platform's own doors, and each answers 200. + * + * ## What was broken + * + * Deleting a record runs the engine's referential cascade scan + * (`ObjectQL.cascadeDeleteRelations`), which probes every registered `lookup` + * that references the deleted object. The registry injects the platform's + * anchors into a federated (ADR-0015 `external`) object too: the tenant anchor + * `organization_id`, the ADR-0117 D1 anchor `owning_business_unit_id`, and the + * owner and audit lookups `owner_id` / `created_by` / `updated_by`. None of them + * exists on the remote table. #21910 taught the scan to skip the tenant anchor + * alone, so on the showcase with its federated fixture: + * + * - an admin's `DELETE /api/v1/data/sys_business_unit/:id` answered 400, the + * driver refusing `showcase_ext_customer.owning_business_unit_id` + * (`INVALID_FILTER`, no such column); + * - removing a user answered 500, the same refusal on + * `showcase_ext_customer.created_by`, raised inside better-auth. + * + * The scan now skips every column the registry injected into a federated + * object and the object does not provision, read from the registry's own + * provenance (`isFederatedUnprovisionedInjectedColumn`). A lookup the author + * declares on a federated object is still probed, and its failure still + * propagates. That half is pinned at the seam, in + * `packages/objectql/src/engine-cascade-federated-tenant-anchor.test.ts`. + * + * ## The user-delete door + * + * The one HTTP door that deletes a user is better-auth's + * `POST /api/v1/auth/admin/remove-user`, which `plugin-auth` mounts when the + * better-auth admin plugin is on. `objectstack serve` turns that plugin on by + * default (`OS_AUTH_ADMIN`); this harness constructs `AuthPlugin` with no + * plugin options, so the route answers 404 here unless something turns it + * on. `OS_SCIM_ENABLED` is the one switch the harness reads that does (SCIM + * forces the admin plugin on, ADR-0134), the same knob + * `admin-credential-lifecycle.dogfood.test.ts` uses. `DELETE /data/sys_user/:id` + * is refused 405 by design (ADR-0092), and `/auth/delete-user` is unconfigured. + * + * The vendor route authorizes on the legacy `sys_user.role === 'admin'` + * scalar, which ADR-0068 D2 stopped writing, so a platform admin is refused + * 403 there by a recorded ruling. This file writes that scalar onto the admin + * row, as `plugin-auth`'s `remove-user-atomicity.test.ts` does, because this + * file's subject is the cascade the delete runs, not who may call the route. + * + * ## Premises, asserted on the same boot so a green delete cannot be vacuous + * + * - the fixture IS provisioned: the federated object answers its seeded rows, + * so neither delete can pass through the probe's missing-table branch; + * - each anchor the deletes would have probed is the platform's injection, + * unprovisioned (`resolveInjectedColumnProvenance`); + * - the remote really lacks those columns: a system read filtered on each one + * is refused. + * + * The working directory is a temporary one. The showcase's external datasource + * and its fixture both name a cwd-relative SQLite file, so this file's remote + * database is its own, never one another file left in the package directory. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveInjectedColumnProvenance } from '@objectstack/metadata-core'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The federated object the showcase ships, bound to the remote table `customers`. */ +const FEDERATED = 'showcase_ext_customer'; + +const SYSTEM_CTX = { isSystem: true }; + +const MEMBER_EMAIL = 'remove.me.21918@example.com'; + +async function findRows(ql: any, object: string, where: Record, limit = 50): Promise { + const rows = await ql.find(object, { where, limit, context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +describe('[#21918] business-unit and user deletes with the showcase federated fixture provisioned', () => { + let stack: VerifyStack; + let ql: any; + let token: string; + let orgId: string; + let prevCwd: string; + let dir: string; + let priorScim: string | undefined; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21918-')); + process.chdir(dir); + // The better-auth admin plugin, which mounts the remove-user door (see the header). + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + // Provision the remote tables, exactly as `os dev` does at boot (the + // harness imports only the stack's default export, so `onEnable` never + // runs on its own). + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { + orgContext: true, + databaseFile: join(dir, 'showcase.db'), + }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); + orgId = String(org.id); + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('PREMISE: the fixture is provisioned, and its remote table refuses a filter on each injected anchor', async () => { + const listed = await stack.apiAs(token, 'GET', `/data/${FEDERATED}`); + expect(listed.status, await listed.clone().text()).toBe(200); + const body: any = await listed.json(); + const rows: unknown[] = body?.records ?? body?.data ?? []; + expect(rows.length, 'the remote customers table answers its seeded rows').toBeGreaterThan(0); + + const schema = ql.getSchema(FEDERATED); + expect(schema?.external, `${FEDERATED} is federated`).toBeTruthy(); + for (const column of ['owning_business_unit_id', 'owner_id', 'created_by', 'updated_by']) { + expect(resolveInjectedColumnProvenance(schema, column), column).toBe('injected-unprovisioned'); + // The read the cascade scan used to issue: SYSTEM identity, filtered on + // the injected column. It is refused, so a scan that still probed this + // object could not answer either delete below with 200. + const refused: any = await findRows(ql, FEDERATED, { [column]: 'any_id' }, 1).then( + () => null, + (e: unknown) => e, + ); + expect(refused, `the remote has no ${column} column`).not.toBeNull(); + expect(refused.code, column).toBe('INVALID_FILTER'); + } + }); + + it('an admin deletes a business unit: 200, the row is gone, and the federated rows are untouched', async () => { + await ql.insert( + 'sys_business_unit', + { id: 'bu_21918', name: 'Doomed Unit', kind: 'department', organization_id: orgId, active: true }, + SYSTEM_CTX, + ); + expect(await findRows(ql, 'sys_business_unit', { id: 'bu_21918' }, 1)).toHaveLength(1); + const before = await findRows(ql, FEDERATED, {}, 500); + + const deleted = await stack.apiAs(token, 'DELETE', '/data/sys_business_unit/bu_21918'); + expect(deleted.status, await deleted.clone().text()).toBe(200); + + expect(await findRows(ql, 'sys_business_unit', { id: 'bu_21918' }, 1)).toHaveLength(0); + expect((await findRows(ql, FEDERATED, {}, 500)).length).toBe(before.length); + }); + + it('an admin removes a user: 200, the row is gone, and the federated rows are untouched', async () => { + await stack.signUp(MEMBER_EMAIL, 'Remove-Me-21918-Passw0rd!'); + const [member] = await findRows(ql, 'sys_user', { email: MEMBER_EMAIL }, 1); + expect(member, 'PREMISE: the member signed up').toBeTruthy(); + const before = await findRows(ql, FEDERATED, {}, 500); + + // The vendor route's own authorization input (see the header), then a fresh session that carries it. + const [admin] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + await ql.update('sys_user', { role: 'admin' }, { where: { id: admin.id }, context: SYSTEM_CTX }); + const adminToken = await stack.signIn(); + + const removed = await stack.apiAs(adminToken, 'POST', '/auth/admin/remove-user', { userId: String(member.id) }); + expect(removed.status, await removed.clone().text()).toBe(200); + + expect(await findRows(ql, 'sys_user', { id: String(member.id) }, 1)).toHaveLength(0); + expect((await findRows(ql, FEDERATED, {}, 500)).length).toBe(before.length); + }); +}); From bd18cf30c25044a3bcae58997d679ccff477d37f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 01:28:09 +0000 Subject: [PATCH 4/5] chore(changeset): objectql patch for the federated injected-anchor skip Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21918-federated-injected-anchors.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/21918-federated-injected-anchors.md diff --git a/.changeset/21918-federated-injected-anchors.md b/.changeset/21918-federated-injected-anchors.md new file mode 100644 index 00000000000..b597b27158a --- /dev/null +++ b/.changeset/21918-federated-injected-anchors.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': patch +--- + +Deleting a business unit or a user no longer fails on a deployment that has a federated (ADR-0015 `external`) object bound. The engine's referential cascade no longer treats any column the platform injects into a federated object as a reference. + +Clause-②: no + +- **What was wrong.** The registry injects its own columns into every object, federated ones included: the tenant anchor `organization_id`, the business-unit anchor `owning_business_unit_id`, the owner `owner_id`, and the audit lookups `created_by` and `updated_by`. The platform provisions no storage for a federated object, so none of them exists on the remote table. An earlier fix taught the cascade to skip `organization_id` alone. The cascade's dependents probe still filtered the remote table on the other anchors, the SQL driver refused the unknown column (`INVALID_FILTER`), and the failure propagated. On the showcase with its federated fixture provisioned, deleting a business unit answered 400 and removing a user answered 500. +- **What changed.** The cascade scan and its atomicity plan skip every column the registry injected into a federated object and the object does not provision. They read which columns those are from the registry's own injected-column provenance, not from a list of names, so a column the registry injects later is covered too. The lifecycle reap and archive passes no longer split a federated object's rows per tenant on its injected `organization_id`: such rows carry no organization, so a tenant-scoped retention override for that object has no rows to select, and the object is swept in one global pass. +- **What did not change.** A lookup the author declares on a federated object, including the author's own `organization_id` or `owner_id`, 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. From 85098a49af592a66fec2048e93c0fc97186905dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:38:07 +0000 Subject: [PATCH 5/5] test(objectql): the reader enumeration scans once, in one top-down walk Each of its three scanning tests re-parsed the package source and climbed from every node to the root to name its site: about 1.5 s per scan unloaded. Under the CPU contention a Test Core shard runs at, two of them measured past vitest's 5 s default timeout. The walk now hands the enclosing site down, the scan is computed once per run, and the tests that may pay for it declare an explicit budget. The READERS table is unchanged, and the pin's own exact-set assertions hold against it. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../federated-injected-column-readers.test.ts | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 4bca2fad97b..f41dd735485 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -360,29 +360,37 @@ function injectedColumnNames(): ReadonlySet { return new Set(Object.keys(injectedSystemColumnDefs({ name: 'probe', external: { remoteName: 'probe' }, fields: {} }))); } -/** The name of the function a node sits in, or `` for top-level code. */ -function siteOf(node: ts.Node): string { - for (let p: ts.Node | undefined = node.parent; p; p = p.parent) { - if ( - (ts.isMethodDeclaration(p) || ts.isFunctionDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) && - p.name - ) { - return p.name.getText(); - } - if (ts.isConstructorDeclaration(p)) return 'constructor'; - const fnInit = (init: ts.Expression | undefined) => !!init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); - if (ts.isPropertyDeclaration(p) && fnInit(p.initializer)) return p.name.getText(); - if ( - ts.isVariableDeclaration(p) && - fnInit(p.initializer) && - ts.isVariableDeclarationList(p.parent) && - ts.isVariableStatement(p.parent.parent) && - ts.isSourceFile(p.parent.parent.parent) - ) { - return p.name.getText(); - } +/** + * The name a node gives the code inside it when it is a function-like + * container, or `undefined` when it is not. A node's site is the name of its + * NEAREST such ancestor, or `` for top-level code. The walk below + * hands that name down as it descends, so each node's site costs nothing. + * The first version asked for every node by climbing to the root: O(nodes x + * depth) over 2.8 MB of source, about 1.5 s a scan and three scans a run. + * Under the CPU contention a Test Core shard runs at (four suites of three + * workers on four cores), two of those scans measured past vitest's 5 s + * default timeout. + */ +function containerName(p: ts.Node, sf: ts.SourceFile): string | undefined { + if ( + (ts.isMethodDeclaration(p) || ts.isFunctionDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) && + p.name + ) { + return p.name.getText(sf); } - return ''; + if (ts.isConstructorDeclaration(p)) return 'constructor'; + const fnInit = (init: ts.Expression | undefined) => !!init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)); + if (ts.isPropertyDeclaration(p) && fnInit(p.initializer)) return p.name.getText(sf); + if ( + ts.isVariableDeclaration(p) && + fnInit(p.initializer) && + ts.isVariableDeclarationList(p.parent) && + ts.isVariableStatement(p.parent.parent) && + ts.isSourceFile(p.parent.parent.parent) + ) { + return p.name.getText(sf); + } + return undefined; } function calleeName(call: ts.CallExpression): string | undefined { @@ -392,7 +400,19 @@ function calleeName(call: ts.CallExpression): string | undefined { return undefined; } +/** The one scan this file makes: every test reads the same answer. */ +let scanned: Scan | undefined; + +/** + * Parsing the package source is the one costly step in this file, so a test + * that may be the first to call this declares {@link SCAN_BUDGET_MS} rather + * than borrowing vitest's 5 s default, which is a budget for a unit, not for a + * parse of the whole package on a shared runner. + */ +const SCAN_BUDGET_MS = 30_000; + function scanObjectqlSources(): Scan { + if (scanned) return scanned; // Located from THIS test file's own path, as `engine-middleware-operation-vocabulary.test.ts` // does: the package's build config targets CommonJS, where `import.meta` is TS1470. const testPath = expect.getState().testPath; @@ -416,8 +436,8 @@ function scanObjectqlSources(): Scan { for (const file of files) { const rel = relative(srcDir, file).split(sep).join('/'); const sf = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); - const visit = (n: ts.Node): void => { - const site = `${rel}#${siteOf(n)}`; + const visit = (n: ts.Node, siteName: string): void => { + const site = `${rel}#${siteName}`; if (ts.isCallExpression(n)) { const name = calleeName(n); if (name) { @@ -454,11 +474,13 @@ function scanObjectqlSources(): Scan { ) { seams.add(`${site} :: organization_id`); } - ts.forEachChild(n, visit); + const inner = containerName(n, sf) ?? siteName; + ts.forEachChild(n, (child) => visit(child, inner)); }; - visit(sf); + visit(sf, ''); } - return { seams, calls, files: files.map((f) => relative(srcDir, f).split(sep).join('/')), columns }; + scanned = { seams, calls, files: files.map((f) => relative(srcDir, f).split(sep).join('/')), columns }; + return scanned; } const siteKeyOf = (seamKey: string): string => seamKey.slice(0, seamKey.indexOf(' :: ')); @@ -474,7 +496,7 @@ describe('[#21918] every engine reader of an injected column has a disposition t expect([...scan.columns]).toEqual( expect.arrayContaining(['organization_id', 'owning_business_unit_id', 'owner_id', 'created_by', 'updated_by']), ); - }); + }, SCAN_BUDGET_MS); it('has a row for every seam use in the source, and no row for a use that is gone', () => { const scan = scanObjectqlSources(); @@ -488,7 +510,7 @@ describe('[#21918] every engine reader of an injected column has a disposition t 'its row to READERS in this file.', ).toEqual([]); expect(stale, 'A READERS row names a seam use the source no longer has: delete or re-key the row.').toEqual([]); - }); + }, SCAN_BUDGET_MS); it('a disposition that says the site asks a federated predicate is true of the source', () => { const scan = scanObjectqlSources(); @@ -509,7 +531,7 @@ describe('[#21918] every engine reader of an injected column has a disposition t expect(asks.some((p) => own.has(p)), `${key}: the site asks none of ${asks.join(', ')}`).toBe(true); } } - }); + }, SCAN_BUDGET_MS); it('the sites that skip are exactly the cascade scan, its plan, and the lifecycle tenant partition', () => { const skipping = new Set(