Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/31849-federated-object-one-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-sharing': patch
'@objectstack/cli': patch
'@objectstack/service-analytics': patch
'@objectstack/trigger-schedule': patch
'@objectstack/metadata-protocol': patch
---

refactor: "is this object federated (ADR-0015 `external`)?" has one runtime binding, and it reads the spec predicate

Clause-②: no
Metadata-protocol: none

`@objectstack/core`'s `isFederatedObject` now returns `!platformProvisionsStorage(schema)` from `@objectstack/spec/data`, the one spelling of that question. `@objectstack/plugin-security` and `@objectstack/plugin-sharing` re-export core's binding from `federated-phantom-anchors.ts` instead of holding their own copies, so every existing import keeps resolving. The remaining hand-written copies of the `external` presence test now ask the same binding: the objectql search companion gate, `os migrate` (unmapped columns, the drift pass and the managed-drift coverage), the analytics federation gate (ADR-0062 D6), the schedule trigger's inert-scope bind line and the save door's datasource-binding read. Behaviour is unchanged on every object record and on `null`, `undefined`, arrays and primitives.
4 changes: 2 additions & 2 deletions packages/cli/src/commands/migrate/unmapped-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Command, Flags } from '@oclif/core';
import chalk from 'chalk';
import { objectNotFoundError } from '@objectstack/core';
import { isFederatedObject, objectNotFoundError } from '@objectstack/core';
import { keysetWalk } from '@objectstack/types';
import { StorageNameMapping } from '@objectstack/spec/system';
import type { ManagedDriftEntry } from '@objectstack/driver-sql';
Expand Down Expand Up @@ -350,7 +350,7 @@ export default class MigrateUnmappedColumns extends Command {
// it to. The read goes to that driver, and the document names it.
const objectDriver = stack.drivers.find((d) => d === bound) ?? null;
const outside =
declared.external != null
isFederatedObject(declared)
? 'it is federated (no managed table)'
: !bound
? 'it is bound to no driver'
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/schema-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import type {
} from '@objectstack/driver-sql';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, StorageNameMapping } from '@objectstack/spec/system';
import { isDevelopmentBoot } from '@objectstack/core';
import { isDevelopmentBoot, isFederatedObject } from '@objectstack/core';
import {
doctorNodeEnv,
provenanceOf,
Expand Down Expand Up @@ -785,7 +785,7 @@ function findDeclaredDatasourceDrivers(
if (!Array.isArray(objects) || typeof engine?.getDriverForObject !== 'function') return [];
const found = new Map<string, SqlDriverLike>();
for (const obj of objects as Array<{ name?: unknown; external?: unknown }>) {
if (obj?.external != null || typeof obj?.name !== 'string') continue;
if (isFederatedObject(obj) || typeof obj?.name !== 'string') continue;
const driver = engine.getDriverForObject(obj.name);
const name = driver?.name;
if (typeof name !== 'string' || !connected.has(name) || found.has(name) || planned.includes(driver)) continue;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/utils/schema-migration-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import fs from 'node:fs';
import {
isDevelopmentBoot,
isFederatedObject,
readDeploymentOrgScopingEntitlement,
resolveAuthSecret,
resolvePlatformAuthComposition,
Expand Down Expand Up @@ -2462,7 +2463,7 @@ export async function measureComposedCoverage(
// Federated objects have no managed table — `detectManagedDrift` could not
// diff one if it wanted to. Counted apart from a real gap rather than
// folded into it.
if (obj?.external != null) { federated++; continue; }
if (isFederatedObject(obj)) { federated++; continue; }
const driver = engine.getDriverForObject(obj.name);
if (!driver) { unbound++; continue; }
if (!plannedDrivers.includes(driver)) { otherDriver++; continue; }
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/security/tenant-field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `isFederatedObject` is the negation of the spec's `platformProvisionsStorage`
* and restates nothing of its own.
*
* The spec predicate is the one spelling of "the platform provisions this
* object's storage" (the #7865 convergence map). Every runtime layer asks
* core's binding (objectql, plugin-security and plugin-sharing re-export it),
* so the two must agree on every input, the inputs that are not object records
* included: there the spec answers "provisioned", which is what the presence
* test this binding replaced answered too.
*/

import { describe, it, expect } from 'vitest';
import { platformProvisionsStorage } from '@objectstack/spec/data';
import { isFederatedObject } from './tenant-field.js';
import { isFederatedObject as fromCoreBarrel } from '../index.js';

const CASES: ReadonlyArray<readonly [label: string, input: unknown, federated: boolean]> = [
['an object with no external block', {}, false],
['an object with an external block', { external: {} }, true],
['an object whose external is null', { external: null }, false],
['an object whose external is undefined', { external: undefined }, false],
['null', null, false],
['undefined', undefined, false],
['an array', [], false],
];

describe('isFederatedObject reads the spec predicate', () => {
it.each(CASES)('agrees with !platformProvisionsStorage on %s', (_label, input, federated) => {
expect(isFederatedObject(input)).toBe(!platformProvisionsStorage(input));
expect(isFederatedObject(input)).toBe(federated);
});

it('is the binding the package barrel exports', () => {
expect(fromCoreBarrel).toBe(isFederatedObject);
});
});
24 changes: 19 additions & 5 deletions packages/core/src/security/tenant-field.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { platformProvisionsStorage } from '@objectstack/spec/data';

/**
* The tenancy-scope predicates a layer below the engine has to ask too: which
* column an object is tenant-scoped by ({@link resolveTenantFieldName}, with
Expand Down Expand Up @@ -100,11 +102,23 @@ export function resolveTenantFieldName(schema: unknown): string | null {
* the #4632 durability ERROR, although nothing durable was lost. Every
* install-local install on a showcase host printed that false alarm.
*
* It is a PRESENCE test, `external != null`, and deliberately not a reading of
* the datasource's `schemaMode`. An object with no `external` block is not
* federated, whatever datasource it lands on. If its DDL is refused, that is a
* real lost sync, and it keeps the ERROR.
* It is a PRESENCE test on the object's own `external` block, and deliberately
* not a reading of the datasource's `schemaMode`. An object with no `external`
* block is not federated, whatever datasource it lands on. If its DDL is
* refused, that is a real lost sync, and it keeps the ERROR.
*
* ## It reads the spec's predicate and restates nothing
*
* `@objectstack/spec/data`'s `platformProvisionsStorage` is the one spelling
* of "the platform provisions this object's storage" (the #7865 convergence
* map), and a federated object is exactly one it does not. So this binding is
* its negation, and every runtime layer asks this binding (plugin-security and
* plugin-sharing re-export it under their own module paths) rather than
* re-spelling the `external` test. A change to what "federated" means is then
* one edit in the spec. On an input that is not an object record (`null`,
* `undefined`, an array, a primitive) the spec answers "provisioned", so this
* answers "not federated", as the presence test it replaced did.
*/
export function isFederatedObject(schema: unknown): boolean {
return (schema as { external?: unknown } | null | undefined)?.external != null;
return !platformProvisionsStorage(schema);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from '@objectstack/spec/api';
import { resolveItemLock, withItemLockFamily } from './item-lock.js';
import type { ItemAddress } from './item-lock.js';
import { isFederatedObject } from '@objectstack/core';
import { itemDiscriminator } from '@objectstack/metadata-core';
import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns, SeedSchema } from '@objectstack/spec/data';
import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
Expand Down Expand Up @@ -729,7 +730,7 @@ export async function objectsBoundExternallyTo(
const storedNames = new Set(stored.map((entry) => entry.name));
const take = (name: string, obj: unknown) => {
const o = obj as { datasource?: unknown; external?: unknown } | null | undefined;
if (o?.datasource === datasource && o.external !== undefined && o.external !== null) bound.set(name, o.external);
if (o?.datasource === datasource && isFederatedObject(o)) bound.set(name, o.external);
};
for (const entry of stored) take(entry.name, entry.data);
const registry = this.engine.registry;
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/engine-driver-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export function mergeDriverOptionsIntoHookView(
* vanish for authenticated org-context reads while anonymous reads still
* see them (#3249).
*
* 2. **`external != null`** — a federated object (ADR-0015), whose schema
* 2. **`isFederatedObject`** — a federated object (ADR-0015), whose schema
* is owned by the REMOTE database (#7738). The driver turns `tenantId`
* into `organization_id = :tenant`, and
* against a remote table that carries no such column that is a SQL error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ const READERS: Record<string, Row> = {
'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 },
'search-companion.ts#isDdlManaged :: isFederatedObject()': {
disposition: 'not-a-read',
why: 'declines to declare the __search companion column on an object the sync seam issues no DDL for; it reads no row',
},
'declared-read-columns.ts#<module> :: created_at': { disposition: 'not-a-read', why: VOCABULARY },
'declared-read-columns.ts#<module> :: updated_at': { disposition: 'not-a-read', why: VOCABULARY },
'having-filter.ts#declaredReferenceNames :: created_at': { disposition: 'not-a-read', why: VOCABULARY },
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/integrity/dangling-reference-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ interface AuditableField {
* recovery: consumer-side tolerance of a read that should never have been
* issued is the direction Prime Directive #12 rules against.
*
* ## Why PROVENANCE and not `external != null`
* ## Why PROVENANCE and not `isFederatedObject`
*
* A federated object MAY declare a real remote `organization_id` (or any other
* anchor name) — the author vouches for that column, it is genuinely there, and
Expand All @@ -458,7 +458,7 @@ function auditableReferenceFields(obj: AuditableObject): AuditableField[] {
const fields = obj?.fields;
if (!fields || typeof fields !== 'object') return [];
// Empty for every object the platform provisions storage for — the common
// case pays one `external == null` test per object and nothing else.
// case pays one `platformProvisionsStorage` test per object and nothing else.
const phantom = new Set(unprovisionedInjectedColumns(obj));
const out: AuditableField[] = [];
for (const [name, def] of Object.entries(fields)) {
Expand Down
8 changes: 4 additions & 4 deletions packages/objectql/src/search-companion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

import { resolveDisplayField, TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';
import { isFederatedObject } from './federated-object.js';

/**
* Name of the hidden companion column. Double-underscore prefixed so it can
Expand Down Expand Up @@ -220,9 +221,8 @@ export function resolveSearchCompanionSources(schema: CompanionObjectMeta | unde
* plugin.ts`, verbatim: "their schema is owned by the remote database, so DDL
* (syncSchema/initObjects) is forbidden and would throw".
*
* ⛔ The predicate is `external != null`, and it is deliberately the SAME
* expression that seam tests rather than a second question about the same
* fact. The two ends must agree by construction: every object the sync seam
* ⛔ The predicate is `isFederatedObject`, and it is deliberately the SAME
* binding that seam asks rather than a second question about the same fact. The two ends must agree by construction: every object the sync seam
* declines to build a column for is exactly an object this seam declines to
* declare one on. Asking the datasource's `schemaMode` here instead would be a
* second implementation of one rule — and this module cannot ask it anyway
Expand Down Expand Up @@ -252,7 +252,7 @@ export function resolveSearchCompanionSources(schema: CompanionObjectMeta | unde
* already-present entry before ever reaching this gate.
*/
function isDdlManaged(schema: CompanionObjectMeta): boolean {
return schema.external == null;
return !isFederatedObject(schema);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* This plugin holds no copy of the federated-object predicate: the export
* `federated-phantom-anchors.ts` keeps under its own module path is
* `@objectstack/core`'s binding itself, which reads the spec's
* `platformProvisionsStorage`. A local re-spelling would answer the same on
* every object record, so only identity can see one come back.
*/

import { describe, it, expect } from 'vitest';
import { isFederatedObject as coreIsFederatedObject } from '@objectstack/core';
import { isFederatedObject } from './federated-phantom-anchors.js';

describe('plugin-security asks core for "is this object federated?"', () => {
it('re-exports the core binding rather than restating it', () => {
expect(isFederatedObject).toBe(coreIsFederatedObject);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
* `applySystemFields` (the ObjectQL registry) injects `organization_id`,
* `owner_id`, `owning_business_unit_id` and the audit `*_by` lookups into every
* object that has not opted out — **including federated ones** (ADR-0015
* `external`). But `Engine.syncObjectSchema` returns EARLY for `external != null`
* and issues no DDL: the remote schema is owned externally. So for a federated
* `external`). But `Engine.syncObjectSchema` returns EARLY for a federated
* object and issues no DDL: the remote schema is owned externally. So for a federated
* object those columns exist in the registered schema and **nowhere else**.
* Measured on the shipped showcase at `origin/main` @ `b54aaab`:
*
Expand Down Expand Up @@ -69,6 +69,8 @@
* does today. The fail direction is toward isolation, never toward exposure.
*/

import { isFederatedObject } from '@objectstack/core';

// [#6562] The injected-column DEFINITION table lives in `@objectstack/metadata-core`
// (the registry that provisions the columns re-exports it from there, and the `/meta`
// read path consumes the same one). Importing the constant — rather than restating
Expand Down Expand Up @@ -113,10 +115,12 @@ function equalsShippedDef(def: unknown, shipped: Readonly<Record<string, unknown
/**
* Is `schema` a federated (ADR-0015 `external`) object binding a remote table?
* The platform provisions no storage for one, so nothing it injects is real.
*
* Re-exported, not restated: `@objectstack/core`'s binding is the runtime's one
* answer (it reads the spec's `platformProvisionsStorage`), and this module path
* keeps exporting it so every importer here resolves to that same function.
*/
export function isFederatedObject(schema: unknown): boolean {
return (schema as { external?: unknown } | null | undefined)?.external != null;
}
export { isFederatedObject };

/**
* Does this object's `organization_id` exist only in the registry — i.e. is it
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ interface ObjectSecurityMeta {
* backing store, because `syncObjectSchema` issues no DDL for a federated
* object. Layer 0 reads this to answer "is this a tenant object?" truthfully;
* see `federated-phantom-anchors.ts` for the provenance test and for why the
* question is provenance rather than `external != null`.
* question is provenance rather than `isFederatedObject`.
*
* `false` on every local object and on a federated object that declares a
* real remote `organization_id` — both keep the tenant wall exactly as it is.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* This plugin holds no copy of the federated-object predicate: the export
* `federated-phantom-anchors.ts` keeps under its own module path is
* `@objectstack/core`'s binding itself, which reads the spec's
* `platformProvisionsStorage`. A local wrapper would answer the same on every
* object record, so only identity can see one come back.
*/

import { describe, it, expect } from 'vitest';
import { isFederatedObject as coreIsFederatedObject } from '@objectstack/core';
import { isFederatedObject } from './federated-phantom-anchors.js';

describe('plugin-sharing asks core for "is this object federated?"', () => {
it('re-exports the core binding rather than wrapping it', () => {
expect(isFederatedObject).toBe(coreIsFederatedObject);
});
});
15 changes: 8 additions & 7 deletions packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
*
* `applySystemFields` (the ObjectQL registry) injects `owner_id` into every
* object that has not opted out — **including federated ones** (ADR-0015
* `external`) — while `Engine.syncObjectSchema` returns EARLY for
* `external != null` and issues no DDL, because the remote schema is owned
* `external`) — while `Engine.syncObjectSchema` returns EARLY for a
* federated object and issues no DDL, because the remote schema is owned
* externally. So for a federated object `owner_id` exists in the registered
* schema and **nowhere else**. Measured on a booted showcase stack at
* `origin/main` @ `b54aaab`, with a federated object carrying no ADR-0090 D1
Expand Down Expand Up @@ -85,17 +85,18 @@
* `columnMap` arm to carry.
*/

import { isOwnerAnchorUnprovable, platformProvisionsStorage } from '@objectstack/spec/data';
import { isFederatedObject } from '@objectstack/core';
import { isOwnerAnchorUnprovable } from '@objectstack/spec/data';

/**
* Is `schema` a federated (ADR-0015 `external`) object binding a remote table?
* The platform provisions no storage for one, so nothing it injects is real.
* The spec's `platformProvisionsStorage` is the one spelling of that predicate
* (#7865's convergence map), so this reads it rather than restating it.
* (#7865's convergence map). `@objectstack/core`'s binding reads it, and this
* module path re-exports that binding rather than wrapping it again, so the
* runtime has one function for the question.
*/
export function isFederatedObject(schema: unknown): boolean {
return !platformProvisionsStorage(schema);
}
export { isFederatedObject };

/**
* Does this object's `owner_id` exist only in the registry — i.e. is it the
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-sharing/src/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1721,7 +1721,7 @@ export class SharingService implements ISharingService {
// not an owner column either — it is the one case `hasOwnerField` answers YES
// about a column that is not there. The platform provisions no storage for a
// federated object (`Engine.syncObjectSchema` returns early for
// `external != null`), so the ownership fast-path behind `checkEdit` /
// `isFederatedObject`), so the ownership fast-path behind `checkEdit` /
// `checkDelete` selects `owner_id` off a remote table that has no such column.
//
// MEASURED on a booted showcase stack (SQLite external datasource, an
Expand Down
Loading
Loading