From c547a8a72503399af0d61a1d93cb91c785e314c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:53:49 +0000 Subject: [PATCH 1/4] wip(core): memoise the Default Organization id per engine Claude-Session: https://claude.ai/code/session_01Ug7sGD6qWVrzQNdihVX3T6 Co-authored-by: Claude --- .../src/security/resolve-authz-context.ts | 144 +++++++++++++++--- ...ult-organization-statements.golden.test.ts | 111 ++++++++++++++ 2 files changed, 235 insertions(+), 20 deletions(-) create mode 100644 packages/runtime/src/default-organization-statements.golden.test.ts diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 4029c98df93..a4c2ec740b1 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -941,6 +941,97 @@ async function readDefaultOrganizationId(ql: any): Promise { return all.length === 1 ? idOf(all[0]) : undefined; } +/** + * [#24523] The objects the Default Organization's answer is read from: the + * two {@link readDefaultOrganizationId} reads ask `sys_organization` alone. + */ +const DEFAULT_ORGANIZATION_WATCHED_OBJECTS: ReadonlySet = new Set([SystemObjectName.ORGANIZATION]); + +/** + * [#24523] The residual bound on a memoised Default Organization answer — + * ⛔ never the in-process rule, which is the write seam. It covers only what no + * seam can see: a peer replica's `sys_organization` write on a deployment with + * no `authz.invalidated` bridge attached (a peer's delete-and-recreate is + * #21868's exact failure), a write that started before this engine's observer + * registered, and a watched write inside an engine transaction that has not + * committed when its statement completes. The same residual bound the + * localization success cache defaults to; the price is two reads per engine + * per interval. + */ +const DEFAULT_ORGANIZATION_MEMO_TTL_MS = 30_000; + +/** `null`: an engine whose wiring threw — never memoised behind. */ +const defaultOrganizationWriteStates = new WeakMap(); + +interface DefaultOrganizationMemoEntry { + /** The answer — `undefined` included: no organization, or several and none `default`, is a real answer. */ + value: string | undefined; + /** The engine-side state this answer was read under. */ + writes: WatchedWriteState; + /** That state's generation, read BEFORE the reads were issued. */ + gen: number; + expiresAt: number; +} + +const defaultOrganizationMemo = new WeakMap(); + +/** + * [#24523] {@link readDefaultOrganizationId}, memoised per engine across + * requests. Before, every authenticated request of a principal holding an + * organization-owned `admin_full_access` grant under `single` paid one + * `sys_organization` read — two when the one organization is not slugged + * `default` — because the request memo de-duplicates only within a request and + * the grants cache is off by default (#11633 Fork 4). + * + * Only how often the answer is read moves; ⛔ the reads, their filters and + * limits, and the predicate fed by the answer + * ({@link grantAnchorsPlatformStanding}) are untouched. + * + * What retires the answer, in-process, synchronously: any write to + * `sys_organization` through the engine — insert, update (a slug moved + * included), delete — observed after it settles by the leg-5 write seam + * ({@link watchedWriteState}), so a Default Organization deleted and + * recreated is read anew on the next request (#21868 is the failure designed + * against). Across replicas: the bridges' non-`write` epoch bumps (`remote` + * from `authz.invalidated`, `metadata`), wholesale. Then the residual + * {@link DEFAULT_ORGANIZATION_MEMO_TTL_MS}. + * + * A stale answer can fail either way, and no design at this cost avoids the + * granting one: a stale id withholds standing from the new Default + * Organization's grant, and keeps granting it to the old organization's grant + * while that row survives. So every window it can be stale in is one the + * residual bound caps. + * + * ⛔ An outage is not an answer: a read that throws (`AuthzStoreUnavailableError` + * from {@link tryFind}) memoises nothing and propagates, so the next request + * reads again — the reason `probeInstallOrganizations` gives for its own memo. + * A table never provisioned reads as zero organizations, the true answer + * there; the write that later creates an organization retires it. + * + * Declines to memoise — every call reads, exactly as before — when: + * - the engine lacks the write epoch or `registerMiddleware` (a `ql` whose + * writes this memo cannot see; every recording double), or its wiring threw; + * - the registry does not hold `sys_organization`: "not registered" is a + * verdict the boot can still contradict, and registering the object writes + * no row the seam would see. + */ +async function memoisedDefaultOrganizationId(ql: any): Promise { + if (!ql || typeof ql !== 'object' || !registryHoldsObject(ql, SystemObjectName.ORGANIZATION)) { + return readDefaultOrganizationId(ql); + } + const writes = watchedWriteState(ql, defaultOrganizationWriteStates, DEFAULT_ORGANIZATION_WATCHED_OBJECTS); + if (!writes) return readDefaultOrganizationId(ql); + // ⭐ Read BEFORE the reads, stored with the answer: a write that lands while + // they are in flight moves the generation past it, so that entry is dead on + // arrival — the safe direction. + const gen = writes.gen; + const hit = defaultOrganizationMemo.get(ql); + if (hit && hit.writes === writes && hit.gen === gen && hit.expiresAt > Date.now()) return hit.value; + const value = await readDefaultOrganizationId(ql); + defaultOrganizationMemo.set(ql, { value, writes, gen, expiresAt: Date.now() + DEFAULT_ORGANIZATION_MEMO_TTL_MS }); + return value; +} + /** The activation ledger's catalog types the resolver honours (ADR-0126 §3 regime C, as ADR-0131 D6 amends it). */ const LEDGER_CATALOG_TYPES = ['position', 'permission'] as const; @@ -1568,7 +1659,7 @@ export async function resolveUserAuthzGrants( return { namedByPosition, disabled }; }; const [defaultOrganizationId, { namedByPosition, disabled }] = await Promise.all([ - anchorReadsDefaultOrganization ? readDefaultOrganizationId(ql) : Promise.resolve(undefined), + anchorReadsDefaultOrganization ? memoisedDefaultOrganizationId(ql) : Promise.resolve(undefined), readPositionLedger(), ]); const platformAnchor = { walled, defaultOrganizationId }; @@ -2029,10 +2120,14 @@ function localizationSuccessCacheTtlMs( const LOCALIZATION_CACHE_WATCHED_OBJECTS: ReadonlySet = new Set(['sys_setting', 'sys_platform_setting']); /** The engine operations that read; every other operation on a watched object retires the bucket. */ -const LOCALIZATION_READ_OPERATIONS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); +const ENGINE_READ_OPERATIONS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); -/** Per-engine invalidation state for the localization success cache. */ -interface LocalizationWriteState { +/** + * Per-engine invalidation state for one cross-request answer read from a + * watched set of objects: the localization success cache, and the Default + * Organization memo ({@link memoisedDefaultOrganizationId}). + */ +interface WatchedWriteState { /** Advanced after each watched write settles, and on every non-`write` epoch bump. Compared, never interpreted. */ gen: number; } @@ -2041,12 +2136,17 @@ interface LocalizationWriteState { * `null` records an engine whose wiring THREW partway — poisoned, never cached * behind, so a half-attached seam can never stand behind an entry. */ -const localizationWriteStates = new WeakMap(); +const localizationWriteStates = new WeakMap(); + +/** The localization cache's engine-side state ({@link watchedWriteState}, {@link LOCALIZATION_CACHE_WATCHED_OBJECTS}). */ +function localizationWriteState(ql: object): WatchedWriteState | undefined { + return watchedWriteState(ql, localizationWriteStates, LOCALIZATION_CACHE_WATCHED_OBJECTS); +} /** - * Fetch — and on first sight of an engine, wire — the localization cache's - * engine-side invalidation state: a middleware that bumps the generation after - * every write to {@link LOCALIZATION_CACHE_WATCHED_OBJECTS} settles (on a throw + * Fetch — and on first sight of an engine, wire — the engine-side + * invalidation state one cross-request answer keeps in `states`: a middleware + * that bumps the generation after every write to `watched` settles (on a throw * too: a partial write is a write), and an epoch subscription that bumps it on * every non-`write` reason. Returns `undefined` for a `ql` without both seams, * and for one whose wiring threw. @@ -2054,7 +2154,7 @@ const localizationWriteStates = new WeakMap, + watched: ReadonlySet, +): WatchedWriteState | undefined { + const existing = states.get(ql); if (existing !== undefined) return existing ?? undefined; const register = (ql as { registerMiddleware?: unknown }).registerMiddleware; const epoch = (ql as { writeEpoch?: { subscribe?: unknown } }).writeEpoch; @@ -2076,7 +2180,7 @@ function localizationWriteState(ql: object): LocalizationWriteState | undefined // Not memoized: probing is cheap and a double's shape is static anyway. return undefined; } - const state: LocalizationWriteState = { gen: 0 }; + const state: WatchedWriteState = { gen: 0 }; try { (register as ( fn: (ctx: { object?: unknown; operation?: unknown }, next: () => Promise) => Promise, @@ -2084,8 +2188,8 @@ function localizationWriteState(ql: object): LocalizationWriteState | undefined const operation = ctx?.operation; if ( typeof ctx?.object !== 'string' - || !LOCALIZATION_CACHE_WATCHED_OBJECTS.has(ctx.object) - || (typeof operation === 'string' && LOCALIZATION_READ_OPERATIONS.has(operation)) + || !watched.has(ctx.object) + || (typeof operation === 'string' && ENGINE_READ_OPERATIONS.has(operation)) ) { return next(); } @@ -2106,10 +2210,10 @@ function localizationWriteState(ql: object): LocalizationWriteState | undefined }, ); } catch { - localizationWriteStates.set(ql, null); + states.set(ql, null); return undefined; } - localizationWriteStates.set(ql, state); + states.set(ql, state); return state; } @@ -2208,7 +2312,7 @@ interface LocalizationCacheEntry { expiresAt: number; kind: 'failure' | 'success'; /** `success` only: the engine-side invalidation state this value was read under. */ - writes?: LocalizationWriteState; + writes?: WatchedWriteState; /** `success` only: that state's generation at read time. */ writesGen?: number; /** `success` only: the settings occupant this value was read through. */ @@ -2226,7 +2330,7 @@ const localizationCache = new WeakMap> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + +let kernel: any; +let engine: any; +let app: any; +let token = ''; +let recording: string[] | undefined; + +async function request(path: string): Promise<{ status: number; sql: string[] }> { + recording = []; + const res = await app.request(`${ORIGIN}${API}${path}`, { headers: { Authorization: `Bearer ${token}` } }); + await res.text(); + const sql = recording; + recording = undefined; + return { status: res.status, sql }; +} + +beforeAll(async () => { + process.env.NODE_ENV = 'development'; + kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); + await kernel.use(new HonoServerPlugin({ port: 0 })); + await kernel.use(new AppPlugin(PIN_APP)); + await kernel.use(new PlatformObjectsPlugin()); + await kernel.use(new AuthPlugin({ secret: 'default-org-memo-24523-secret' })); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + engine = await kernel.getServiceAsync('objectql'); + const httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + const knex = (engine as any).getDriver('sys_user').knex; + knex.on('query', (q: any) => { if (recording) recording.push(String(q?.sql ?? '')); }); + const signIn = await app.request(`${ORIGIN}${API}/auth/sign-in/email`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(DEV_ADMIN), + }); + expect(signIn.status).toBe(200); + token = ((await signIn.json()) as { token: string }).token; +}, BOOT_TIMEOUT); + +afterAll(async () => { + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } +}, 60_000); + +async function measure(label: string) { + for (const path of ['/auth/get-session', '/data/pin_note?$top=1']) { + const counts: number[] = []; + let orgReads = 0; + let last: string[] = []; + for (let i = 0; i < 4; i++) { + const r = await request(path); + expect(r.status).toBe(200); + counts.push(r.sql.length); + orgReads = r.sql.filter((s) => /sys_organization/.test(s)).length; + last = r.sql; + } + // eslint-disable-next-line no-console + console.log(`MEASURE ${label} ${path} counts=${counts.join(',')} warmOrgReads=${orgReads}\n ${last.join('\n ')}`); + } +} + +describe('baseline', () => { + it('measures', async () => { + const [grant] = rowsOf(await engine.find('sys_user_permission_set', { where: { permission_set: 'admin_full_access' }, limit: 5, context: SYS })); + const [org] = rowsOf(await engine.find('sys_organization', { where: {}, limit: 5, context: SYS })); + // eslint-disable-next-line no-console + console.log('MEASURE grant', JSON.stringify(grant), 'org', JSON.stringify(org)); + await measure(`org-owned grant, slug=${org.slug}`); + await engine.update('sys_organization', { id: org.id, slug: 'acme' }, { context: SYS }); + await measure('org-owned grant, slug=acme'); + await engine.update('sys_user_permission_set', { id: grant.id, organization_id: null }, { context: SYS }); + await measure('org-less grant, slug=acme'); + await engine.update('sys_organization', { id: org.id, slug: 'default' }, { context: SYS }); + await measure('org-less grant, slug=default'); + }, 120_000); +}); From 091a8d52881d070c360a2b2ad2300177dfdc95e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:58:24 +0000 Subject: [PATCH 2/4] test(core,runtime): pin the Default Organization memo, its retirement and the statements golden Claude-Session: https://claude.ai/code/session_01Ug7sGD6qWVrzQNdihVX3T6 Co-authored-by: Claude --- ...-context.default-organization-memo.test.ts | 310 ++++++++++++++++++ ...ult-organization-statements.golden.test.ts | 205 +++++++++--- 2 files changed, 468 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts diff --git a/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts new file mode 100644 index 00000000000..ada9f45c769 --- /dev/null +++ b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24523] The Default Organization answer is memoised per engine across + * requests, and retired by a `sys_organization` write through the engine's + * write seam, by a non-`write` epoch bump, and by the residual TTL. + * + * Every case drives `resolveUserAuthzGrants` — the one caller — on a recording + * double that carries the two engine seams the memo requires, shaped as the + * engine's own (the same shape `resolve-localization-cache.test.ts` drives the + * leg-5 seam with): the write epoch, bumped with `'write'` when a write STARTS, + * and a middleware chain each write SNAPSHOTS when it starts. + * + * What each case reads: the `sys_organization` reads a resolution issued + * (zero on a memo hit), and the platform standing the answer feeds. ⛔ The + * reads themselves — `{ slug: 'default' }` limit 1, then `{}` limit 2 — are + * asserted byte-for-byte wherever they are issued: only how often they run + * moves. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec'; + +import { resetPlatformAdminEmailMemo } from './platform-admin.js'; +import { resolveUserAuthzGrants } from './resolve-authz-context.js'; +import { makeRecordingQl } from './__tests__/resolve-authz-context.batch-equivalence.testkit.js'; + +const NOW = Date.parse('2026-10-19T00:00:00.000Z'); +const ENVS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL', 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'] as const; + +let ambient: Record = {}; +beforeEach(() => { + ambient = Object.fromEntries(ENVS.map((k) => [k, process.env[k]])); + // `single`, no configured administrator, the grants cache at its default (off). + for (const k of ENVS) delete process.env[k]; + resetPlatformAdminEmailMemo(); +}); +afterEach(() => { + vi.restoreAllMocks(); + for (const k of ENVS) { + if (ambient[k] === undefined) delete process.env[k]; + else process.env[k] = ambient[k]; + } + resetPlatformAdminEmailMemo(); +}); + +type Middleware = (ctx: { object: string; operation: string }, next: () => Promise) => Promise; + +const BY_SLUG = { object: 'sys_organization', where: { slug: 'default' }, limit: 1, isSystem: true }; +const SOLE = { object: 'sys_organization', where: {}, limit: 2, isSystem: true }; + +/** + * The administrator's tables: one `admin_full_access` grant owned by + * `grantOrganization`, and the organizations given. + */ +function tablesOf(grantOrganization: string, organizations: Array>) { + return { + sys_user: [{ id: 'usr_1', email: 'admin@example.com' }], + sys_member: [], + sys_user_position: [], + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set: ADMIN_FULL_ACCESS, organization_id: grantOrganization }, + ], + sys_permission_set: [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }], + sys_organization: organizations, + } as Record>>; +} + +/** + * {@link makeRecordingQl} with the engine's write seams. `write(object, + * operation, apply)` is one engine write: the epoch first, then the chain as + * it stood when the write started, `apply` as its driver step. `pause` holds a + * write between its start and its driver step, for the in-flight cases. + */ +function makeEngineQl(tables: Record>>, seams = true) { + const ql = makeRecordingQl(tables) as ReturnType & Record; + const listeners = new Set<(epoch: number, reason: string) => void>(); + const middlewares: Middleware[] = []; + let epoch = 0; + const writeEpoch = { + get current() { + return epoch; + }, + bump(reason: string) { + epoch += 1; + for (const l of [...listeners]) l(epoch, reason); + return epoch; + }, + subscribe(l: (e: number, r: string) => void) { + listeners.add(l); + return () => listeners.delete(l); + }, + }; + if (seams) { + ql.writeEpoch = writeEpoch; + ql.registerMiddleware = (fn: Middleware) => { + middlewares.push(fn); + }; + } + const write = async ( + object: string, + operation: 'insert' | 'update' | 'delete', + apply: () => void, + pause?: Promise, + ): Promise => { + writeEpoch.bump('write'); + const chain = [...middlewares]; + let i = 0; + const next = async (): Promise => { + if (i < chain.length) return chain[i++]({ object, operation }, next); + if (pause) await pause; + apply(); + }; + await next(); + }; + const organizationReads = () => ql.calls.filter((c) => c.object === 'sys_organization'); + /** The `sys_organization` reads one resolution issued, and its platform standing. */ + const resolve = async () => { + const before = organizationReads().length; + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, seedEmail: 'admin@example.com' }); + return { reads: organizationReads().slice(before), standing: grants.positions.includes('platform_admin') }; + }; + return { ql, write, writeEpoch, middlewares, resolve }; +} + +describe('[#24523] the Default Organization answer is read once per engine', () => { + it('⭐ slugged `default`: the first resolution reads it, the next ones do not', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('⭐ the only organization, slugged otherwise: two reads once, then none', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'acme' }])); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG, SOLE], standing: true }); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('`undefined` is a real answer and is memoised: several organizations, none `default`', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'acme' }, { id: 'org_b', slug: 'beta' }])); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG, SOLE], standing: false }); + expect(await e.resolve()).toEqual({ reads: [], standing: false }); + // …and an organization write retires it like any other answer. + await e.write('sys_organization', 'update', () => {}); + expect((await e.resolve()).reads).toEqual([BY_SLUG, SOLE]); + }); + + it('CONTROL — an engine without the write seams memoises nothing: every resolution reads, as before', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }]), false); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + }); + + it('CONTROL — an organization-less grant never reads it, memo or not', async () => { + const e = makeEngineQl(tablesOf('', [{ id: 'org_a', slug: 'default' }])); + // The grant's organization is blank: the legacy organization-less spelling. + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + expect(e.middlewares).toHaveLength(0); + }); +}); + +describe('[#24523 / #21868] a sys_organization write through the engine retires the answer', () => { + it('⭐ the Default Organization deleted and recreated: the next resolution reads the new id', async () => { + const tables = tablesOf('org_a', [{ id: 'org_a', slug: 'default' }]); + const e = makeEngineQl(tables); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + + await e.write('sys_organization', 'delete', () => { + tables.sys_organization.splice(0); + }); + await e.write('sys_organization', 'insert', () => { + tables.sys_organization.push({ id: 'org_b', slug: 'default' }); + }); + // The first-user promotion's grant now belongs to the new organization. + tables.sys_user_permission_set[0].organization_id = 'org_b'; + + // A memo still answering `org_a` would withhold standing from `org_b`'s grant. + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('⭐ a slug change that moves `default` to another organization: the old organization\'s grant stops anchoring', async () => { + const tables = tablesOf('org_a', [{ id: 'org_a', slug: 'default' }, { id: 'org_b', slug: 'beta' }]); + const e = makeEngineQl(tables); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + + await e.write('sys_organization', 'update', () => { + tables.sys_organization[0].slug = 'alpha'; + }); + await e.write('sys_organization', 'update', () => { + tables.sys_organization[1].slug = 'default'; + }); + // The GRANTING direction: a memo still answering `org_a` would keep + // conferring standing on `org_a`'s grant. + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: false }); + + tables.sys_user_permission_set[0].organization_id = 'org_b'; + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('a write to another object — a watched grants table included — does not retire it', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + await e.resolve(); + await e.write('sys_user_permission_set', 'update', () => {}); + await e.write('sys_session', 'update', () => {}); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('the `remote` and `metadata` epoch bumps retire it; a bare `write` bump does not', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + await e.resolve(); + e.writeEpoch.bump('write'); + expect((await e.resolve()).reads).toEqual([]); + e.writeEpoch.bump('remote'); + expect((await e.resolve()).reads).toEqual([BY_SLUG]); + e.writeEpoch.bump('metadata'); + expect((await e.resolve()).reads).toEqual([BY_SLUG]); + expect((await e.resolve()).reads).toEqual([]); + }); + + it('a write that lands WHILE the read is in flight leaves that answer dead on arrival', async () => { + const tables = tablesOf('org_a', [{ id: 'org_a', slug: 'default' }]); + const e = makeEngineQl(tables); + // Register the observer first (one resolution), then retire it so the next one reads. + await e.resolve(); + e.writeEpoch.bump('manual'); + const find = e.ql.find.bind(e.ql); + let landed = false; + e.ql.find = async (object: string, opts: any) => { + const rows = await find(object, opts); + if (object === 'sys_organization' && !landed) { + landed = true; + // The rows above are the pre-write snapshot; the write settles before the resolution does. + await e.write('sys_organization', 'update', () => { + tables.sys_organization[0].slug = 'gone'; + }); + } + return rows; + }; + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + // Not served from the entry that read was stored under. + expect((await e.resolve()).reads).toEqual([BY_SLUG, SOLE]); + }); + + it('the TTL is the residual bound: an answer older than 30 s is read again', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW); + await e.resolve(); + clock.mockReturnValue(NOW + 29_999); + expect((await e.resolve()).reads).toEqual([]); + clock.mockReturnValue(NOW + 30_001); + expect((await e.resolve()).reads).toEqual([BY_SLUG]); + }); + + it('MEASURED — a write that STARTED before the observer registered is not seen; the TTL bounds it', async () => { + const tables = tablesOf('org_a', [{ id: 'org_a', slug: 'default' }]); + const e = makeEngineQl(tables); + const clock = vi.spyOn(Date, 'now').mockReturnValue(NOW); + let release!: () => void; + const pause = new Promise((r) => { + release = r; + }); + // The write starts with an empty chain: no observer is registered yet. + const inFlight = e.write('sys_organization', 'update', () => { + tables.sys_organization[0].slug = 'alpha'; + }, pause); + // The first resolution registers the observer and reads the pre-write row. + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + release(); + await inFlight; + // The write landed unobserved: the answer stands, stale, until the TTL. + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + clock.mockReturnValue(NOW + 30_001); + expect(await e.resolve()).toEqual({ reads: [BY_SLUG, SOLE], standing: true }); + }); +}); + +describe('[#24523] ⛔ an outage is not an answer', () => { + it('⭐ a failed read memoises nothing: it propagates, and the next resolution reads again', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + const find = e.ql.find.bind(e.ql); + let down = true; + e.ql.find = async (object: string, opts: any) => { + if (object === 'sys_organization' && down) { + await find(object, opts); // recorded as issued + throw new Error('connect ECONNREFUSED 127.0.0.1:5432'); + } + return find(object, opts); + }; + const refused = await resolveUserAuthzGrants(e.ql, 'usr_1', { nowMs: NOW, seedEmail: 'admin@example.com' }) + .then(() => undefined, (err: unknown) => err as { code?: unknown; status?: unknown }); + expect(refused).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + + down = false; + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + expect(await e.resolve()).toEqual({ reads: [], standing: true }); + }); + + it('a registry that does not hold sys_organization: no read, nothing wired, nothing memoised', async () => { + const e = makeEngineQl(tablesOf('org_a', [{ id: 'org_a', slug: 'default' }])); + let registered = false; + e.ql.registry = { getObject: (name: string) => (name === 'sys_organization' ? registered : true) }; + expect(await e.resolve()).toEqual({ reads: [], standing: false }); + expect(e.middlewares).toHaveLength(0); + // Registered later in the same boot: read at once, not answered from a verdict. + registered = true; + expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); + }); +}); diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index b442317617e..5709d7a1190 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -1,9 +1,30 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * MEASUREMENT SCAFFOLD — baseline reading, replaced by the golden. + * [#24523] Statements per authenticated request, pinned on a REAL kernel — the + * plugin set `default-organization-boot-invariant.pin.test.ts` composes, with + * `objectstack dev`'s seeded administrator, whose first-user promotion writes + * the `admin_full_access` grant OWNED BY THE DEFAULT ORGANIZATION (ADR-0131 D5). + * + * The batch-equivalence goldens' fixture holds only the organization-less + * grant, so it never exercised the Default Organization read; this one holds + * the organization-owned row. Before the memo, that read cost every such + * request one `sys_organization` statement (two when the one organization is + * not slugged `default`) on top of the organization-less admin's count. + * + * Counted: every statement the SQL driver issues inside the request's own + * async context (knex's `query` event, the same listener `Server-Timing`'s + * `db` span counts), so a background poller's statement is never counted. + * Warm: two requests first, so the session, the localization cache and the + * served metadata are warm before any count is read. + * + * Then the two retirements the memo owes (#21868 is the failure designed + * against), read over HTTP as the standing `get-session` reports: + * - the Default Organization deleted and a new one created; + * - `default` moved from one organization to another, both directions. */ +import { AsyncLocalStorage } from 'node:async_hooks'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; @@ -21,7 +42,18 @@ const BOOT_TIMEOUT = 180_000; const ORIGIN = 'http://localhost:3000'; const API = '/api/v1'; const SYS = { isSystem: true }; +/** `AuthPlugin`'s dev-admin defaults (`OS_SEED_ADMIN_*` unset). */ const DEV_ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +const GET_SESSION = '/auth/get-session'; +const DATA_READ = '/data/pin_note?$top=1'; + +/** + * The organization-less administrator's warm counts on this composition, + * measured on `origin/main` 5a871e4e41: `get-session` 8, the data read 10. + * The organization-owned administrator paid 9 / 11 there (slug `default`) and + * 10 / 12 (the sole organization slugged otherwise). + */ +const WARM = { [GET_SESSION]: 8, [DATA_READ]: 10 } as const; const PIN_APP: any = { manifest: { id: 'com.pin.defaultorgmemo24523', name: 'Default organization memo pins', version: '1.0.0' }, @@ -32,80 +64,159 @@ const PIN_APP: any = { const rowsOf = (r: unknown): Array> => Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); +const statements = new AsyncLocalStorage(); let kernel: any; let engine: any; let app: any; let token = ''; -let recording: string[] | undefined; - -async function request(path: string): Promise<{ status: number; sql: string[] }> { - recording = []; - const res = await app.request(`${ORIGIN}${API}${path}`, { headers: { Authorization: `Bearer ${token}` } }); - await res.text(); - const sql = recording; - recording = undefined; - return { status: res.status, sql }; +let prevNodeEnv: string | undefined; + +const find = async (object: string, where: Record = {}) => + rowsOf(await engine.find(object, { where, limit: 50, context: SYS })); + +/** One authenticated request, and the statements the driver issued for it. */ +async function request(path: string): Promise<{ status: number; body: any; sql: string[] }> { + const sql: string[] = []; + const res: Response = await statements.run(sql, () => + app.request(`${ORIGIN}${API}${path}`, { headers: { Authorization: `Bearer ${token}` } }), + ); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : undefined, sql: [...sql] }; +} + +/** The warm statement counts of both requests, and the `sys_organization` statements among them. */ +async function warm() { + const out: Record = {}; + for (const path of [GET_SESSION, DATA_READ]) { + await request(path); + await request(path); + const r = await request(path); + expect(r.status, JSON.stringify(r.body)).toBe(200); + out[path] = { count: r.sql.length, organization: r.sql.filter((s) => /sys_organization/.test(s)).length }; + } + return out; +} + +async function standing() { + const r = await request(GET_SESSION); + expect(r.status).toBe(200); + return { standing: r.body?.user?.isPlatformAdmin === true, organizationReads: r.sql.filter((s) => /sys_organization/.test(s)).length }; +} + +async function adminGrant() { + const [user] = await find('sys_user', { email: DEV_ADMIN.email }); + const [grant] = await find('sys_user_permission_set', { user_id: user.id, permission_set: 'admin_full_access' }); + return grant; } beforeAll(async () => { - process.env.NODE_ENV = 'development'; + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` arms it kernel = new ObjectKernel(); await kernel.use(new ObjectQLPlugin()); await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); await kernel.use(new HonoServerPlugin({ port: 0 })); await kernel.use(new AppPlugin(PIN_APP)); await kernel.use(new PlatformObjectsPlugin()); - await kernel.use(new AuthPlugin({ secret: 'default-org-memo-24523-secret' })); + await kernel.use(new AuthPlugin({ secret: 'default-org-memo-24523-pin-secret' })); await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); await kernel.use(new SharingServicePlugin()); await kernel.use(createRestApiPlugin({})); await kernel.use(createDispatcherPlugin({})); await kernel.bootstrap(); engine = await kernel.getServiceAsync('objectql'); - const httpServer = await kernel.getServiceAsync('http-server'); - app = httpServer.getRawApp(); - const knex = (engine as any).getDriver('sys_user').knex; - knex.on('query', (q: any) => { if (recording) recording.push(String(q?.sql ?? '')); }); + app = (await kernel.getServiceAsync('http-server')).getRawApp(); + (engine as any).getDriver('sys_user').knex.on('query', (q: { sql?: unknown }) => { + statements.getStore()?.push(String(q?.sql ?? '')); + }); const signIn = await app.request(`${ORIGIN}${API}/auth/sign-in/email`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(DEV_ADMIN), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(DEV_ADMIN), }); - expect(signIn.status).toBe(200); + expect(signIn.status, await signIn.clone().text()).toBe(200); token = ((await signIn.json()) as { token: string }).token; }, BOOT_TIMEOUT); afterAll(async () => { try { await kernel?.shutdown?.(); } catch { /* best-effort */ } + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; }, 60_000); -async function measure(label: string) { - for (const path of ['/auth/get-session', '/data/pin_note?$top=1']) { - const counts: number[] = []; - let orgReads = 0; - let last: string[] = []; - for (let i = 0; i < 4; i++) { - const r = await request(path); - expect(r.status).toBe(200); - counts.push(r.sql.length); - orgReads = r.sql.filter((s) => /sys_organization/.test(s)).length; - last = r.sql; - } - // eslint-disable-next-line no-console - console.log(`MEASURE ${label} ${path} counts=${counts.join(',')} warmOrgReads=${orgReads}\n ${last.join('\n ')}`); - } -} +describe('[#24523] statements per warm request: an organization-owned admin grant costs what an organization-less one does', () => { + it('the fixture: the promoted administrator\'s grant is owned by the Default Organization, slugged `default`', async () => { + const [organization] = await find('sys_organization'); + expect(organization.slug).toBe('default'); + expect((await adminGrant()).organization_id).toBe(organization.id); + expect((await standing()).standing).toBe(true); + }); + + it('⭐ slugged `default`: get-session 8 and the data read 10, with no sys_organization statement', async () => { + expect(await warm()).toEqual({ + [GET_SESSION]: { count: WARM[GET_SESSION], organization: 0 }, + [DATA_READ]: { count: WARM[DATA_READ], organization: 0 }, + }); + }); + + it('⭐ the sole organization slugged otherwise: the same counts', async () => { + const [organization] = await find('sys_organization'); + await engine.update('sys_organization', { id: organization.id, slug: 'acme' }, { context: SYS }); + expect(await warm()).toEqual({ + [GET_SESSION]: { count: WARM[GET_SESSION], organization: 0 }, + [DATA_READ]: { count: WARM[DATA_READ], organization: 0 }, + }); + expect((await standing()).standing).toBe(true); + }); -describe('baseline', () => { - it('measures', async () => { - const [grant] = rowsOf(await engine.find('sys_user_permission_set', { where: { permission_set: 'admin_full_access' }, limit: 5, context: SYS })); - const [org] = rowsOf(await engine.find('sys_organization', { where: {}, limit: 5, context: SYS })); - // eslint-disable-next-line no-console - console.log('MEASURE grant', JSON.stringify(grant), 'org', JSON.stringify(org)); - await measure(`org-owned grant, slug=${org.slug}`); - await engine.update('sys_organization', { id: org.id, slug: 'acme' }, { context: SYS }); - await measure('org-owned grant, slug=acme'); + it('CONTROL — the organization-less grant: the same counts, on either slug', async () => { + const grant = await adminGrant(); + const [organization] = await find('sys_organization'); await engine.update('sys_user_permission_set', { id: grant.id, organization_id: null }, { context: SYS }); - await measure('org-less grant, slug=acme'); - await engine.update('sys_organization', { id: org.id, slug: 'default' }, { context: SYS }); - await measure('org-less grant, slug=default'); - }, 120_000); + const sole = await warm(); + await engine.update('sys_organization', { id: organization.id, slug: 'default' }, { context: SYS }); + const slugged = await warm(); + await engine.update('sys_user_permission_set', { id: grant.id, organization_id: organization.id }, { context: SYS }); + for (const counts of [sole, slugged]) { + expect(counts).toEqual({ + [GET_SESSION]: { count: WARM[GET_SESSION], organization: 0 }, + [DATA_READ]: { count: WARM[DATA_READ], organization: 0 }, + }); + } + }); +}); + +describe('[#24523 / #21868] a sys_organization write retires the memoised Default Organization', () => { + it('⭐ the Default Organization deleted and recreated: the next request reads the new id', async () => { + const [old] = await find('sys_organization', { slug: 'default' }); + await standing(); // the control above wrote the organization: read it once + expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); + + await engine.delete('sys_organization', { where: { id: old.id }, context: SYS }); + const created = await engine.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYS }); + expect(created.id).not.toBe(old.id); + // The promotion's grant now belongs to the recreated organization. + const grant = await adminGrant(); + await engine.update('sys_user_permission_set', { id: grant.id, organization_id: created.id }, { context: SYS }); + + // A memo still answering the deleted id would withhold standing here. + expect(await standing()).toEqual({ standing: true, organizationReads: 1 }); + expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); + }); + + it('⭐ `default` moved to another organization: the old one\'s grant stops anchoring, the new one\'s starts', async () => { + const [from] = await find('sys_organization', { slug: 'default' }); + const to = await engine.insert('sys_organization', { name: 'Second', slug: 'second' }, { context: SYS }); + expect(await standing()).toEqual({ standing: true, organizationReads: 1 }); + expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); + + await engine.update('sys_organization', { id: from.id, slug: 'first' }, { context: SYS }); + await engine.update('sys_organization', { id: to.id, slug: 'default' }, { context: SYS }); + // The granting direction: a memo still answering `from` would keep conferring standing. + expect(await standing()).toEqual({ standing: false, organizationReads: 1 }); + + const grant = await adminGrant(); + await engine.update('sys_user_permission_set', { id: grant.id, organization_id: to.id }, { context: SYS }); + expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); + }); }); From c74585b7074da32f6fca52424ed19113bc402d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:04:51 +0000 Subject: [PATCH 3/4] chore(changeset): core patch for the Default Organization memo Claude-Session: https://claude.ai/code/session_01Ug7sGD6qWVrzQNdihVX3T6 Co-authored-by: Claude --- .changeset/24523-default-organization-memo.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/24523-default-organization-memo.md diff --git a/.changeset/24523-default-organization-memo.md b/.changeset/24523-default-organization-memo.md new file mode 100644 index 00000000000..5623ac82c58 --- /dev/null +++ b/.changeset/24523-default-organization-memo.md @@ -0,0 +1,12 @@ +--- +'@objectstack/core': patch +--- + +perf(core): an administrator whose `admin_full_access` grant belongs to the Default Organization no longer pays a `sys_organization` read on every authenticated request + +Clause-②: no (a per-engine memo of an unchanged read: no accept set, standing verdict or served body moves; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` because it is the platform-standing anchor) + +- **The Default Organization is read once per engine, not once per request.** Under `single`, `resolveUserAuthzGrants` asks for the Default Organization whenever the principal holds an organization-owned `admin_full_access` grant: one `sys_organization` read, or two when the only organization is not slugged `default`. The answer is now kept per engine across requests. On a booted composition the warm `get-session` costs 8 statements and a data read 10, the same as for an organization-less grant. Before, it cost 9 and 11, or 10 and 12. +- **What retires it.** Any `sys_organization` write through the engine (insert, update, delete), observed after it settles. A peer replica's write is seen through the `remote` and `metadata` epoch bumps the `authz.invalidated` and metadata bridges deliver. A 30-second bound covers what no seam sees: a peer's write with no bridge attached, a write that started before the engine's observer registered, and a write inside a transaction that has not committed yet. A Default Organization that is deleted and recreated, or a `default` slug moved to another organization, is read again on the next request. +- **A failed read is not kept.** A read that fails with the authorization store unavailable propagates as before, and the next request reads again. "No Default Organization" (no organization, or several and none `default`) is a real answer and is kept. +- **Unchanged:** the two reads, their filters and limits, and the platform-standing predicate the answer feeds. An engine without the write epoch and `registerMiddleware`, or a registry that does not hold `sys_organization`, keeps reading every time. From 9fda5d6b1cb80748bd6ff1b433447cfd5294db85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:17:47 +0000 Subject: [PATCH 4/4] docs(core): say which bump carries a peer's sys_organization write to the Default Organization memo Claude-Session: https://claude.ai/code/session_01Ug7sGD6qWVrzQNdihVX3T6 Co-authored-by: Claude --- .changeset/24523-default-organization-memo.md | 2 +- .../core/src/security/resolve-authz-context.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.changeset/24523-default-organization-memo.md b/.changeset/24523-default-organization-memo.md index 5623ac82c58..2c219fd80b2 100644 --- a/.changeset/24523-default-organization-memo.md +++ b/.changeset/24523-default-organization-memo.md @@ -7,6 +7,6 @@ perf(core): an administrator whose `admin_full_access` grant belongs to the Defa Clause-②: no (a per-engine memo of an unchanged read: no accept set, standing verdict or served body moves; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` because it is the platform-standing anchor) - **The Default Organization is read once per engine, not once per request.** Under `single`, `resolveUserAuthzGrants` asks for the Default Organization whenever the principal holds an organization-owned `admin_full_access` grant: one `sys_organization` read, or two when the only organization is not slugged `default`. The answer is now kept per engine across requests. On a booted composition the warm `get-session` costs 8 statements and a data read 10, the same as for an organization-less grant. Before, it cost 9 and 11, or 10 and 12. -- **What retires it.** Any `sys_organization` write through the engine (insert, update, delete), observed after it settles. A peer replica's write is seen through the `remote` and `metadata` epoch bumps the `authz.invalidated` and metadata bridges deliver. A 30-second bound covers what no seam sees: a peer's write with no bridge attached, a write that started before the engine's observer registered, and a write inside a transaction that has not committed yet. A Default Organization that is deleted and recreated, or a `default` slug moved to another organization, is read again on the next request. +- **What retires it.** Any `sys_organization` write through the engine (insert, update, delete), observed after it settles. With the `authz.invalidated` bridge attached (the grants cache on, `OS_AUTHZ_GRANTS_CACHE_TTL_MS` above 0, and a cross-process cluster driver), a peer replica's write arrives as a `remote` epoch bump and retires it; every other non-`write` bump (`metadata`, `manual`) retires it too. A 30-second bound covers what no seam sees: a peer's write with no bridge attached, which on the shipped default (the grants cache off) is every peer's write, so there the bound is the only cover for it; a write that started before the engine's observer registered; and a write inside a transaction that has not committed yet. A Default Organization that is deleted and recreated, or a `default` slug moved to another organization, is read again on the next request. - **A failed read is not kept.** A read that fails with the authorization store unavailable propagates as before, and the next request reads again. "No Default Organization" (no organization, or several and none `default`) is a real answer and is kept. - **Unchanged:** the two reads, their filters and limits, and the platform-standing predicate the answer feeds. An engine without the write epoch and `registerMiddleware`, or a registry that does not hold `sys_organization`, keeps reading every time. diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index a4c2ec740b1..df3aadb5b1e 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -951,8 +951,9 @@ const DEFAULT_ORGANIZATION_WATCHED_OBJECTS: ReadonlySet = new Set([Syste * [#24523] The residual bound on a memoised Default Organization answer — * ⛔ never the in-process rule, which is the write seam. It covers only what no * seam can see: a peer replica's `sys_organization` write on a deployment with - * no `authz.invalidated` bridge attached (a peer's delete-and-recreate is - * #21868's exact failure), a write that started before this engine's observer + * no `authz.invalidated` bridge attached — the shipped default, where this + * bound is the only cover for a peer's write (a peer's delete-and-recreate is + * #21868's exact failure) — a write that started before this engine's observer * registered, and a watched write inside an engine transaction that has not * committed when its statement completes. The same residual bound the * localization success cache defaults to; the price is two reads per engine @@ -992,9 +993,13 @@ const defaultOrganizationMemo = new WeakMap