diff --git a/.changeset/25201-platform-object-tenancy-ledger-retired.md b/.changeset/25201-platform-object-tenancy-ledger-retired.md new file mode 100644 index 00000000000..cb3fc98e78b --- /dev/null +++ b/.changeset/25201-platform-object-tenancy-ledger-retired.md @@ -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 + + + +**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] on tenant-scoped 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. diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts index 14858081ebd..0ae24266a25 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts @@ -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' }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 24448296bf2..4e25ea20466 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -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, @@ -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 }, ); } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 240d4b5be0b..e487740c590 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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, @@ -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 } : {}; @@ -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 @@ -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. // diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index f4d3494f181..fc536f99f87 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -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, diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 27101b02bc0..d4d580940f5 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -3,48 +3,40 @@ // ── 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, commit ff08691e6's recorded reasoning -// inheriting PER OBJECT rather than by namespace. +// 「同意」): isSystem x a tenant-scoped object is IN the control's scope. The +// ruling's first implementation read "tenant-scoped" off a hand-kept per-table +// ledger. ADR-0131 D9 made the question structural — column ⇒ in scope — and +// D13 retired the ledger with its last reader, the tenant-audit mute: the +// object's SCHEMA now answers both cuts, and nothing answers by name. // // ## What each test here discriminates // -// Two gates were narrowed in one stroke and they fail differently, so both are -// pinned on both sides: +// Two gates read the same predicate and 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 -// census cited in commit e49d98896's message 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). +// 1. `resolveSystemInsertOrganization` — every object with a tenant column +// reaches ADR-0131 D9's derive/refuse decision, platform namespace or +// not. 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 case carries its +// no-tenant-column control, run through the same engine on the same +// posture. +// 2. The engine's `bypassTenantAudit` isSystem mute. 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). A +// platform object and an application object of the same schema shape are +// handed the SAME options: a name decides nothing. // // ## 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. +// reads a REFUSAL, a WARNING-enablement, or a derive that D9 already ruled. 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'; +import * as publicApi from './index.js'; const ORG_ID = 'org_msokm9oaz0cal87q'; const SYSTEM_CTX: ExecutionContext = { isSystem: true } as ExecutionContext; @@ -89,11 +81,11 @@ function makeDriver(observed: ObservedCall[], organizations: string[]) { const PACKAGE_ID = '#13491'; -/** ADMITTED by the inventory — #12745 fixed its writer, a backfill was ordered. */ +/** A platform object with a tenant column — #12745 fixed its writer, a backfill was ordered. */ const SYS_FILE = { name: 'sys_file', fields: { key: { type: 'text' } } } as any; -/** GLOBAL by the inventory — commit ff08691e6's own example, named verbatim in the ruling. */ +/** Commit ff08691e6's own "legitimately org-less" example, the old ledger's one `global` verdict. */ const SYS_PERMISSION_SET = { name: 'sys_permission_set', fields: { label: { type: 'text' } } } as any; -/** UNCLASSIFIED — a platform object the inventory did not adjudicate. */ +/** A platform object the old ledger never adjudicated (`unclassified`). */ 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; @@ -104,8 +96,15 @@ const NO_TENANT_FIELD = { fields: { subject: { type: 'text' } }, } as any; +/** A platform object that DECLARES itself out of the machinery on its own schema. */ +const SYS_TENANTLESS = { + name: 'sys_tenantless_ledger', + systemFields: { tenant: false }, + fields: { subject: { type: 'text' } }, +} as any; + const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; -/** ADMITTED by #23284 — the notification family's event row. */ +/** The notification family's event row (#23284). */ const SYS_NOTIFICATION = { name: 'sys_notification', fields: { topic: { type: 'text' } } } as any; async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) { @@ -113,7 +112,7 @@ async function makeEngine(opts: { posture?: string; organizations?: string[] } = 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, SYS_NOTIFICATION]) { + for (const o of [SYS_FILE, SYS_PERMISSION_SET, SYS_UNADJUDICATED, DISPATCH_ORDER, NO_TENANT_FIELD, SYS_TENANTLESS, ORG_OBJECT, SYS_NOTIFICATION]) { engine.registry.registerObject(o, PACKAGE_ID); } if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture); @@ -127,83 +126,31 @@ 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_file')).toBe('tenant-scoped'); - // The inventory's one `global` entry, `sys_permission_set`, retired with - // its object (C3 stage 8): no `global` verdict is recorded today. A new one - // brings its own discriminating pair here. - expect(Object.entries(PLATFORM_OBJECT_TENANCY).filter(([, e]) => e.tenancy === 'global')).toEqual([]); - expect(classifyPlatformObjectTenancy('sys_permission_set')).toBe('unclassified'); - // 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); - for (const [name, entry] of Object.entries(PLATFORM_OBJECT_TENANCY)) { - expect(classifyPlatformObjectTenancy(name), name).toBe(entry.tenancy); - expect(isPlatformObjectOutOfTenantAuditScope(name), name).toBe(entry.tenancy !== 'tenant-scoped'); - } - }); - - 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. - // - // `sys_notification` (#23284) arrives on the maintainer's 2026-08-24 - // ruling that names the table — 「11303 - // sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」 — - // once its producers fill the column; its own describe block below pins - // what the admission moves. - expect(tenantScopedPlatformObjects()).toEqual([ - 'sys_approval_action', - 'sys_approval_approver', - 'sys_approval_request', - 'sys_automation_run', - 'sys_file', - 'sys_notification', - 'sys_notification_delivery', - 'sys_record_share', - 'sys_upload_session', - ]); +describe('ADR-0131 D13 — the per-table ledger is retired, not merely unread', () => { + it('@objectstack/objectql no longer exports the ledger or its predicates', () => { + // A tombstone, not a count. The ledger published four names, every one + // spelled around "platform object" (the table, its classifier, its audit + // predicate, its tenant-scoped list); re-adding any of them — or a sibling + // under a new name — reds here. + expect(Object.keys(publicApi).filter((name) => /PlatformObject|PLATFORM_OBJECT/.test(name))).toEqual([]); }); }); -describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/refuse', () => { - it('single posture, one organization: an admitted object now DERIVES it', async () => { +describe('gate 1 — every object with a tenant column reaches ADR-0131 D9 derive/refuse', () => { + it('single posture, one organization: a platform object with a tenant column 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); }); - // [ADR-0131 C8 stage 8b] The classification no longer decides the write - // under any posture: every platform object with a tenant column is judged, - // admitted or not (`single` first, the walled postures in 18.x). + // [ADR-0131 C8 stage 8b] No name decides the write under any posture: every + // platform object with a tenant column is judged (`single` first, the walled + // postures in 18.x). it.each([ ['sys_permission_set', { label: 'Admin' }], ['sys_audit_entry', { subject: 'e1' }], ] as const)( - 'single posture, one organization: the un-admitted platform object %s now DERIVES it', + 'single posture, one organization: the platform object %s DERIVES it', async (object, row) => { const { engine, observed } = await makeEngine({ posture: 'single' }); await engine.insert(object, { ...row }, { context: SYSTEM_CTX } as any); @@ -215,7 +162,7 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref ['no organization', [] as string[], 0], ['two organizations', [ORG_ID, 'org_second'], 2], ] as const)( - 'single posture, %s: an org-less system insert on an un-admitted platform object is REFUSED, loudly, and never written', + 'single posture, %s: an org-less system insert on a platform object is REFUSED, loudly, and never written', async (_label, organizations, count) => { const { engine, observed } = await makeEngine({ posture: 'single', organizations: [...organizations] }); await expect( @@ -235,7 +182,7 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref ['isolated', 'sys_permission_set', { label: 'Admin' }], ['group', 'sys_permission_set', { label: 'Admin' }], ] as const)( - '[C8 stage 8b] %s posture: the un-admitted platform object %s is judged too — its org-less system insert is REFUSED, and never written', + '[C8 stage 8b] %s posture: the platform object %s is judged — its org-less system insert is REFUSED, and never written', async (posture, object, row) => { const { engine, observed } = await makeEngine({ posture }); await expect( @@ -246,7 +193,7 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref ); it.each(['isolated', 'group'] as const)( - '%s posture: an org-less system write on an admitted object is REFUSED, loudly', + '%s posture: an org-less system write on sys_file is REFUSED, loudly', async (posture) => { const { engine, observed } = await makeEngine({ posture }); // The envelope, not the throw: a bare `toThrow()` would stay green for a @@ -265,7 +212,7 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref '%s posture: an object with NO tenant column is NOT refused — the discriminating control', async (posture) => { // ADR-0131 D7: a deployment-level table declares no organization column, - // and that declaration — not a platform-namespace verdict — is what keeps + // and that declaration — never a platform-namespace verdict — is what keeps // its rows organization-less under a wall. const { engine, observed } = await makeEngine({ posture }); await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); @@ -288,8 +235,8 @@ describe('#23284 — a notification no producer could attribute takes the owner // The producers thread an organization when they have one (the run's, the // record the notification is about, the approval request's). What is left — // a notification about no record, from a run with no organization — reaches - // the engine with none, and admission is what puts it under the system-write - // rule instead of landing it with `organization_id` NULL. + // the engine with none, and its tenant column is what puts it under the + // system-write rule instead of landing it with `organization_id` NULL. it('single posture, one organization: the org-less notification takes the sole organization', async () => { const { engine, observed } = await makeEngine({ posture: 'single' }); await engine.insert('sys_notification', { topic: 'sweep.breach', organization_id: null }, { context: SYSTEM_CTX } as any); @@ -326,31 +273,31 @@ describe('#23284 — a notification no producer could attribute takes the owner }); }); -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); - }); +describe('gate 2 — the isSystem tenant-audit mute is decided by the SCHEMA (ADR-0131 D13)', () => { + it.each([ + ['sys_file', { key: 'k1' }], + // The old ledger's `global` verdict: muted by name until D13. + ['sys_permission_set', { label: 'Admin' }], + // The old ledger's `unclassified` default: muted by absence until D13. + ['sys_audit_entry', { subject: 'e1' }], + ['dispatch_order', { subject: 'e1' }], + ] as const)( + 'an elevated write on %s, which has a tenant column, is NOT auto-muted', + async (object, row) => { + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert(object, { ...row }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, object)?.options?.bypassTenantAudit).toBeUndefined(); + }, + ); - it('an UNCLASSIFIED platform object is still muted — status quo, pending adjudication', async () => { + it('a platform object and an application object of the same shape are handed the SAME driver options — the name decides nothing', async () => { + // The ablation this card was accepted on: put a per-table exemption back + // for `sys_audit_entry` and this pin reds while no write pin above moves — + // the mute is the only thing a per-table entry could ever change. 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); + await engine.insert('dispatch_order', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_audit_entry')?.options).toEqual(lastWrite(observed, 'dispatch_order')?.options); }); it('an object with NO tenant field is still muted — nothing to be unscoped from', async () => { @@ -359,6 +306,14 @@ describe('#13491 gate 2 — the isSystem mute narrows by the SAME classification expect(lastWrite(observed, 'billing_license')?.options?.bypassTenantAudit).toBe(true); }); + it('a platform object that declares `systemFields.tenant: false` is muted by that declaration', async () => { + // The structural way out of the audit: a declaration on the object's own + // schema, the one the census records per object — never a list of names. + const { engine, observed } = await makeEngine({ posture: 'single' }); + await engine.insert('sys_tenantless_ledger', { subject: 'e1' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'sys_tenantless_ledger')?.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( diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts deleted file mode 100644 index 9a765253204..00000000000 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#13491] Per-object tenancy classification for the platform namespaces. - * - * ## ⚠️ What still reads this ledger (ADR-0131 C8 stage 8b) - * - * Under no posture does the ledger decide a write any more: ADR-0131 D9 made - * the system-insert rule structural — a platform object with a tenant column - * is in scope like any other, so its organization-less system insert takes - * the derived organization or is refused - * (`Engine.resolveSystemInsertOrganization`). Stage 8b lifted the exemption - * under `single` first and under the walled postures (`group` / `isolated`) - * in 18.x. One reader remains, and it is why the ledger has not retired yet - * (D13): the engine's `bypassTenantAudit` isSystem mute - * (`Engine.buildDriverOptions`), a diagnostics-only reading in every posture - * that changes nothing a write touches. - * - * ⛔ Do not add an entry to make a write behave: an entry changes nothing but - * the mute. - * - * ## 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. Commit ff08691e6's recorded 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. - * - * ⛔ The census that measures this is NOT restated here. It is derived by - * `scripts/platform-object-tenancy-census.mjs`, committed as - * `scripts/platform-object-tenancy-census.json`, and held to the tree by - * `scripts/check-platform-object-tenancy-census.mjs` (commit 26144c204). Its PREDICATE is - * the half this paragraph used to leave out: an object is inside the machinery - * when `resolveTenantFieldName` answers non-null on the registered schema — - * after `applySystemFields`, because the injected column is what the engine - * sees, not what the author typed. The artefact records, per object, the - * declaration on its OWN schema that puts it outside. - * - * ⛔ Do not restate a count here "for reference". This paragraph carried three - * hand-written digits and a parenthetical explaining them, and each half failed - * in its own direction. The parenthetical mis-attributed the exclusion — it - * named an object as an addition to the set it was already in — while its - * arithmetic stayed right, which is why nothing caught it. The digits then went - * stale when an object opted out of the tenant column (`systemFields: { tenant: - * false }`, a third mechanism the parenthetical's taxonomy had no slot for), and - * CI was green throughout. A number in this comment has no second party; the - * artefact has a gate. - * - * The ARGUMENT survives that measurement, and no digit was load-bearing for it: - * the great majority of platform-namespace objects carry the tenant column — - * `sys_permission_set`, commit ff08691e6's own example of a legitimately org-less object, - * among them — so a schema read admits nearly all of them 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 stops the engine auto-muting the - * driver's tenant-audit warning for elevated writes on the object. It no - * longer decides whether an org-less write reaches - * `resolveSystemInsertOrganization` (#8844): under every posture every - * platform object with a tenant column reaches it whatever its entry says - * (stage 8b, above), and an org-less write no organization is derivable for - * is REFUSED loudly ({@link SystemWriteOrganizationRequiredError}). 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' - /** Commit ff08691e6'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) + commit f087c376f (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 (commit 1272f0a6b, 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.', - }, - // #23284 (writer) + the 2026-08-24 #11303 ruling, verbatim: - // 「11303 sys_inbox_message/sys_notification/sys_email 应该写 organization_id。」 - // The table is NAMED by the ruling, and this card made every producer fill - // it: the `notify` node threads the run's organization, else the organization - // of the record the notification is about; the approvals service threads the - // request's. Admitting it is what hands a notification no producer could - // attribute to ADR-0131 D9 — the sole organization under `single`, a refusal - // where none is derivable — instead of the NULL row the HotCRM v18 pass - // measured on every system-context emit. Only the event row: its delivery - // was admitted already (below), and `emit()` threads the event row's stored - // organization onto the delivery, the inbox row and the receipt, so the - // family follows its notification on both the inline and the outbox path. - sys_notification: { - tenancy: 'tenant-scoped', - evidence: - '`MessagingService.writeEvent` (`messaging-service.ts`) stamps `organization_id` from the organization ' + - 'its producer threads: the flow `notify` node passes the run\'s organization, else the organization of ' + - 'the record the notification is about (`notify-node.ts`), and the approvals service passes the ' + - 'request\'s (`approval-service.ts`). The maintainer ruled on 2026-08-24 that this table must carry ' + - '`organization_id`. An org-less row here is invisible to every report and cleanup that filters by ' + - 'organization, so it is a defect, not a design.', - }, - // #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`).', - }, - // Commit 3f64fe6c6 (writer + backfill), ruled 2026-09-02 — decision batch #11 item 3, - // maintainer verbatim 「#13564 转维护者处理;其他同意」 ("其他同意" adopts A: - // tenant-scoped, writer-repaired, existing rows backfilled from the record - // they grant access to). The per-table order the `sys_file` precedent - // requires; it covers `sys_record_share` and no other table. - sys_record_share: { - tenancy: 'tenant-scoped', - evidence: - 'The writer was repaired to stamp `organization_id` on every insert and update ' + - '(`SharingService.grant`, `plugin-sharing/src/sharing-service.ts`): a rule-materialised grant ' + - "carries the granting rule's organization, a direct grant the shared record's. The maintainer ordered " + - 'the rows written before it backfilled from the record they reference on 2026-09-02 ' + - '(`backfill-sys-record-share-organizations.ts`). A grant table that cannot say which organization ' + - 'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read compiles ' + - '`organization_id = :tenant` at both layers, and every organization-less grant would silently ' + - 'disappear.', - }, -}; - -/** - * 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 99c3195df02..64bcca267c1 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -83,12 +83,12 @@ * * ⚠️ Platform-namespace objects used to be a fourth population: first the * whole `sys_` / `cloud_` / `ai_` NAMESPACE, then (#13491) the per-object - * verdict of `PLATFORM_OBJECT_TENANCY`. ADR-0131 C8 stage 8b retired that + * verdict of a hand-kept per-table ledger. ADR-0131 C8 stage 8b retired that * write-path reader — under `single` first, then under the walled postures * in 18.x — so a platform object with a tenant column takes the derived - * organization or is refused, like every other object. The ledger keeps - * its other reader, the tenant-audit mute in `Engine.buildDriverOptions` - * ({@link isPlatformObjectOutOfTenantAuditScope}). C1 withdrew the seed + * organization or is refused, like every other object. The ledger's last + * reader, the tenant-audit mute in `Engine.buildDriverOptions`, became a + * schema read too, and the ledger retired (D13). C1 withdrew the seed * loader's namespace exemption. #8686's backfill * (`seed-tenancy-backfill.ts`) still carries the NAMESPACE form; its * residue attribution is C7's. Read the three together before changing any @@ -133,10 +133,9 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; * * ⚠️ Since #13491 this is a name test, NOT a tenancy rule, and it is no longer * a write-path cut at all. Its only reader is - * {@link isPlatformNamespaceObject}, which - * {@link isPlatformObjectOutOfTenantAuditScope} (`platform-object-tenancy.ts`) - * uses as a cheap pre-filter before consulting the per-object verdict. ⛔ Never - * reach for it to decide whether a row stays org-less. + * {@link isPlatformNamespaceObject}, a published name test the engine itself no + * longer consults: the per-table ledger it used to pre-filter for retired with + * ADR-0131 D13. ⛔ Never reach for it to decide whether a row stays org-less. * * ## The three copies, and why they no longer agree * @@ -174,10 +173,10 @@ const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/; * * ⚠️ 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. + * separated those two questions, and ADR-0131 D13 answers the second one from + * the schema alone ({@link resolveTenantFieldName}). ⛔ 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); @@ -187,6 +186,27 @@ export function isPlatformNamespaceObject(object: string): boolean { * Resolve the column an object is tenant-scoped by, or `null` when it is not * tenant-scoped at all. * + * ## ⭐ This is the one tenancy-scope predicate (ADR-0131 D9 / D13) + * + * It decides both of the engine's tenancy cuts for an object, read on the + * registered schema — the authored one plus the columns `applySystemFields` + * injects, which is the schema the engine actually holds: whether an + * organization-less system insert is judged by the system-write rule + * (`Engine.resolveSystemInsertOrganization`), and whether an elevated write's + * tenant-audit stays live (`Engine.buildDriverOptions`). Non-null ⇒ in scope, + * whatever the object's name. There is no per-table list beside it: the + * #13491 ledger that once narrowed the audit by name retired with D13, so an + * object leaves scope only by DECLARING it on its own schema + * (`tenancy.enabled: false`, `systemFields.tenant: false`, no tenant field). + * + * Which platform-namespace objects that leaves outside, and the declaration + * that puts each there, is derived — never restated here — by + * `scripts/platform-object-tenancy-census.mjs` into + * `scripts/platform-object-tenancy-census.json`, which + * `scripts/check-platform-object-tenancy-census.mjs` holds to the tree. ⛔ Do + * not quote its counts in this comment: a hand-written count here went stale + * once already, with CI green. + * * The twin of `SqlDriver.computeTenantField`, same precedence and same reading: * an explicit `tenancy.enabled: false` opt-out wins over any column-presence * heuristic (ADR-0066 / `isTenancyDisabled`), then a declared diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index e9515e9648a..4b16bd4939d 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -2834,17 +2834,23 @@ export class ApprovalService implements IApprovalService { * - `sys_business_unit_member` declares no `organization_id`; the column * is injected (`applySystemFields`) and the tenancy census lists it in; * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`); - * - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId` - * from every `sys_` object), and elevated system-context writes do NOT - * (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570). - * So a NULL on a member row means UNKNOWN tenancy, not "platform-global", - * and unknown tenancy is not a member of this organization. This is the + * - seed replay and elevated system-context inserts fill it too since + * ADR-0131: the seed loader stamps every seed row with the resolved + * organization (C1), and an organization-less system insert on any + * object with a tenant column takes the derived organization or is + * refused (D9, `Engine.resolveSystemInsertOrganization`) — judged by + * the schema, with no per-table exemption left (D13); + * - so a NULL on a member row is residue written before 18.0 (which the + * ownership ceremony attributes) or a lean composition that registers + * no organization object at all. + * Either way a NULL means UNKNOWN tenancy, not "platform-global", and + * unknown tenancy is not a member of this organization. This is the * ruling `plugin-sharing`'s `memberScope` already applies to the same rows * (#14547 / #14949), and the posture this file already takes for * `sys_team_member` and `sys_user_position`. * * The cost is declared, not hidden: an organization whose MEMBERSHIP rows - * were seeded or system-written expands to nobody even on a unit it can + * still carry no organization expands to nobody even on a unit it can * see. That is not silent — the graph-type fallback in `expandApprover` * warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the * request as for any unstaffed target — and the repair is to stamp the diff --git a/packages/plugins/plugin-approvals/src/business-unit-member-org-screen.test.ts b/packages/plugins/plugin-approvals/src/business-unit-member-org-screen.test.ts index e079f7e0a3f..7e7e1f8fc80 100644 --- a/packages/plugins/plugin-approvals/src/business-unit-member-org-screen.test.ts +++ b/packages/plugins/plugin-approvals/src/business-unit-member-org-screen.test.ts @@ -19,10 +19,12 @@ * the column is INJECTED (`applySystemFields`, `injected-system-columns.ts`) * and the tenancy census lists the object `reach: "in"` with * `tenantField: "organization_id"`; - * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`); seed - * replay does not (`seed-loader.ts` withholds its `fallbackOrgId` from - * every `sys_` object); elevated system-context writes do not either - * (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570). + * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`), and + * since ADR-0131 so do seed replay (C1) and an elevated system-context + * insert (D9: the derived organization or a refusal, judged by the tenant + * column with no per-table exemption left, D13). A NULL that remains is + * pre-18.0 residue the ownership ceremony attributes, or a lean + * composition with no organization object. * * ⇒ a NULL there means UNKNOWN tenancy, not "platform-global", and routing * approval authority over tenant A's record to an identity of unknown @@ -39,8 +41,8 @@ * ONLY the request organization's users, at both depths of the walk. * B2 — THE CONTROL: an org-stamped unit still routes its own members — * strict, not broken — and still drops the other tenant's row. - * B3 — THE DECLARED COST: org-less membership rows (seed replay, elevated - * writes) do NOT route when the request carries an organization; the + * B3 — THE DECLARED COST: org-less membership rows (pre-18.0 residue) + * do NOT route when the request carries an organization; the * slot falls to the literal and the #3807 warning fires, so the empty * slate is loud rather than silent. * B4 — a request carrying no organization is untouched: every member @@ -215,8 +217,8 @@ describe('#14946 business-unit MEMBER org screen', () => { }); it('B3 — THE DECLARED COST: org-less membership rows do NOT route when the request carries an organization, and the empty slate is LOUD', async () => { - // Seed replay and elevated system writes both leave `organization_id` - // NULL (#14570). Unknown tenancy is not "this organization": the slot + // A membership row written before 18.0 can still carry a NULL + // `organization_id`. Unknown tenancy is not "this organization": the slot // falls to the literal, which #3807's warning already reports. engine._tables['sys_business_unit_member'] = [ { id: 'bm_seeded', business_unit_id: 'bu_seeded', user_id: 'u_seeded', organization_id: null }, diff --git a/packages/plugins/plugin-sharing/src/business-unit-graph.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.ts index 6a20250614c..60f84c1efcd 100644 --- a/packages/plugins/plugin-sharing/src/business-unit-graph.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.ts @@ -339,27 +339,25 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService { * rather than named by anyone — and its organization is the only tenancy * fact it carries. * - * `sys_business_unit_member` rows are NOT organization-stamped on every write - * path. Re-measured on this tree at `origin/main` for #14547: + * Which write paths stamp `sys_business_unit_member.organization_id`, on + * this tree since ADR-0131: * - * - REST / session writes ARE stamped — the engine threads the caller's - * `tenantId` into `DriverOptions` and `SqlDriver.injectTenantOnInsert` - * fills the injected `organization_id` column; - * - SEED replay is NOT — `seed-loader.ts` withholds its single-org - * `fallbackOrgId` from every `/^(sys_|cloud_|ai_)/` object, so a seeded - * membership lands org-less unless the replay pinned an organization or - * the record spelled the column itself; - * - ELEVATED (system-context) writes are NOT — `sys_business_unit_member` - * is absent from `PLATFORM_OBJECT_TENANCY` - * (`packages/objectql/src/tenancy/platform-object-tenancy.ts`), so it - * classifies `unclassified` and `Engine.resolveSystemInsertOrganization` - * returns early, stamping nothing. That residual classification gap is - * tracked separately (#14570) and is not closed here; + * - REST / session writes — the engine threads the caller's `tenantId` + * into `DriverOptions` and `SqlDriver.injectTenantOnInsert` fills the + * injected `organization_id` column; + * - SEED replay — the seed loader stamps every seed row with the resolved + * organization, platform namespaces included (C1); + * - ELEVATED (system-context) inserts — an organization-less one takes the + * derived organization or is refused (D9, + * `Engine.resolveSystemInsertOrganization`), judged by the object's + * tenant column alone: no per-table exemption is left (D13); * - `driver-memory` / `driver-mongodb` never stamp a tenant column at all * (both refuse to boot multi-tenant, which is what makes that safe). * - * So a NULL organization on a member row does not mean "platform-global", it - * means UNKNOWN TENANCY — and admitting an identity of unknown tenancy into + * What can still hold a NULL is residue written before 18.0 (the ownership + * ceremony attributes it) or a lean composition that registers no + * organization object. Either way a NULL organization on a member row does + * not mean "platform-global", it means UNKNOWN TENANCY — and admitting an identity of unknown tenancy into * an org-stamped grant is the same cross-tenant over-grant, arriving by the * other door. A grant fails CLOSED: unknown tenancy is not a member here. * The sibling recipient widths already read their membership rows this way — @@ -367,7 +365,8 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService { * screens `sys_user_position`, both with a strict equality. * * ⚠️ The cost is declared, not hidden: a rule stamped with an organization - * whose memberships were seeded expands to nobody even on a unit it can see. + * whose memberships carry no organization expands to nobody even on a unit + * it can see. * That outcome is LOUD — `SharingRuleService.expandRecipient` warns once per * rule, naming the rule and the unit — where before it was silent, and the * repair is to stamp the membership rows rather than to widen this screen. diff --git a/scripts/check-platform-object-tenancy-census.mjs b/scripts/check-platform-object-tenancy-census.mjs index a70107823ae..51830e8ee70 100644 --- a/scripts/check-platform-object-tenancy-census.mjs +++ b/scripts/check-platform-object-tenancy-census.mjs @@ -11,8 +11,9 @@ * * ## The failure this exists for, in both of the directions it happened * - * `packages/objectql/src/tenancy/platform-object-tenancy.ts` carried the census - * as PROSE: three digits and a parenthetical explaining them. Nothing re-derived + * The per-table tenancy ledger's header (`platform-object-tenancy.ts`, since + * retired by ADR-0131 D13) carried the census as PROSE: three digits and a + * parenthetical explaining them. Nothing re-derived * it, so it was true only until the population moved, and it failed silently * when it did -- twice, in the two different ways a hand-written count can: * @@ -39,7 +40,9 @@ * check: the artefact carries no line numbers, so a pure displacement cannot * move it and there is exactly one mechanical repair path * (`node scripts/platform-object-tenancy-census.mjs --write`). - * 2. **The header still points here.** Grain 1's fix was to DELETE the digits, + * 2. **The header still points here.** The header is now + * `resolveTenantFieldName`'s docblock (`HEADER` in the generator says why it + * moved when the ledger retired). Grain 1's fix was to DELETE the digits, * so what is left to protect is the pointer and the predicate sentence. These * are required to be PRESENT, not to be right -- a count is exactly what must * not come back, and the way it comes back is by someone helpfully restating diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index a5daba9d032..269ec093e36 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -3,8 +3,8 @@ "The platform-object tenancy census — DERIVED. Rebuild with `node scripts/platform-object-tenancy-census.mjs --write`;", "`node scripts/check-platform-object-tenancy-census.mjs` holds it to the tree (#14957).", "", - "⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in", - "packages/objectql/src/tenancy/platform-object-tenancy.ts, and failed twice: once with a wrong REASON behind a", + "⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in the", + "header of the per-table tenancy ledger (retired by ADR-0131 D13), and failed twice: once with a wrong REASON behind a", "right total (all excluded objects were `managedBy: better-auth`; one of them was described as an addition to", "the set it was already in), and once with a stale TOTAL (PR #15155 declared `systemFields: { tenant: false }`", "on sys_metadata_activation, moving it out of reach, and the ungated prose stayed at the old digits with CI green).", @@ -13,6 +13,8 @@ "`resolveTenantFieldName` on the REGISTERED schema — the authored schema plus the columns", "`resolveInjectedSystemColumns` injects. Both are loaded from source and executed by the generator.", "⛔ `managedBy` is not the predicate; it is one declaration among several that make the predicate answer null.", + "`reach: in` is the whole of the engine's tenancy scope for the object: the system-write rule AND the tenant-audit", + "(ADR-0131 D9/D13). No per-table list sits beside it — the ledger that once narrowed the audit by name retired.", "", "`reasons` are the declarations on the object's OWN schema, spelled as the schema spells them. They are NOT", "mutually exclusive — an object carrying two is why flattening them into one \"the reason\" produced failure 1 —", diff --git a/scripts/platform-object-tenancy-census.mjs b/scripts/platform-object-tenancy-census.mjs index 0f55b84c703..ffef7d400db 100644 --- a/scripts/platform-object-tenancy-census.mjs +++ b/scripts/platform-object-tenancy-census.mjs @@ -120,10 +120,17 @@ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); export const ARTEFACT = 'scripts/platform-object-tenancy-census.json'; /** - * The header whose hand-written digits this artefact replaced. The gate - * requires it to keep POINTING here -- see the gate's `HEADER_MARKERS`. + * The header that must keep POINTING here -- see the gate's `HEADER_MARKERS`. + * + * It was the per-table ledger's own header (`platform-object-tenancy.ts`), whose + * hand-written digits this artefact replaced. That ledger retired under ADR-0131 + * D13: the tenant-audit's scope became the schema predicate this census executes, + * so the pointer now lives where that predicate is defined and documented -- + * `resolveTenantFieldName`'s own docblock. The artefact's reader moved with it: + * `reach: in` is now, by construction, "inside the system-write rule AND the + * tenant-audit", with no per-table verdict beside it. */ -export const HEADER = 'packages/objectql/src/tenancy/platform-object-tenancy.ts'; +export const HEADER = 'packages/objectql/src/tenancy/system-write-organization.ts'; /** The two files the predicate is loaded from. Both are executed, not read. */ export const PREDICATE_SOURCES = { @@ -428,8 +435,8 @@ const ARTEFACT_COMMENT = [ 'The platform-object tenancy census — DERIVED. Rebuild with `node scripts/platform-object-tenancy-census.mjs --write`;', '`node scripts/check-platform-object-tenancy-census.mjs` holds it to the tree (#14957).', '', - '⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in', - 'packages/objectql/src/tenancy/platform-object-tenancy.ts, and failed twice: once with a wrong REASON behind a', + '⛔ Do not hand-edit a number here. This file exists because the same census WAS hand-written prose, in the', + 'header of the per-table tenancy ledger (retired by ADR-0131 D13), and failed twice: once with a wrong REASON behind a', 'right total (all excluded objects were `managedBy: better-auth`; one of them was described as an addition to', 'the set it was already in), and once with a stale TOTAL (PR #15155 declared `systemFields: { tenant: false }`', 'on sys_metadata_activation, moving it out of reach, and the ungated prose stayed at the old digits with CI green).', @@ -438,6 +445,8 @@ const ARTEFACT_COMMENT = [ '`resolveTenantFieldName` on the REGISTERED schema — the authored schema plus the columns', '`resolveInjectedSystemColumns` injects. Both are loaded from source and executed by the generator.', '⛔ `managedBy` is not the predicate; it is one declaration among several that make the predicate answer null.', + '`reach: in` is the whole of the engine\'s tenancy scope for the object: the system-write rule AND the tenant-audit', + '(ADR-0131 D9/D13). No per-table list sits beside it — the ledger that once narrowed the audit by name retired.', '', '`reasons` are the declarations on the object\'s OWN schema, spelled as the schema spells them. They are NOT', 'mutually exclusive — an object carrying two is why flattening them into one "the reason" produced failure 1 —',