Skip to content

Commit 3a440b0

Browse files
refactor(objectql)!: retire the PLATFORM_OBJECT_TENANCY ledger — the tenant-audit mute is decided by the schema, and the [tenant-audit] warning names both readings (#25210)
Fixes #25201 Part of the ADR-0131 18.x line (owed scope of #15212, D13). Takes #25203's item 4 (the stale "tracked as #14570" docblocks). Clause-②: no (narrowing) Metadata-protocol: none ## What changes - **The per-table ledger is retired.** `packages/objectql/src/tenancy/platform-object-tenancy.ts` is deleted, along with its six exports from `@objectstack/objectql`: `PLATFORM_OBJECT_TENANCY`, `classifyPlatformObjectTenancy`, `isPlatformObjectOutOfTenantAuditScope`, `tenantScopedPlatformObjects`, `PlatformObjectTenancy` and `PlatformObjectTenancyEntry`. - **The tenant-audit mute is decided by the schema.** In `Engine.buildDriverOptions`, an elevated write is auto-muted (`bypassTenantAudit: true`) only when the object has no tenant column (`resolveTenantFieldName(schema) === null`, i.e. `tenancy.enabled: false`, `systemFields.tenant: false` or no tenant field) or is federated. No name and no list decides it. That is ADR-0131 D9's "column ⇒ in scope", now applied to the audit as well. - **The `[tenant-audit]` warning** (`SqlDriver.auditMissingTenant`) no longer tells every unscoped write to "Pass tenantId". It names both readings, because the driver cannot tell them apart: - a write that belongs to one organization should run in that organization's context; - a write that crosses organizations on purpose (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's prefix and metadata are unchanged. - **Docblocks:** `system-write-organization.ts`, `approval-service.ts`, `business-unit-graph.ts` and the B3 test comment now describe the post-ADR-0131 write paths. Seed replay (C1) and elevated inserts (D9) stamp the organization, so a NULL member row is pre-18.0 residue or a lean composition. They no longer cite the ledger, nor #14570 as the tracking card (that card is no longer open). - **The census is repurposed, not removed.** `scripts/platform-object-tenancy-census.mjs` already executed the schema predicate (`resolveTenantFieldName` on the registered schema) and never read the ledger. Two things move: - its `HEADER` pointer now targets `resolveTenantFieldName`'s docblock in `system-write-organization.ts`; - the artefact's comment says `reach: in` is the whole of the engine's tenancy scope. The artefact is regenerated (the comment only; no row moved: 84 / 41 / 43). `lint.yml`'s step runs it unchanged and green, so no governed-path change is needed. Its stated readers: that gate, and `packages/cli/src/utils/organization-ownership-inventory.ts`. ## Measured before the change (base `9c431bad`) - **Readers of `isPlatformObjectOutOfTenantAuditScope`:** one behavioural reader, `engine.ts`'s `isTenantAuditInScope`. It only decided `opts.bypassTenantAudit`, which the SQL driver reads in `auditMissingTenant` to skip a WARN. Diagnostics only. The system-insert rule had no ledger reader left (stage 8b and #24667). - **Importers of the six exports outside `@objectstack/objectql`:** - `scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`, a dated one-off audit that imports `dist`. Nothing runs it (no `package.json` script, no workflow, no gate roster). It now refuses loudly at import, which its own docblock prescribes for a missing ledger. - No package imports them. - Because they are published names, removing them is still declared a narrowing. ## Acceptance notes `git grep PLATFORM_OBJECT_TENANCY`, outside CHANGELOGs, still finds files this PR may not or should not touch: - `.claude/skills/pm-dispatch/references/platform-readings.md:403` and `docs/adr/0131…:878` (governed); - `docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md` (a dated audit record); - `scripts/audits/14096-…mjs` (above). Also outside this card's surface: `packages/spec/src/automation/schedule-organization.zod.ts:76` (and its generated reference page) still cites the deleted file's path. ## Proof (head `aaa6ef6a`) - **CI:** every required context is green: `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core` (all six shards), `Build Core` and `Governed Surface Queue Guard`, plus `Check Changeset`. - **Pins, with ablations** (the commit was in place first; each restore is `git checkout HEAD -- <path>`, and the tree was clean afterwards): - **The mute is decided by the schema.** `tenancy-by-object-classification.test.ts` passes 28/28. Ablation: I re-added a per-table exemption for `sys_audit_entry` in `buildDriverOptions`. Exactly 2 pins went red: "sys_audit_entry … is NOT auto-muted" and "the SAME driver options — the name decides nothing". The other 26 stayed green, among them every write pin (derive, refusals, the stamped `tenantId`) for `sys_audit_entry` itself. So a per-table entry changes no write; it changes only the mute. - **The ledger's exports are gone.** A tombstone pin checks that the public API has no `PlatformObject*` name. - **The warning's new text.** `sql-driver-tenant-audit-posture.test.ts` passes 12/12. Ablation: restoring base `sql-driver.ts` turns exactly the new pin red (1 failed, 11 passed). - **Package suites and typecheck, all exit 0:** - objectql: 8643 passed; - driver-sql: 3749 passed, 212 skipped (live-dialect cells); - plugin-approvals: 1213; - plugin-sharing: 1200; - service-messaging: 628; - service-storage: 1130. - **`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`:** 100 commands, each run with its exit code recorded before any pipe. `--ran`: "100 derived famil(ies) accounted for — 99 run, 1 NOT-MEASURED". Two exits are non-zero, both shallow-clone prerequisites: - `check-plugin-teardown-shape --self-test`: exit 3, prerequisite not met; - `check-engine-split-ratio --days 90`: exit 2, refuses on a shallow clone. The census gate is green in both modes: 84 objects, 41 in reach, 43 out, every exclusion explained. - **`check-governed-merges --branch`:** NOT governed. +263/−501 over 14 files. - **R1 (fresh boot):** `pnpm dev -- --fresh` gave health 200. The seeded admin signs in (200) and reads `showcase_account` (200, 14 rows). There are 0 `[tenant-audit]` lines, since `single` does not audit. - **R4 (walled `os serve`):** - **Setup:** `OS_TENANCY_POSTURE=isolated` with a fresh SQLite file, `OS_SECRET_KEY`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only` and `OS_PLATFORM_OWNER_EMAIL`. The showcase declares no `@objectstack/organizations`, so for this run only I declared it locally and linked the workspace package. Both are reverted, and the tree is clean. - **Result:** health 200, no refusal. The owner signs up (200), signs in (200) and reads `sys_user` (200). - **What the audit printed:** 43 `[tenant-audit]` lines over boot and sign-up, every one with the new text, across 22 objects. 18 are `showcase_*` app objects, which were audited before too. The 4 platform objects now audited because the ledger's mute is gone are `sys_activity`, `sys_business_unit`, `sys_member` and `sys_user_permission_set`. - **Upgrade harness:** `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts` passes 15/15. That is 8 tests on the 18.0 fixture (ceremony, boot, data) plus 7 on the fixture-18.0.0 leg (boots HEAD with no refusal, no destructive drift, rows intact, the D1 advisory). ## Observation, not filed On R4 several of those lines are **creates**. They must carry `organization_id` on the row: under a wall an organization-less system insert is refused (D9), and a row that names its organization reaches the driver with no `tenantId` (pinned in `tenancy-by-object-classification.test.ts`). Examples are `create` on `sys_business_unit` and `bulkCreate` on `showcase_account`. For an insert the row's own column is the scope, so these lines are noise. That was already true for application objects before this PR; it now covers those 4 platform objects as well. A follow-up could have the driver skip the audit on an insert whose row carries its tenant field. That is the 8c-1 design space, outside this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01DWpoqyLrN4CEQFhSjFddQ9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 123ff6f commit 3a440b0

14 files changed

Lines changed: 263 additions & 501 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/driver-sql': patch
4+
---
5+
6+
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)
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: none
10+
11+
<!-- 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. -->
12+
13+
**BREAKING** for code that imports the ledger from `@objectstack/objectql`. It ships as `patch` because the metadata protocol is unchanged.
14+
15+
- **`@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.
16+
- **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.
17+
- **`@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.
18+
19+
**Migration** for code that imported the ledger:
20+
21+
- 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.
22+
- FROM `classifyPlatformObjectTenancy(name)` TO `resolveTenantFieldName(schema) !== null` on the registered schema.
23+
- FROM `isPlatformObjectOutOfTenantAuditScope(name)` TO `resolveTenantFieldName(schema) === null`.
24+
- FROM `tenantScopedPlatformObjects()` TO the census's `reach: "in"` rows.
25+
- FROM `PlatformObjectTenancy` TO delete it: no classification exists any more.
26+
- FROM `PlatformObjectTenancyEntry` TO delete it: no classification exists any more.

‎packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,31 @@ describe('#5262 — the verdict is taken LIVE, never frozen at first write', ()
199199
expect((driver as any)._multiTenantMode).toBeUndefined();
200200
});
201201
});
202+
203+
// ───────────────────────────────────────────────────────────────────────────
204+
// [ADR-0131 D8 / D13] What the line PRESCRIBES. Under a wall, an exempt
205+
// platform admin's cross-organization predicate update or delete reaches this
206+
// driver with no `tenantId` on purpose — the wall lifted the scope — and so
207+
// does a write that simply lost its organization. The driver cannot tell the
208+
// two apart, so the line must name the remedy for each and must not tell the
209+
// first to "pass tenantId", the one change that would break it.
210+
describe('the [tenant-audit] line names both readings and the remedy for each', () => {
211+
it('a wall-exempt cross-organization updateMany is not told to pass tenantId', async () => {
212+
setEnv({ posture: 'isolated' });
213+
await writeWithoutTenant('x1');
214+
warns = [];
215+
// The exempt admin's shape: a predicate update with no tenantId.
216+
await driver.updateMany('account', { where: { name: 'x1' } } as any, { name: 'renamed' });
217+
218+
const line = warns.find((w) => w.msg.includes('[tenant-audit] updateMany'));
219+
expect(line, 'positive control: the unscoped updateMany must warn').toBeDefined();
220+
expect(line!.msg).not.toMatch(/pass tenantId/i);
221+
// The deliberate reading and its declaration.
222+
expect(line!.msg).toContain('crosses organizations on purpose');
223+
expect(line!.msg).toContain('a platform admin the organization wall exempts');
224+
expect(line!.msg).toContain('bypassTenantAudit:true');
225+
// The lost-organization reading and its remedy.
226+
expect(line!.msg).toContain("run it in that organization's context");
227+
expect(line!.meta).toMatchObject({ object: 'account', op: 'updateMany', tenantField: 'organization_id' });
228+
});
229+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15980,6 +15980,15 @@ export class SqlDriver implements IDataDriver {
1598015980
* Throttled to one warning per `${object}:${op}` so background workers
1598115981
* don't spam the log. Set `options.bypassTenantAudit = true` (or env
1598215982
* `OS_TENANT_AUDIT=0`) to silence intentionally.
15983+
*
15984+
* The line names BOTH readings, because this driver cannot tell them apart:
15985+
* an unscoped write here is either one that lost its organization (fix:
15986+
* run it in that organization's context) or one the organization wall
15987+
* lifted the scope from on purpose — an exempt platform admin's
15988+
* cross-organization update or delete arrives exactly so, with no
15989+
* `tenantId` (ADR-0131 D8). Telling that second write to "pass tenantId"
15990+
* prescribes the one change that would break it, so ⛔ the remedy sentence
15991+
* never addresses the write as if it were only the first kind.
1598315992
*/
1598415993
protected auditMissingTenant(
1598515994
object: string,
@@ -16008,7 +16017,11 @@ export class SqlDriver implements IDataDriver {
1600816017
if (this.tenantAuditWarned.has(key)) return;
1600916018
this.tenantAuditWarned.add(key);
1601016019
this.logger.warn(
16011-
`[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.`,
16020+
`[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId — the statement is not confined to one organization. `
16021+
+ 'If the write belongs to one organization, run it in that organization\'s context (an ExecutionContext carrying its organization); '
16022+
+ 'the row\'s own organization column does not scope an update or a delete. '
16023+
+ 'If it crosses organizations on purpose (a platform admin the organization wall exempts, or a system job over every organization), '
16024+
+ 'nothing needs fixing: the caller declares it with bypassTenantAudit:true, which also silences this line.',
1601216025
{ object, op, tenantField: field },
1601316026
);
1601416027
}

‎packages/objectql/src/engine.ts‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,6 @@ import {
220220
SystemWriteOrganizationRequiredError,
221221
ORGANIZATION_OBJECT,
222222
} from './tenancy/system-write-organization.js';
223-
// [#13491] The per-object tenancy inventory that replaced the blanket
224-
// namespace exemption — the ONE reading both narrowed gates consult.
225-
import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object-tenancy.js';
226223
import { resolveTenancyPosture } from '@objectstack/types';
227224
import {
228225
normalizeTenancyPosture,
@@ -7536,15 +7533,18 @@ export class ObjectQL implements IObjectQLEngine {
75367533
const hasTenant = tenantScope !== undefined;
75377534
const hasTz = execCtx?.timezone !== undefined;
75387535
const isSystem = execCtx?.isSystem === true;
7539-
// [#13491] The object's tenancy classification — the reading the mute
7540-
// below narrows by (ADR-0131 C8 stage 8b retired its other reader, the
7541-
// system-insert exemption, under every posture). An object
7542-
// with no tenant field is out by construction; a platform object is out
7543-
// unless the inventory adjudicated it tenant-scoped.
7536+
// [ADR-0131 D9/D13] The tenant-audit's scope is the object's SCHEMA: a
7537+
// tenant column (the registered schema's, as `resolveTenantFieldName`
7538+
// reads it) on an object that is not federated. That is the same reading
7539+
// the system-insert rule takes, and there is no second, per-table one —
7540+
// the #13491 ledger that used to narrow this by name retired with D13. An
7541+
// object is outside the audit only by declaring itself outside the
7542+
// machinery (`tenancy.enabled: false`, `systemFields.tenant: false`, no
7543+
// tenant field, `external`), which `scripts/platform-object-tenancy-census.json`
7544+
// enumerates for the platform namespaces.
75447545
const isTenantAuditInScope =
75457546
resolveTenantFieldName(objectSchema) !== null &&
7546-
!isFederated &&
7547-
!isPlatformObjectOutOfTenantAuditScope(object);
7547+
!isFederated;
75487548
const preserveAudit = (execCtx as any)?.preserveAudit === true;
75497549
if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base;
75507550
const opts: any = base && typeof base === 'object' ? { ...base } : {};
@@ -7563,9 +7563,9 @@ export class ObjectQL implements IObjectQLEngine {
75637563
opts.timezone = execCtx!.timezone;
75647564
}
75657565
if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {
7566-
// [#13491] The isSystem mute NARROWED by the object's tenancy
7567-
// classification, in the same stroke as the guard above and by the same
7568-
// reading (2026-08-31 ruling, execution point 1).
7566+
// [#13491 → ADR-0131 D13] The isSystem mute, cut by the object's
7567+
// schema alone (2026-08-31 ruling, execution point 1: the OBJECT decides,
7568+
// never the caller's flag).
75697569
//
75707570
// It used to mute EVERY elevated write, whatever the object was. The
75717571
// census cited in commit e49d98896's message measured what that cost: 135 of 175 write call sites
@@ -7575,10 +7575,10 @@ export class ObjectQL implements IObjectQLEngine {
75757575
// were found by a person reading call sites.
75767576
//
75777577
// What still mutes: an object with no tenant field at all (nothing to be
7578-
// unscoped from — the driver would exit at `resolveTenantField` anyway),
7579-
// and a platform object the inventory classified `global` or has not
7580-
// adjudicated. What no longer mutes: a tenant-scoped object, platform
7581-
// namespace or not. A caller that means it still passes
7578+
// unscoped from — the driver would exit at `resolveTenantField` anyway)
7579+
// and a federated one. What does not: any object with a tenant column,
7580+
// platform namespace or not — a name prefix, or a hand-kept list of
7581+
// names, decides nothing here. A caller that means it still passes
75827582
// `bypassTenantAudit: true` explicitly and is honoured above — this
75837583
// branch only fills in a value nobody supplied.
75847584
//

‎packages/objectql/src/index.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -553,18 +553,6 @@ export {
553553
DEFAULT_TENANT_FIELD,
554554
} from './tenancy/system-write-organization.js';
555555

556-
// [#13491] The per-object tenancy inventory for the platform namespaces — the
557-
// 2026-08-31 ruling's replacement for the wholesale `sys_ / cloud_ / ai_`
558-
// exemption. Exported so the inventory is readable (and testable) from outside
559-
// the engine; the ledger itself is hand-adjudicated, never derived.
560-
export {
561-
PLATFORM_OBJECT_TENANCY,
562-
classifyPlatformObjectTenancy,
563-
isPlatformObjectOutOfTenantAuditScope,
564-
tenantScopedPlatformObjects,
565-
type PlatformObjectTenancy,
566-
type PlatformObjectTenancyEntry,
567-
} from './tenancy/platform-object-tenancy.js';
568556
export type {
569557
SystemWriteOrganizationDecision,
570558
SystemWriteRefusalReason,

0 commit comments

Comments
 (0)