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
26 changes: 26 additions & 0 deletions .changeset/25201-platform-object-tenancy-ledger-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/objectql': patch
'@objectstack/driver-sql': patch
---

refactor(objectql)!: the per-table platform-object tenancy ledger is retired, and the tenant-audit mute is decided by the object's schema (ADR-0131 D13)

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#PLATFORM_OBJECT_TENANCY, @objectstack/objectql#classifyPlatformObjectTenancy, @objectstack/objectql#isPlatformObjectOutOfTenantAuditScope, @objectstack/objectql#tenantScopedPlatformObjects, @objectstack/objectql#PlatformObjectTenancy, @objectstack/objectql#PlatformObjectTenancyEntry) The only author-reachable removals are runtime exports of `@objectstack/objectql`'s published entry: the ledger table, its three predicates and its two types. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->

**BREAKING** for code that imports the ledger from `@objectstack/objectql`. It ships as `patch` because the metadata protocol is unchanged.

- **`@objectstack/objectql`: the ledger and its exports are gone.** No write ever depended on them: since ADR-0131 C8 stage 8b, every object with a tenant column takes the system-write rule's derived organization or is refused, whatever its name. The ledger's last reader was the engine's tenant-audit mute, which is now read off the schema.
- **The tenant-audit mute (`bypassTenantAudit` on an elevated write) is decided by the object's schema alone.** An elevated write is muted only when the object has no tenant column (`tenancy.enabled: false`, `systemFields.tenant: false`, or no tenant field) or is federated. A platform-namespace object that has a tenant column is now audited like any other. On a walled posture (`isolated` / `group`), an elevated write on such an object that reaches the SQL driver with no `tenantId` therefore prints the `[tenant-audit]` warning, once per object and operation. Nothing a write touches changes: the mute only decides whether that line is printed.
- **`@objectstack/driver-sql`: the `[tenant-audit]` warning names both readings.** It no longer tells every unscoped write to "pass tenantId". A write that belongs to one organization should run in that organization's context. A write that crosses organizations on purpose, such as a platform admin the organization wall exempts or a system job over every organization, needs no fix, and the caller declares it with `bypassTenantAudit: true`. The line still starts `[tenant-audit] <op> on tenant-scoped object "<object>"`, and its metadata is unchanged.

**Migration** for code that imported the ledger:

- FROM `PLATFORM_OBJECT_TENANCY` TO the object's schema: an object is in tenant scope when `resolveTenantFieldName(schema)` (still exported) answers non-null. `scripts/platform-object-tenancy-census.json` in the repository lists, per platform object, the schema declaration that puts it outside.
- FROM `classifyPlatformObjectTenancy(name)` TO `resolveTenantFieldName(schema) !== null` on the registered schema.
- FROM `isPlatformObjectOutOfTenantAuditScope(name)` TO `resolveTenantFieldName(schema) === null`.
- FROM `tenantScopedPlatformObjects()` TO the census's `reach: "in"` rows.
- FROM `PlatformObjectTenancy` TO delete it: no classification exists any more.
- FROM `PlatformObjectTenancyEntry` TO delete it: no classification exists any more.
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,31 @@ describe('#5262 — the verdict is taken LIVE, never frozen at first write', ()
expect((driver as any)._multiTenantMode).toBeUndefined();
});
});

// ───────────────────────────────────────────────────────────────────────────
// [ADR-0131 D8 / D13] What the line PRESCRIBES. Under a wall, an exempt
// platform admin's cross-organization predicate update or delete reaches this
// driver with no `tenantId` on purpose — the wall lifted the scope — and so
// does a write that simply lost its organization. The driver cannot tell the
// two apart, so the line must name the remedy for each and must not tell the
// first to "pass tenantId", the one change that would break it.
describe('the [tenant-audit] line names both readings and the remedy for each', () => {
it('a wall-exempt cross-organization updateMany is not told to pass tenantId', async () => {
setEnv({ posture: 'isolated' });
await writeWithoutTenant('x1');
warns = [];
// The exempt admin's shape: a predicate update with no tenantId.
await driver.updateMany('account', { where: { name: 'x1' } } as any, { name: 'renamed' });

const line = warns.find((w) => w.msg.includes('[tenant-audit] updateMany'));
expect(line, 'positive control: the unscoped updateMany must warn').toBeDefined();
expect(line!.msg).not.toMatch(/pass tenantId/i);
// The deliberate reading and its declaration.
expect(line!.msg).toContain('crosses organizations on purpose');
expect(line!.msg).toContain('a platform admin the organization wall exempts');
expect(line!.msg).toContain('bypassTenantAudit:true');
// The lost-organization reading and its remedy.
expect(line!.msg).toContain("run it in that organization's context");
expect(line!.meta).toMatchObject({ object: 'account', op: 'updateMany', tenantField: 'organization_id' });
});
});
15 changes: 14 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15980,6 +15980,15 @@ export class SqlDriver implements IDataDriver {
* Throttled to one warning per `${object}:${op}` so background workers
* don't spam the log. Set `options.bypassTenantAudit = true` (or env
* `OS_TENANT_AUDIT=0`) to silence intentionally.
*
* The line names BOTH readings, because this driver cannot tell them apart:
* an unscoped write here is either one that lost its organization (fix:
* run it in that organization's context) or one the organization wall
* lifted the scope from on purpose — an exempt platform admin's
* cross-organization update or delete arrives exactly so, with no
* `tenantId` (ADR-0131 D8). Telling that second write to "pass tenantId"
* prescribes the one change that would break it, so ⛔ the remedy sentence
* never addresses the write as if it were only the first kind.
*/
protected auditMissingTenant(
object: string,
Expand Down Expand Up @@ -16008,7 +16017,11 @@ export class SqlDriver implements IDataDriver {
if (this.tenantAuditWarned.has(key)) return;
this.tenantAuditWarned.add(key);
this.logger.warn(
`[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId — writes will not be tenant-isolated. Pass tenantId via ExecutionContext or set bypassTenantAudit:true to silence.`,
`[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId — the statement is not confined to one organization. `
+ 'If the write belongs to one organization, run it in that organization\'s context (an ExecutionContext carrying its organization); '
+ 'the row\'s own organization column does not scope an update or a delete. '
+ 'If it crosses organizations on purpose (a platform admin the organization wall exempts, or a system job over every organization), '
+ 'nothing needs fixing: the caller declares it with bypassTenantAudit:true, which also silences this line.',
{ object, op, tenantField: field },
);
}
Expand Down
34 changes: 17 additions & 17 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,6 @@ import {
SystemWriteOrganizationRequiredError,
ORGANIZATION_OBJECT,
} from './tenancy/system-write-organization.js';
// [#13491] The per-object tenancy inventory that replaced the blanket
// namespace exemption — the ONE reading both narrowed gates consult.
import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js';
import { resolveTenancyPosture } from '@objectstack/types';
import {
normalizeTenancyPosture,
Expand Down Expand Up @@ -7536,15 +7533,18 @@ export class ObjectQL implements IObjectQLEngine {
const hasTenant = tenantScope !== undefined;
const hasTz = execCtx?.timezone !== undefined;
const isSystem = execCtx?.isSystem === true;
// [#13491] The object's tenancy classification — the reading the mute
// below narrows by (ADR-0131 C8 stage 8b retired its other reader, the
// system-insert exemption, under every posture). An object
// with no tenant field is out by construction; a platform object is out
// unless the inventory adjudicated it tenant-scoped.
// [ADR-0131 D9/D13] The tenant-audit's scope is the object's SCHEMA: a
// tenant column (the registered schema's, as `resolveTenantFieldName`
// reads it) on an object that is not federated. That is the same reading
// the system-insert rule takes, and there is no second, per-table one —
// the #13491 ledger that used to narrow this by name retired with D13. An
// object is outside the audit only by declaring itself outside the
// machinery (`tenancy.enabled: false`, `systemFields.tenant: false`, no
// tenant field, `external`), which `scripts/platform-object-tenancy-census.json`
// enumerates for the platform namespaces.
const isTenantAuditInScope =
resolveTenantFieldName(objectSchema) !== null &&
!isFederated &&
!isPlatformObjectOutOfTenantAuditScope(object);
!isFederated;
const preserveAudit = (execCtx as any)?.preserveAudit === true;
if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base;
const opts: any = base && typeof base === 'object' ? { ...base } : {};
Expand All @@ -7563,9 +7563,9 @@ export class ObjectQL implements IObjectQLEngine {
opts.timezone = execCtx!.timezone;
}
if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {
// [#13491] The isSystem mute NARROWED by the object's tenancy
// classification, in the same stroke as the guard above and by the same
// reading (2026-08-31 ruling, execution point 1).
// [#13491 → ADR-0131 D13] The isSystem mute, cut by the object's
// schema alone (2026-08-31 ruling, execution point 1: the OBJECT decides,
// never the caller's flag).
//
// It used to mute EVERY elevated write, whatever the object was. The
// census cited in commit e49d98896's message measured what that cost: 135 of 175 write call sites
Expand All @@ -7575,10 +7575,10 @@ export class ObjectQL implements IObjectQLEngine {
// were found by a person reading call sites.
//
// What still mutes: an object with no tenant field at all (nothing to be
// unscoped from — the driver would exit at `resolveTenantField` anyway),
// and a platform object the inventory classified `global` or has not
// adjudicated. What no longer mutes: a tenant-scoped object, platform
// namespace or not. A caller that means it still passes
// unscoped from — the driver would exit at `resolveTenantField` anyway)
// and a federated one. What does not: any object with a tenant column,
// platform namespace or not — a name prefix, or a hand-kept list of
// names, decides nothing here. A caller that means it still passes
// `bypassTenantAudit: true` explicitly and is honoured above — this
// branch only fills in a value nobody supplied.
//
Expand Down
12 changes: 0 additions & 12 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,18 +553,6 @@ export {
DEFAULT_TENANT_FIELD,
} from './tenancy/system-write-organization.js';

// [#13491] The per-object tenancy inventory for the platform namespaces — the
// 2026-08-31 ruling's replacement for the wholesale `sys_ / cloud_ / ai_`
// exemption. Exported so the inventory is readable (and testable) from outside
// the engine; the ledger itself is hand-adjudicated, never derived.
export {
PLATFORM_OBJECT_TENANCY,
classifyPlatformObjectTenancy,
isPlatformObjectOutOfTenantAuditScope,
tenantScopedPlatformObjects,
type PlatformObjectTenancy,
type PlatformObjectTenancyEntry,
} from './tenancy/platform-object-tenancy.js';
export type {
SystemWriteOrganizationDecision,
SystemWriteRefusalReason,
Expand Down
Loading
Loading