From 49e26952056e405465be2a8f1bda343437af654b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:09:24 +0000 Subject: [PATCH 1/2] fix(objectql): cut the tenant-audit control's scope by the object's tenancy, not the caller's flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-31 (联案 #13491 + #13497, verbatim「同意」): the tenant-audit control's scope is decided per OBJECT, not by the caller's `isSystem` flag. `isSystem` x a tenant-scoped object is IN scope; `isSystem` x a genuinely global object is out, with #8672's reasoning inheriting per object rather than exempting the `sys_`/`cloud_`/`ai_` namespace wholesale. Two gates narrow by one classification: the blanket `isPlatformNamespaceObject` short-circuit in `resolveSystemInsertOrganization`, and the engine's `bypassTenantAudit` isSystem mute (the #13178 census measured it silencing 135 of 175 write call sites). The classification cannot be read off the schema: `applySystemFields` provisions `organization_id` unconditionally, so 59 of the 84 platform-namespace objects in this tree carry the column, `sys_permission_set` included. The inventory is therefore hand-adjudicated in `packages/objectql/src/tenancy/platform-object-tenancy.ts`, with an `unclassified` verdict that keeps today's behaviour for anything the evidence does not settle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- ...nt-audit-scope-by-object-classification.md | 47 +++ packages/objectql/src/engine.ts | 51 +++- packages/objectql/src/index.ts | 13 + .../src/system-write-organization.test.ts | 12 +- .../tenancy-by-object-classification.test.ts | 271 ++++++++++++++++++ .../src/tenancy/platform-object-tenancy.ts | 234 +++++++++++++++ .../src/tenancy/system-write-organization.ts | 39 ++- 7 files changed, 648 insertions(+), 19 deletions(-) create mode 100644 .changeset/tenant-audit-scope-by-object-classification.md create mode 100644 packages/objectql/src/tenancy-by-object-classification.test.ts create mode 100644 packages/objectql/src/tenancy/platform-object-tenancy.ts diff --git a/.changeset/tenant-audit-scope-by-object-classification.md b/.changeset/tenant-audit-scope-by-object-classification.md new file mode 100644 index 0000000000..8c77e2ac67 --- /dev/null +++ b/.changeset/tenant-audit-scope-by-object-classification.md @@ -0,0 +1,47 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql): cut the tenant-audit control's scope by the OBJECT's tenancy, not the caller's `isSystem` flag (#13491) + +Maintainer ruling, 2026-08-31 (联案 #13491 + #13497, verbatim「同意」). The batch #9 +ruling — "`isSystem` writes are out of this control's scope" — was narrowed by its +own look-back clause, which #13497's measurement fired: + +- **`isSystem` x a tenant-scoped object = IN scope.** A system write that lands an + org-less row on a tenant-scoped object is the defect class the control exists + for. It has occurred five times (#12745, #12928, #10673, #8617, cloud#1239 — one + a credentials table) and every instance was found by a person reading call + sites, never by the control. +- **`isSystem` x a genuinely global object = OUT of scope.** #8672's reasoning + ("an org-less row is defensible for `sys_permission_set`") now inherits **per + object**; the wholesale `sys_` / `cloud_` / `ai_` namespace exemption is + withdrawn. + +Two gates narrow by that one classification: + +1. `resolveSystemInsertOrganization`'s blanket + `isPlatformNamespaceObject(object)` short-circuit, so an admitted platform + object reaches #8844's existing derive/refuse machinery (multi-organization + refusal branch included); +2. the engine's `bypassTenantAudit` `isSystem` mute, which used to silence EVERY + elevated write — the #13178 census measured 135 of 175 write call sites (77%) + silenced at it, the control's largest gate sitting ahead of the condition the + control is about. + +**Why a hand-adjudicated ledger and not a schema read.** `applySystemFields` +provisions the `organization_id` COLUMN unconditionally — its existence was +deliberately decoupled from whether tenancy is on. Measured by AST census of every +`ObjectSchema.create` in `packages/`: of 84 platform-namespace objects, 59 carry a +tenant column, `sys_permission_set` among them. A schema read therefore replaces a +wholesale exemption with a wholesale inclusion. The ruling's source is "有列**且有 +写手填**", and the writer half is a fact about the code, established once by +inventory (`packages/objectql/src/tenancy/platform-object-tenancy.ts`). + +**Direction is one-way.** What this adds is refusals and warnings only; no write's +target or payload changes. An object the inventory could not adjudicate is +`unclassified`, which keeps today's behaviour exactly and goes back to the +maintainer on a list — so the blast radius equals the seven admitted objects +(`sys_file`, `sys_upload_session`, `sys_approval_request`, `sys_approval_action`, +`sys_approval_approver`, `sys_automation_run`, `sys_notification_delivery`), each +admitted on a citable writer fact. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index fb1804b217..54c7009208 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -128,11 +128,13 @@ import { redactBoundStatement } from './driver-fault-redaction.js'; import { resolveSystemWriteOrganization, resolveTenantFieldName, - isPlatformNamespaceObject, carriesOrganization, 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, type TenancyPosture } from '@objectstack/spec/security'; @@ -3569,6 +3571,14 @@ export class ObjectQL implements IObjectQLEngine { !isFederated; const hasTz = execCtx?.timezone !== undefined; const isSystem = execCtx?.isSystem === true; + // [#13491] The object's tenancy classification — the ONE reading both the + // mute below and `resolveSystemInsertOrganization` narrow by. An object + // with no tenant field is out by construction; a platform object is out + // unless the inventory adjudicated it tenant-scoped. + const isTenantAuditInScope = + resolveTenantFieldName(objectSchema) !== null && + !isFederated && + !isPlatformObjectOutOfTenantAuditScope(object); const preserveAudit = (execCtx as any)?.preserveAudit === true; if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base; const opts: any = base && typeof base === 'object' ? { ...base } : {}; @@ -3597,10 +3607,28 @@ export class ObjectQL implements IObjectQLEngine { // (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly. opts.timezone = execCtx!.timezone; } - if (isSystem && opts.bypassTenantAudit === undefined) { - // System-elevated writes (boot-time seeds, internal mirrors, scheduled - // hooks) are unscoped by design — silence the audit warn for them but - // still flag genuine user-path bugs. + 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). + // + // It used to mute EVERY elevated write, whatever the object was. The + // #13178 census measured what that cost: 135 of 175 write call sites + // (77%) were silenced here — at the control's LARGEST gate, sitting + // ahead of the condition the control is about — and the control has + // never produced a finding, while all five known instances of the defect + // 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 + // `bypassTenantAudit: true` explicitly and is honoured above — this + // branch only fills in a value nobody supplied. + // + // ⚠️ Direction, per execution point 3: what this ADDS is a driver WARN + // line. It changes nothing about what any write touches. opts.bypassTenantAudit = true; } if (preserveAudit && opts.preserveAudit === undefined) { @@ -3760,9 +3788,16 @@ export class ObjectQL implements IObjectQLEngine { // system write that threaded one. Nothing to resolve; this is the shape the // ruling asks every system write to reach. if (carriesOrganization(execCtx?.tenantId)) return undefined; - // Platform namespaces stay global by design (#8672's reasoning, which the - // #8844 ruling confirms does not generalize to application objects). - if (isPlatformNamespaceObject(object)) return undefined; + // [#13491] Platform-namespace objects are excluded PER OBJECT, not + // wholesale. The 2026-08-31 ruling withdrew the blanket + // `isPlatformNamespaceObject(object)` exemption that used to stand here: + // #8672's "an org-less row is defensible for `sys_permission_set`" + // inherits per object, and the namespace also holds objects whose org-less + // rows are a defect — five instances of that class, all found by hand. + // `unclassified` keeps the old exclusion so an unadjudicated object's + // behaviour does not move; see `platform-object-tenancy.ts` for the + // inventory and why the classification cannot be read off the schema. + if (isPlatformObjectOutOfTenantAuditScope(object)) return undefined; const objectSchema = this._registry.getObject(object) as any; // A federated object's schema is the REMOTE's (ADR-0015); the platform's // injected column says nothing about it, which is the same reason diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 510f958c88..0b618e677d 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -323,6 +323,19 @@ export { GLOBAL_TENANT, 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, diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index f5ca9c02b1..07abb3fcce 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -104,7 +104,16 @@ const DISPATCH_ORDER = { }, } as any; -/** A platform-namespace object: its rows are deliberately org-less (#8672). */ +/** + * A platform-namespace object the #13491 inventory did NOT adjudicate. + * + * ⚠️ Its exclusion below is `unclassified`, not `global`. Until the 2026-08-31 + * ruling this file read it as "platform namespace ⇒ deliberately org-less + * (#8672)"; that wholesale reading was withdrawn, and what these cases now pin + * is that an UNADJUDICATED object's behaviour did not move — which is what + * bounds the reclassification's blast radius to the admitted list. The admitted + * and `global` sides are pinned in `tenancy-by-object-classification.test.ts`. + */ const SYS_LEDGER = { name: 'sys_audit_entry', fields: { subject: { type: 'text' } }, @@ -329,6 +338,7 @@ describe('#8844 the exclusions — populations the refusal must not touch', () = it.each(['isolated', 'single'] as const)( 'a platform-namespace object stays org-less on the %s posture', async (posture) => { + // ⚠️ [#13491] Reads as an UNCLASSIFIED verdict now, not a namespace one. // #8672 measured this primitive on `sys_permission_set` and filed it as an // observation because an org-less row is defensible there. The #8844 // ruling confirms that reasoning holds for platform objects and does NOT diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts new file mode 100644 index 0000000000..87e591d98e --- /dev/null +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ── The tenant-audit control's scope is cut by the OBJECT, not the caller's flag (#13491) ── +// +// Maintainer ruling, 2026-08-31 (总监席第 5 场, 联案 #13491 + #13497, verbatim +// 「同意」). It narrowed the batch #9 ruling ("isSystem writes are out of this +// control's scope") by that ruling's OWN look-back clause, which #13497's +// measurement fired: +// +// - isSystem x a TENANT-SCOPED object = IN scope; +// - isSystem x a genuinely GLOBAL object = OUT of scope, #8672's reasoning +// inheriting PER OBJECT rather than by namespace. +// +// ## What each test here discriminates +// +// Two gates were narrowed in one stroke and they fail differently, so both are +// pinned on both sides: +// +// 1. `resolveSystemInsertOrganization` — an admitted object now reaches +// #8844's derive/refuse machinery. A file pinning only the derive would +// stay green if the engine stamped a guess on a walled install; one +// pinning only the refusal would stay green if it refused everything. So +// each admitted-object case carries its excluded-object control, run +// through the same engine on the same posture. +// 2. The engine's `bypassTenantAudit` isSystem mute. This is the gate the +// #13178 census measured as silencing 135 of 175 write call sites (77%) — +// the control's LARGEST gate, sitting ahead of the condition the control +// is about. Pinned as the OPTION the engine hands the driver, because that +// is the whole of what the engine decides; `@objectstack/objectql` cannot +// import `@objectstack/driver-sql` (the dependency runs the other way). +// +// ## Direction, per the ruling's execution point 3 +// +// 「多出来的只能是拒绝/告警,⛔ 永不静默改写行为」. So every assertion below +// reads a REFUSAL, a WARNING-enablement, or a derive that #8844 already ruled — +// and the `unclassified` cases assert that behaviour did NOT move, which is +// what keeps the reclassification's blast radius equal to the admitted list. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { + PLATFORM_OBJECT_TENANCY, + classifyPlatformObjectTenancy, + isPlatformObjectOutOfTenantAuditScope, + tenantScopedPlatformObjects, +} from './tenancy/platform-object-tenancy.js'; + +const ORG_ID = 'org_msokm9oaz0cal87q'; +const SYSTEM_CTX: ExecutionContext = { isSystem: true } as ExecutionContext; + +interface ObservedCall { + object: string; + method: string; + options: Record | undefined; +} + +function makeDriver(observed: ObservedCall[], organizations: string[]) { + const record = (object: string, method: string, options: any) => + observed.push({ object, method, options }); + return { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, _ast: any, options: any) { + record(object, 'find', options); + return object === 'sys_organization' ? organizations.map((id) => ({ id })) : []; + }, + async findOne() { return null; }, + async count() { return 0; }, + async create(object: string, data: any, options: any) { + record(object, 'create', options); + return { id: 'r_1', ...data }; + }, + async update(object: string, id: string, data: any, options: any) { + record(object, 'update', options); return { id, ...data }; + }, + async delete() { return true; }, + async bulkCreate(object: string, rows: any[], options: any) { + record(object, 'bulkCreate', options); + return rows.map((r, i) => ({ id: `r_${i + 1}`, ...r })); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + } as any; +} + +const PACKAGE_ID = '#13491'; + +/** ADMITTED by the inventory — #12745 fixed its writer, a backfill was ordered. */ +const SYS_FILE = { name: 'sys_file', fields: { key: { type: 'text' } } } as any; +/** GLOBAL by the inventory — #8672's own example, named verbatim in the ruling. */ +const SYS_PERMISSION_SET = { name: 'sys_permission_set', fields: { label: { type: 'text' } } } as any; +/** UNCLASSIFIED — a platform object the inventory did not adjudicate. */ +const SYS_UNADJUDICATED = { name: 'sys_audit_entry', fields: { subject: { type: 'text' } } } as any; +/** An ordinary application object: tenant-scoped by its schema alone. */ +const DISPATCH_ORDER = { name: 'dispatch_order', fields: { subject: { type: 'text' } } } as any; +/** ADR-0066: the DECLARED way to say "rows of this object belong to no org". */ +const NO_TENANT_FIELD = { + name: 'billing_license', + tenancy: { enabled: false }, + fields: { subject: { type: 'text' } }, +} as any; + +const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; + +async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) { + const observed: ObservedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(observed, opts.organizations ?? [ORG_ID]), true); + await engine.init(); + for (const o of [SYS_FILE, SYS_PERMISSION_SET, SYS_UNADJUDICATED, DISPATCH_ORDER, NO_TENANT_FIELD, ORG_OBJECT]) { + engine.registry.registerObject(o, PACKAGE_ID); + } + if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture); + return { engine, observed }; +} + +const lastWrite = (observed: ObservedCall[], object: string) => + [...observed].reverse().find((c) => c.object === object && c.method !== 'find'); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('#13491 the inventory — a verdict per object, never a namespace', () => { + it('an admitted object is tenant-scoped and an unlisted platform name is NOT guessed', () => { + expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); + // ⛔ The escape hatch the ruling made mandatory: an object nobody + // adjudicated reads `unclassified`, never a guess in either direction. + expect(classifyPlatformObjectTenancy('sys_never_heard_of_it')).toBe('unclassified'); + }); + + it('#8672 inherits PER OBJECT — the same namespace holds both verdicts', () => { + expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('global'); + expect(classifyPlatformObjectTenancy('sys_file')).toBe('tenant-scoped'); + // The discriminating half: the old blanket predicate answered the SAME + // thing for both of these, which is the exemption the ruling withdrew. + expect(isPlatformObjectOutOfTenantAuditScope('sys_permission_set')).toBe(true); + expect(isPlatformObjectOutOfTenantAuditScope('sys_file')).toBe(false); + }); + + it('the exemption applies to platform namespaces only — an application object was never in it', () => { + expect(isPlatformObjectOutOfTenantAuditScope('dispatch_order')).toBe(false); + }); + + it('every non-unclassified entry cites its evidence', () => { + // The admission bar the ledger header states: a verdict without a citation + // is the guess execution point 2 forbids. + for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { + expect(entry.tenancy, name).not.toBe('unclassified'); + expect(entry.evidence.length, name).toBeGreaterThan(40); + } + }); + + it('the admitted list is exactly the objects whose behaviour this card moves', () => { + // Pinned as a LIST, not a count: the blast radius of the reclassification + // IS this set, and a silent arrival here is a behaviour change nobody + // adjudicated. Growing it is a maintainer decision, not a refactor. + expect(tenantScopedPlatformObjects()).toEqual([ + 'sys_approval_action', + 'sys_approval_approver', + 'sys_approval_request', + 'sys_automation_run', + 'sys_file', + 'sys_notification_delivery', + 'sys_upload_session', + ]); + }); +}); + +describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/refuse', () => { + it('single posture, one organization: an admitted object now DERIVES it', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_file', { key: 'k1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID); + }); + + it('single posture: a GLOBAL platform object still stays org-less — the control', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_permission_set')?.options?.tenantId).toBeUndefined(); + }); + + it('single posture: an UNCLASSIFIED platform object stays org-less — behaviour did not move', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); + }); + + it.each(['isolated', 'group'] as const)( + '%s posture: an org-less system write on an admitted object is REFUSED, loudly', + async (posture) => { + const { engine, observed } = await makeEngine({ posture }); + // The envelope, not the throw: a bare `toThrow()` would stay green for a + // transport-layer failure and for a driver that throws a plain Error. + await expect( + engine.insert('sys_file', { key: 'k1' }, { context: SYSTEM_CTX } as any), + ).rejects.toMatchObject({ + code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', + status: 500, + }); + expect(lastWrite(observed, 'sys_file')).toBeUndefined(); + }, + ); + + it.each(['isolated', 'group'] as const)( + '%s posture: a GLOBAL platform object is NOT refused — the discriminating control', + async (posture) => { + const { engine, observed } = await makeEngine({ posture }); + await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_permission_set')?.options?.tenantId).toBeUndefined(); + }, + ); + + it('a write that CARRIES its organization is untouched on a walled posture', async () => { + const { engine, observed } = await makeEngine({ posture: 'isolated' }); + await engine.insert( + 'sys_file', + { key: 'k1' }, + { context: { isSystem: true, tenantId: ORG_ID } } as any, + ); + expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID); + }); +}); + +describe('#13491 gate 2 — the isSystem mute narrows by the SAME classification', () => { + it('an elevated write on an admitted platform object is no longer auto-muted', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_file', { key: 'k1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_file')?.options?.bypassTenantAudit).toBeUndefined(); + }); + + it('an elevated write on an ordinary tenant-scoped object is no longer auto-muted', async () => { + // The 135-of-175 population the census measured. Restoring it is the whole + // reason the control has an effective population at all. + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('dispatch_order', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.options?.bypassTenantAudit).toBeUndefined(); + }); + + it('a GLOBAL platform object is still muted — #8672, inherited per object', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_permission_set')?.options?.bypassTenantAudit).toBe(true); + }); + + it('an UNCLASSIFIED platform object is still muted — status quo, pending adjudication', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options?.bypassTenantAudit).toBe(true); + }); + + it('an object with NO tenant field is still muted — nothing to be unscoped from', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('billing_license', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'billing_license')?.options?.bypassTenantAudit).toBe(true); + }); + + it('an EXPLICIT caller bypass is still honoured — this branch only fills a gap', async () => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert( + 'dispatch_order', + { subject: 'e1' }, + { context: SYSTEM_CTX, bypassTenantAudit: true } as any, + ); + expect(lastWrite(observed, 'dispatch_order')?.options?.bypassTenantAudit).toBe(true); + }); +}); diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts new file mode 100644 index 0000000000..eb224531ff --- /dev/null +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13491] Per-object tenancy classification for the platform namespaces. + * + * ## The ruling this implements + * + * Maintainer, 2026-08-31 (总监席第 5 场, 联案 #13491 + #13497, verbatim 「同意」): + * the tenant-audit control's scope is cut by **the object's tenancy + * classification**, never by the caller's flag. + * + * - `isSystem` x a TENANT-SCOPED object = IN scope. A system write that lands + * an org-less row on a tenant-scoped object is the defect class the control + * exists for; it has occurred five times (#12745, #12928, #10673, #8617, + * cloud#1239 — one a credentials table) and every instance was found by a + * person reading call sites, never by the control. + * - `isSystem` x a GENUINELY GLOBAL object = OUT of scope. #8672's reasoning + * ("an org-less row is defensible for `sys_permission_set`") inherits **per + * object**; the wholesale `sys_ / cloud_ / ai_` namespace exemption is + * withdrawn. + * + * ## Why a ledger and not a schema read + * + * The obvious implementation — read the object's schema and treat "has an + * `organization_id` column" as "tenant-scoped" — does not work, and the + * measurement is the reason this file exists. `applySystemFields` + * (`registry.ts`) provisions the tenant COLUMN unconditionally: its existence + * was deliberately decoupled from whether tenancy is on, so that sudo writers + * can always stamp it. Measured on this tree by AST census of every + * `ObjectSchema.create` in `packages/`: + * + * - 84 platform-namespace objects are registered in this repository; + * - 25 resolve NO tenant field (24 `managedBy: 'better-auth'`, plus + * `sys_sso_provider`'s `tenancy.enabled: false`) and are already outside + * the machinery — they exit at `resolveTenantFieldName` returning null; + * - **59 carry a tenant column**, `sys_permission_set` — #8672's own example + * of a legitimately org-less object — among them. + * + * So a schema read admits 59 of 84 in one stroke, i.e. it replaces a wholesale + * exemption with a wholesale inclusion. The ruling's classification source is + * "有列**且有写手填**" — the column AND a writer that fills it — and the second + * half is not a runtime fact. It is a fact about the CODE, established once by + * inventory and written down here. + * + * ## The three classifications, and why `unclassified` is not a failure + * + * The ruling's execution point 2 makes the escape hatch mandatory: + * 「判不了的逐个列出回批呈裁,⛔ 不猜」 — an object whose tenancy cannot be + * determined is LISTED for adjudication, never guessed either way. + * + * `unclassified` therefore keeps TODAY'S behaviour exactly (the object stays + * outside the machinery, as the blanket guard had it) and appears on the list + * that goes back to the maintainer. This is what keeps the blast radius of the + * reclassification knowable even though the unclassified list is long: the only + * objects whose behaviour changes are the ones classified `tenant-scoped`, and + * that set is enumerated below with a citation each. + * + * ⛔ Do not promote an entry to `tenant-scoped` to shorten the list. The + * admission bar is a CITABLE writer fact — a maintainer-ordered organization + * repair, or a writer that stamps the column — and an entry without one is the + * guess the ruling forbids. + * + * ## What admission actually changes, per object + * + * A `tenant-scoped` classification lets the object reach + * `resolveSystemInsertOrganization` (#8844): on a `single` posture with exactly + * one organization the write DERIVES it, and on a walled posture + * (`group` / `isolated`) an org-less write is REFUSED loudly + * ({@link SystemWriteOrganizationRequiredError}). It also stops the engine + * auto-muting the driver's tenant-audit warning for elevated writes on it. + * Both directions are the ruled one — a refusal or a warning, never a silent + * rewrite of what the write touches (execution point 3). + */ + +import { isPlatformNamespaceObject } from './system-write-organization'; + +/** + * What the one-time inventory concluded about one platform-namespace object. + * + * `no-tenant-column` is deliberately NOT a member: an object with no tenant + * field never reaches a classification question — `resolveTenantFieldName` + * answers it first, in both the engine and the driver — so recording it here + * would be a second reading of a fact one function already owns. + */ +export type PlatformObjectTenancy = + /** Column present AND a citable writer fills it. In scope. */ + | 'tenant-scoped' + /** #8672's reasoning inherits: rows are deliberately org-less. Out of scope. */ + | 'global' + /** Not determinable from the tree. Out of scope, PENDING ADJUDICATION. */ + | 'unclassified'; + +/** One inventory entry: the verdict plus the evidence it was reached on. */ +export interface PlatformObjectTenancyEntry { + readonly tenancy: PlatformObjectTenancy; + /** Why. A `tenant-scoped` or `global` entry must cite a source. */ + readonly evidence: string; +} + +/** + * The one-time inventory (#13491 execution point 2). + * + * Only objects with a VERDICT are listed. Everything else in the platform + * namespaces — including the five `cloud_`-runtime objects defined in the + * separate `cloud` repository, which this tree cannot read — is `unclassified` + * by absence, which is why {@link classifyPlatformObjectTenancy} answers + * `unclassified` for an unlisted name rather than throwing. + */ +export const PLATFORM_OBJECT_TENANCY: Readonly> = { + // ── tenant-scoped ──────────────────────────────────────────────────────── + // Each of these has a maintainer-ordered organization repair, a ruled writer + // that stamps the column, or both. The citation is the admission bar. + // + // ⚠️ `evidence` is a RUNTIME string — it reaches operators and generated + // surfaces, where a tracker id resolves to nothing (maintainer ruling + // 2026-08-12). So the prose names the FILE that carries the fact, and the + // tracker anchors stay in these `//` comments, for the reader who can + // resolve them and is already looking at the source. + + // #12745 (writer) + the 2026-08-28 backfill ruling 「12745 A回,其他同意。」 + sys_file: { + tenancy: 'tenant-scoped', + evidence: + 'The writer was repaired to thread the acting session organization ' + + '(`StorageMetadataStore.createFile`, `metadata-store.ts`), and the maintainer ordered a backfill on ' + + '2026-08-28 for the rows it had stranded (`backfill-sys-file-organizations.ts`). An org-less row ' + + 'here is a defect, not a design.', + }, + // #12928 (insert, FORWARD-STAMP-ONLY) + #13178 (update) + sys_upload_session: { + tenancy: 'tenant-scoped', + evidence: + '`StorageMetadataStore` stamps `organization_id` from `context.tenantId` on both the insert and the ' + + 'update half (`metadata-store.ts`). The rows that predate the insert repair are historic and were ' + + 'ruled FORWARD-STAMP-ONLY — they say nothing about a NEW write.', + }, + // #10101 (PR #11311, writer) + maintainer 2026-08-23 direction 3 (backfill); cloud#1395 + sys_approval_request: { + tenancy: 'tenant-scoped', + evidence: + "The writer was repaired to stamp from the SUBJECT record's organization, and the maintainer ordered " + + 'the backfill `backfill-platform-row-organizations.ts` on 2026-08-23 for the rows produced before ' + + 'it — measured on the cloud tracker as pending approvals invisible in every organization-scoped ' + + "inbox, their own owner's included.", + }, + // #10101 child row; same 2026-08-23 backfill order + sys_approval_action: { + tenancy: 'tenant-scoped', + evidence: + 'Child row of `sys_approval_request`; moves with its parent in the same 2026-08-23 backfill order, ' + + 'and `approval-service.ts` stamps `organization_id` on it at write time.', + }, + // #10101 child row; same 2026-08-23 backfill order + sys_approval_approver: { + tenancy: 'tenant-scoped', + evidence: + 'Child row of `sys_approval_request`; same 2026-08-23 backfill order, and `approval-service.ts` ' + + 'stamps `organization_id` on it at write time.', + }, + // #10101 (writer) + the same 2026-08-23 backfill order + sys_automation_run: { + tenancy: 'tenant-scoped', + evidence: + '`ObjectStoreSuspendedRunStore` resolves the run organization through ' + + '`recordOrgResolver.organizationOf(...)` and stamps it (`suspended-run-store.ts`); unattributed run ' + + 'history was the measured symptom, and the same 2026-08-23 order covers its backfill.', + }, + // #11698 + sys_notification_delivery: { + tenancy: 'tenant-scoped', + evidence: + '`SqlOutbox.enqueue` writes `organization_id` from the organization the messaging service derives ' + + 'for the notification (`sql-outbox.ts`, `messaging-service.ts#notificationOrganization`).', + }, + + // ── global ─────────────────────────────────────────────────────────────── + // #8672, named verbatim by the 2026-08-31 ruling; the driver predicate is #2734. + // ⚠️ `ensure-default-organization.ts` DOES stamp org-scoped permission sets, so + // this object holds BOTH populations. It is `global` on the maintainer naming + // it, not on an absence of org-scoped writes — flagged for the adjudicating + // batch rather than smoothed over. + sys_permission_set: { + tenancy: 'global', + evidence: + "The case the 2026-08-31 ruling names verbatim as the one whose reasoning inherits: 'an org-less " + + "row is defensible for `sys_permission_set`'. The SQL driver's own tenant predicate is written for " + + 'this population — a NULL organization marks a GLOBAL/platform row, and with strict equality every ' + + 'tenant admin saw ZERO RBAC rows on a fresh deployment. It also holds org-scoped rows, so the ' + + 'verdict rests on the maintainer naming it, not on an absence of org-scoped writes.', + }, +}; + +/** + * The inventory's verdict for `object`. + * + * Answers `unclassified` for any platform-namespace name the inventory does not + * list — the deliberate direction, since an unlisted object is precisely one + * nobody has adjudicated, and `unclassified` is the status-quo (excluded) + * behaviour. + * + * ⛔ Never call this on a non-platform object: the classification question is + * about the platform namespaces only, and an application object's tenancy is + * answered from its schema. {@link isPlatformObjectOutOfTenantAuditScope} is + * the predicate that pairs the two. + */ +export function classifyPlatformObjectTenancy(object: string): PlatformObjectTenancy { + return PLATFORM_OBJECT_TENANCY[object]?.tenancy ?? 'unclassified'; +} + +/** + * Is `object` a platform-namespace object the tenant-audit control does NOT + * cover? + * + * This is the narrowed successor to the blanket + * `isPlatformNamespaceObject(object)` short-circuit. It answers `true` for a + * platform object classified `global` or `unclassified` — the population the + * old guard covered wholesale — and `false` for a tenant-scoped one, which now + * flows into the machinery that was always there. + * + * A non-platform object answers `false`: it was never in this exemption, and + * its tenancy is read from its schema one line further down. + */ +export function isPlatformObjectOutOfTenantAuditScope(object: string): boolean { + if (!isPlatformNamespaceObject(object)) return false; + return classifyPlatformObjectTenancy(object) !== 'tenant-scoped'; +} + +/** Every object the inventory admitted as tenant-scoped, for tests and reports. */ +export function tenantScopedPlatformObjects(): readonly string[] { + return Object.entries(PLATFORM_OBJECT_TENANCY) + .filter(([, e]) => e.tenancy === 'tenant-scoped') + .map(([name]) => name) + .sort(); +} diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index e0f0c4b7e3..e3760e7e37 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -65,15 +65,25 @@ * org-less rows is `tenancy: { enabled: false }` — a metadata declaration, * loud and checkable — never a per-write bypass flag, which is exactly the * lenient-consumer accommodation Prime Directive #12 forbids. - * - **The platform namespaces `sys_` / `cloud_` / `ai_`** ({@link - * isPlatformNamespaceObject}). Their rows are deliberately global / - * cross-organization: this is #8672's reasoning ("an org-less row is - * defensible for `sys_permission_set`"), which the #8844 ruling confirms - * holds for platform objects and does NOT generalize to application objects. - * The same regexp is the seed loader's own rule for which seeds it will - * stamp, and #8686's backfill re-spells it for the same reason this module - * does: the three write paths have to agree about the platform namespace, or - * one of them manufactures a new disagreement while claiming to remove one. + * - **Platform-namespace objects the inventory did not admit** ({@link + * isPlatformObjectOutOfTenantAuditScope}, `platform-object-tenancy.ts`). + * + * ⚠️ This exclusion used to be the whole `sys_` / `cloud_` / `ai_` + * NAMESPACE, on #8672's reasoning that "an org-less row is defensible for + * `sys_permission_set`". The maintainer WITHDREW the wholesale form on + * 2026-08-31 (#13491, 联案 #13497): that reasoning inherits **per object**, + * and the same namespace also holds objects whose org-less rows are a + * defect — five instances (#12745, #12928, #10673, #8617, cloud#1239, one a + * credentials table), every one found by a person reading call sites and + * none by this control. So the exclusion is now a per-object verdict, and an + * object nobody has adjudicated stays excluded and stays ON THE LIST. + * + * ⚠️ The seed loader (`/^(sys_|cloud_|ai_)/` in `seed-loader.ts`) and + * #8686's backfill (`seed-tenancy-backfill.ts`) still carry the NAMESPACE + * form. That divergence is deliberate and NOT resolved here: #13491's + * landing is the runtime write path, and re-cutting the seed and backfill + * paths is a separate decision about rows those paths already wrote. Read + * the three together before changing any of them. * - **Writes that already carry an organization** — on the execution context * or on the row itself. That IS "carrying an explicit organization"; the * ruling asks for nothing more. @@ -125,7 +135,16 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; */ const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/; -/** Is this object in a platform namespace whose rows stay org-less by design? */ +/** + * Is this object in one of the platform namespaces? + * + * ⚠️ NAME-SHAPE ONLY, since #13491. This answers "does the name carry a + * platform prefix", NOT "do its rows stay org-less" — the 2026-08-31 ruling + * separated those two questions, and the second one is + * {@link isPlatformObjectOutOfTenantAuditScope} in `platform-object-tenancy.ts`. + * ⛔ Never reach for this predicate to decide tenancy: doing so is the + * wholesale exemption the ruling withdrew. + */ export function isPlatformNamespaceObject(object: string): boolean { return PLATFORM_NAMESPACE.test(object); } From e486ce8163b6cb0dc75cb7157c5706adea7dae3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 04:21:48 +0000 Subject: [PATCH 2/2] docs(permissions): re-anchor the system-context census after the engine edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/permissions/system-context.mdx` anchors `objectql/src/engine.ts` by line number in 15 places, and the tenancy-classification commit shifted every anchor below its change point. 14 of the 15 are pure line rot — the anchored statement is byte-identical across the move — and are repaired mechanically by `node scripts/check-system-context-census.mjs --fix`. The 15th is not rot: this branch deliberately reworded the construct row 24's `NON_READ_ANCHORS` needle locates, `if (isSystem && opts.bypassTenantAudit === undefined) {`, by adding the classification term. The construct still exists and the row still describes it, so the needle is re-pointed at the new spelling rather than left to rot off. ⛔ No row was added and none was retired — the ledger stays shrink-only, and the gate reports the same population as before the change: 109 elevation read sites, 27 declared non-read. Found by the PM, not by the local gate derivation: `dispatch-gates.mjs` does not name this family, which is a second instance of the standing reading that the derived family is not the whole farm. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- scripts/check-system-context-census.mjs | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3ea588e459..e6c3808a88 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10581` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10743` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9474` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10616` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10778` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9509` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1576` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9511`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5603` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3571`, `:3573`, `:3600` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9546`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5638` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3573`, `:3583`, `:3610` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6301` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11329` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11258` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6336` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11364` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11293` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3403` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:13670` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3405` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:13705` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9457`–`9474` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9492`–`9509` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1451` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:273` | diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index c9058e3bdf..a411cab3b2 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -184,7 +184,7 @@ export const NON_READ_ANCHORS = [ }, { file: 'packages/objectql/src/engine.ts', - needle: 'if (isSystem && opts.bypassTenantAudit === undefined) {', + needle: 'if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {', why: 'row 24 -- where `bypassTenantAudit` is threaded to the driver', }, {