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. 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/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-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts new file mode 100644 index 00000000000..f41dd735485 --- /dev/null +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -0,0 +1,571 @@ +// 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 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); + } + 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 { + const c = call.expression; + if (ts.isIdentifier(c)) return c.text; + if (ts.isPropertyAccessExpression(c)) return c.name.text; + 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; + 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, siteName: string): void => { + const site = `${rel}#${siteName}`; + 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`); + } + const inner = containerName(n, sf) ?? siteName; + ts.forEachChild(n, (child) => visit(child, inner)); + }; + visit(sf, ''); + } + 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(' :: ')); + +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']), + ); + }, 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(); + 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([]); + }, SCAN_BUDGET_MS); + + 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); + } + } + }, SCAN_BUDGET_MS); + + 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/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/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.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 }`, 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. 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); + }); +});