From 328b3042499fb6a84f3eaaf04cd3abe6c2b7f713 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:37:38 +0000 Subject: [PATCH 01/18] wip(objectql): refuse a single-posture system insert with no organization (ADR-0131 D9) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/objectql/src/engine.ts | 17 +++- .../src/system-write-organization.test.ts | 69 ++++++++++--- .../src/tenancy/system-write-organization.ts | 99 +++++++++++++------ 3 files changed, 137 insertions(+), 48 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b0ab6aac69d..ce86fa20219 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -5725,7 +5725,9 @@ export class ObjectQL implements IObjectQLEngine { * [#9261] "The probe found no organizations" and "the probe could not run" * are different facts (ADR-0110 D3), and this returns the FIRST one only when * it measured it. The `catch {}` this replaces answered both with `[]`, which - * `resolveSystemWriteOrganization` reads as `no-organization-yet` — so one + * `resolveSystemWriteOrganization` then read as "no organization yet" (a + * branch that landed the row; since ADR-0131 D9 a registered-but-empty + * organization object is refused instead) — so one * transient failure silently skipped both halves of the #8844 ruling: the * single-organization stamp (rows land untenanted and fork exactly the * counter the ruling exists to protect) and the multi-organization REFUSAL @@ -5808,8 +5810,9 @@ export class ObjectQL implements IObjectQLEngine { * Returns the organization id to thread as `DriverOptions.tenantId` (the same * knob a session write sets, so the driver stamps the column and the counter * scopes by it), or `undefined` when there is nothing to resolve. Throws - * {@link SystemWriteOrganizationRequiredError} on the multi-organization - * branch. + * {@link SystemWriteOrganizationRequiredError} wherever no owner is + * derivable (ADR-0131 D9): a wall, several organizations, or — under + * `single`, in a composition that registers the organization object — none. * * Called AFTER the beforeInsert hooks on purpose: a hook that stamps the * organization itself has carried it, and must not then be refused for a @@ -5853,12 +5856,18 @@ export class ObjectQL implements IObjectQLEngine { if (rows.every((row) => carriesOrganization(row?.[tenantField]))) return undefined; const posture = this.resolveEnginePosture(); + // [ADR-0131 D9] Zero organizations is refused under `single` only where + // the composition registers the organization object; the composition that + // registers none keeps today's branch (`no-organization-object`). The + // probe itself is unchanged and answers `[]` for both — this is the one + // fact that tells them apart, read from the same registry the probe reads. const decision = await resolveSystemWriteOrganization({ posture, probeOrganizations: () => this.probeInstallOrganizations(), + organizationObjectRegistered: !!this._registry.getObject(ORGANIZATION_OBJECT), }); if (decision.kind === 'derived') return decision.organizationId; - if (decision.kind === 'no-organization-yet') return undefined; + if (decision.kind === 'no-organization-object') return undefined; throw new SystemWriteOrganizationRequiredError( object, posture, diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index f5d00312a92..e5ccae6c34c 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -144,12 +144,16 @@ const PLATFORM_GLOBAL = { const ORG_OBJECT = { name: 'sys_organization', fields: { name: { type: 'text' } } } as any; -async function makeEngine(opts: { posture?: string; organizations?: string[] } = {}) { +async function makeEngine(opts: { posture?: string; organizations?: string[]; lean?: boolean } = {}) { const observed: ObservedCall[] = []; const engine = new ObjectQL(); engine.registerDriver(makeDriver(observed, opts.organizations ?? [ORG_ID]), true); await engine.init(); - for (const o of [DISPATCH_ORDER, SYS_LEDGER, PLATFORM_GLOBAL, ORG_OBJECT]) { + // `lean` models a composition that registers NO organization object at all. + const objects = opts.lean + ? [DISPATCH_ORDER, SYS_LEDGER, PLATFORM_GLOBAL] + : [DISPATCH_ORDER, SYS_LEDGER, PLATFORM_GLOBAL, ORG_OBJECT]; + for (const o of objects) { engine.registry.registerObject(o, PACKAGE_ID); } if (opts.posture) engine.setTenancyPostureProvider(() => opts.posture); @@ -168,15 +172,16 @@ describe('#8844 the decision — the ruling as one pure function', () => { const probe = (ids: string[]) => () => Promise.resolve(ids); it('[binding point 1] single-tenant with exactly one organization derives it', async () => { - expect(await resolveSystemWriteOrganization({ posture: 'single', probeOrganizations: probe([ORG_ID]) })) - .toEqual({ kind: 'derived', organizationId: ORG_ID }); + expect(await resolveSystemWriteOrganization({ + posture: 'single', probeOrganizations: probe([ORG_ID]), organizationObjectRegistered: true, + })).toEqual({ kind: 'derived', organizationId: ORG_ID }); }); it.each(['group', 'isolated'] as const)( '[binding point 2] the walled posture %s refuses, without asking the database anything', async (posture) => { const probeOrganizations = vi.fn(async () => [ORG_ID]); - expect(await resolveSystemWriteOrganization({ posture, probeOrganizations })) + expect(await resolveSystemWriteOrganization({ posture, probeOrganizations, organizationObjectRegistered: true })) .toEqual({ kind: 'refuse', reason: 'walled-posture' }); // The count could not change the verdict on a walled install, so it is // not read — a refusal must not cost a query per write. @@ -192,16 +197,28 @@ describe('#8844 the decision — the ruling as one pure function', () => { expect(await resolveSystemWriteOrganization({ posture: 'single', probeOrganizations: probe([ORG_ID, SECOND_ORG_ID]), + organizationObjectRegistered: true, })).toEqual({ kind: 'refuse', reason: 'ambiguous-organization', organizationCount: 2 }); }); - it('[first boot] no organization yet is NOT a refusal — there is nothing to fork away from', async () => { - // Seeds land during `start()`; the admin, and with them the first - // organization, arrive by a later sign-up POST. Refusing here would refuse - // first boot itself, and there is no second partition to fork from anyway — - // #8686's `sys_organization`-insert handoff adopts exactly these rows. - expect(await resolveSystemWriteOrganization({ posture: 'single', probeOrganizations: probe([]) })) - .toEqual({ kind: 'no-organization-yet' }); + it('[ADR-0131 D9] `single` with the organization object registered and NO organization refuses', async () => { + // The Default Organization is a boot invariant under `single` (D3): it + // exists before the application seeds and before the listener. An install + // that registers the organization object and holds none of it has no owner + // to derive, and D9 refuses rather than landing a row nobody owns. + expect(await resolveSystemWriteOrganization({ + posture: 'single', probeOrganizations: probe([]), organizationObjectRegistered: true, + })).toEqual({ kind: 'refuse', reason: 'no-organization', organizationCount: 0 }); + }); + + it('[ADR-0131 Q2, held as-is] a composition with NO organization object keeps today\'s branch', async () => { + // A lean embedding / bare-kernel composition registers no + // `sys_organization` at all. Whether D9's refusal reaches it is ADR-0131 + // C8's question; C1 pins today's answer unchanged — nothing to stamp, + // nothing refused. + expect(await resolveSystemWriteOrganization({ + posture: 'single', probeOrganizations: probe([]), organizationObjectRegistered: false, + })).toEqual({ kind: 'no-organization-object' }); }); }); @@ -253,12 +270,32 @@ describe('#8844 single-tenant — the system write is stamped like a session wri expect(probes()).toBe(2); }); - it('[first boot] leaves the write untenanted when the install has no organization yet', async () => { + it('[ADR-0131 D9] refuses the write when the install registers the organization object and holds none', async () => { const { engine, observed } = await makeEngine({ organizations: [] }); - await engine.insert('dispatch_order', { subject: 'seeded' }, { context: SYSTEM_CTX } as any); - // Nothing to stamp and nothing refused: the row lands org-less exactly as - // before, for #8686's handoff to adopt when the organization appears. + const refusal = await engine + .insert('dispatch_order', { subject: 'no owner' }, { context: SYSTEM_CTX } as any) + .catch((e) => e); + // The envelope (ADR-0112): `code` and `status`, then the reason that + // separates this refusal from the other two. + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.status).toBe(500); + expect(refusal.reason).toBe('no-organization'); + // ADR-0123 D4: the refusal names what is missing. + expect(refusal.message).toContain('holds NO organization'); + // ⛔ Nothing reached the driver — not defaulted, not written org-less. + expect(observed.filter((c) => c.object === 'dispatch_order')).toEqual([]); + }); + + it('[ADR-0131 Q2, held as-is] a composition with no organization object still lands the write untenanted', async () => { + // DISCRIMINATING CONTROL for the refusal above: the same empty install, + // minus the organization object. C1 leaves this branch exactly as it was + // (ADR-0131 C8 decides whether D9 reaches it). + const { engine, observed } = await makeEngine({ organizations: [], lean: true }); + await engine.insert('dispatch_order', { subject: 'lean' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'dispatch_order')?.method).toBe('create'); expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBeUndefined(); + // The lean probe answers from the registry and never asks the driver. + expect(observed.filter((c) => c.object === 'sys_organization')).toEqual([]); }); }); diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 5cb7397cd96..a06a45d1f42 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -51,6 +51,12 @@ * posture. Nothing here renumbers anything; this module only decides what a * write ABOUT to happen resolves to. * + * ADR-0131 D9 generalises binding points 1 and 2 to every topology: under + * `single` the owner is DERIVED only when exactly one organization exists, and + * a write with no derivable owner is REFUSED — several organizations, a wall, + * and (since the Default Organization became a boot invariant, D3) zero + * organizations alike. ⛔ Nothing defaults to any owner but the derivable one. + * * ## The three exclusions, and why the refusal is not broader * * A refusal that fires too widely breaks every system write on a walled install @@ -78,12 +84,11 @@ * none by this control. So the exclusion is now a per-object verdict, and an * object nobody has adjudicated stays excluded and stays ON THE LIST. * - * ⚠️ The seed loader (`/^(sys_|cloud_|ai_)/` in `seed-loader.ts`) and - * #8686's backfill (`seed-tenancy-backfill.ts`) still carry the NAMESPACE - * form. That divergence is deliberate and NOT resolved here: #13491's - * landing is the runtime write path, and re-cutting the seed and backfill - * paths is a separate decision about rows those paths already wrote. Read - * the three together before changing any of them. + * ⚠️ This per-object exclusion STAYS until ADR-0131 C8, which retires it + * together with the ledger (D13); C1 withdrew only 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 of them. * - **Writes that already carry an organization** — on the execution context * or on the row itself. That IS "carrying an explicit organization"; the * ruling asks for nothing more. @@ -152,17 +157,17 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; * {@link classifyPlatformObjectTenancy} and * {@link isPlatformObjectOutOfTenantAuditScope}, which is what * `Engine.resolveSystemInsertOrganization` calls. - * - **`seed-loader.ts`** still cuts by the namespace regexp - * (`/^(sys_|cloud_|ai_)/`, at its `fallbackOrgId` decision). + * - **`seed-loader.ts`** no longer cuts at all: ADR-0131 D3 withdrew its + * namespace exemption (C1), so every seed row is stamped with the resolved + * organization — `sys_` / `cloud_` / `ai_` seeds included — or refused. * - **`seed-tenancy-backfill.ts`** still cuts by the namespace regexp (its own - * `PLATFORM_NAMESPACE`). + * `PLATFORM_NAMESPACE`); attributing the residue that path already wrote is + * ADR-0131 C7's. * - * ⛔ Do NOT restore the coupling by re-cutting the seed paths per object, and - * ⛔ do not re-stamp anything to match. Whether those two should follow is a - * separate decision about rows they have ALREADY WRITTEN, and the re-ruling's + * ⛔ Do NOT re-stamp anything here to match. What becomes of rows the seed paths + * have ALREADY WRITTEN is the residue question (C7), and the re-ruling's * execution point 3 (⛔ never silently rewrite behaviour) governs every answer - * to it — including "leave them as they are". Read the three together before - * changing any of them. + * to it. Read the three together before changing any of them. */ const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/; @@ -224,15 +229,23 @@ export function carriesOrganization(value: unknown): boolean { */ export type SystemWriteOrganizationDecision = /** - * Nothing to resolve — the install has no organization yet, which is the - * normal state of a fresh boot (seeds land during `start()`; the admin, and - * with them the first organization, arrive by a later sign-up POST). No - * organization exists, so nothing can be stamped and — crucially — nothing is - * forked either: there is no second partition to fork away from. #8686's - * `sys_organization`-insert handoff adopts exactly these rows the moment the - * answer becomes derivable. ⛔ Refusing here would refuse first boot itself. + * Nothing to resolve — this COMPOSITION registers no organization object at + * all (a lean embedding, a bare-kernel test), so no organization can exist + * and there is nothing to stamp or to refuse in the name of. The write lands + * as it always has. + * + * ⚠️ Deliberately NOT "the install has no organization yet". That reading + * used to share this kind, and it was the normal state of a fresh `single` + * boot while the Default Organization arrived only with the first sign-up. + * ADR-0131 D3 made that organization load-bearing — it exists before the + * application seeds and before the listener — so a `single` install that + * registers `sys_organization` and holds none of it is the boot invariant + * broken, and is refused (`no-organization`) like every other topology with + * no derivable owner (D9). Only the composition with no organization object + * keeps this branch; whether D9's refusal also reaches it is ADR-0131 C8's + * question, not this one's. */ - | { kind: 'no-organization-yet' } + | { kind: 'no-organization-object' } /** Binding point 1: exactly one organization, so the answer is derivable. */ | { kind: 'derived'; organizationId: string } /** Binding point 2: refuse loudly rather than default to `__global__`. */ @@ -251,25 +264,49 @@ export type SystemWriteRefusalReason = * topology falls under binding point 2 rather than getting a guessed default. * #8686's backfill draws the same line (`skipped-ambiguous-organization`). */ - | 'ambiguous-organization'; + | 'ambiguous-organization' + /** + * The posture says `single`, the composition registers the organization + * object, and it holds NO organization. Under ADR-0131 D3 the Default + * Organization exists before anything writes (plugin-auth creates it at + * boot, and a failure to is a boot error), so this is the boot invariant + * broken — and there is no owner to derive, which D9 refuses rather than + * writing a row nobody owns. + */ + | 'no-organization'; /** - * Resolve the organization a system-context write should carry. + * Resolve the organization a system-context write should carry (ADR-0131 D9): + * derived at exactly one organization under `single`, refused at zero, at + * several, and under a wall. * * `probeOrganizations` is called ONLY on the single-tenant branch — a walled * posture is refused without asking the database anything, because the answer * could not change the verdict. + * + * `organizationObjectRegistered` separates the two facts an empty probe can + * mean: an install with the organization object and none of its rows (refused, + * `no-organization`) and a composition with no organization object at all + * (`no-organization-object`, unchanged). It is a required input, not a + * defaulted one: a caller that does not know the answer must not be handed the + * permissive reading by omission. */ export async function resolveSystemWriteOrganization(args: { posture: TenancyPosture; /** Organization ids, capped at 2 by the caller — only "0 / 1 / several" matters. */ probeOrganizations: () => Promise; + /** Does this composition register the organization object ({@link ORGANIZATION_OBJECT}) at all? */ + organizationObjectRegistered: boolean; }): Promise { if (args.posture !== 'single') { return { kind: 'refuse', reason: 'walled-posture' }; } const ids = await args.probeOrganizations(); - if (ids.length === 0) return { kind: 'no-organization-yet' }; + if (ids.length === 0) { + return args.organizationObjectRegistered + ? { kind: 'refuse', reason: 'no-organization', organizationCount: 0 } + : { kind: 'no-organization-object' }; + } if (ids.length === 1) return { kind: 'derived', organizationId: ids[0] }; return { kind: 'refuse', reason: 'ambiguous-organization', organizationCount: ids.length }; } @@ -293,9 +330,15 @@ function buildRefusalMessage( reason === 'walled-posture' ? `this install runs the '${posture}' tenancy posture, where organizations are an enforced ` + `boundary and there is no single "install organization" to derive` - : `this install declares the 'single' tenancy posture but holds ` + - `${organizationCount ?? 'several'} organizations, so which one owns the row is not derivable ` + - `(exactly 1 is required to adopt one without guessing)`; + : reason === 'no-organization' + ? `this install declares the 'single' tenancy posture but holds NO organization, so there is no ` + + `Default Organization to derive the owner from. Under 'single' the Default Organization is ` + + `created at boot, before application seeds load and before the server accepts requests, and a ` + + `failure to create it stops the boot — so its absence means the organization was deleted or ` + + `this write ran in a composition that never booted the auth plugin` + : `this install declares the 'single' tenancy posture but holds ` + + `${organizationCount ?? 'several'} organizations, so which one owns the row is not derivable ` + + `(exactly 1 is required to adopt one without guessing)`; return ( `Insert on '${object}' was REFUSED: a system-context write on a tenant-scoped object must carry an ` + `organization, and ${condition}. Writing it anyway would store ` + From 599b051e7464e3536fef60d49f0feb3c4f6d06d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 15:54:43 +0000 Subject: [PATCH 02/18] wip(plugin-auth,metadata-protocol,runtime): Default Organization boot invariant, owner promotion, seed stamping (ADR-0131 D3/D9) Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 184 ++++++++++++--- .../plugin-auth/src/auth-plugin.test.ts | 126 ++++++++++- .../plugins/plugin-auth/src/auth-plugin.ts | 80 +++++-- .../src/default-org-bootstrap-once.ts | 28 ++- .../src/ensure-default-organization.test.ts | 111 +++++++++ .../src/ensure-default-organization.ts | 213 +++++++++++++++++- packages/runtime/src/app-plugin.ts | 9 +- 7 files changed, 689 insertions(+), 62 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index df690287d42..2b6a2f8e208 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -14,7 +14,13 @@ import type { Seed, EngineQueryOptions, } from '@objectstack/spec/data'; -import { SeedLoaderConfigSchema, isMultiValueField, referenceTargetOf } from '@objectstack/spec/data'; +import { + SeedLoaderConfigSchema, + isMultiValueField, + isTenancyDisabled, + referenceTargetOf, + resolveInjectedSystemColumns, +} from '@objectstack/spec/data'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { resolveSeedRecord } from '@objectstack/formula'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; @@ -405,17 +411,57 @@ function perOrganizationSeedRowId(authoredId: string, organizationId: string): s * or drop the record loudly. See the 15.1.x replay corruption incident: * every restart used to sever one lookup per replayed child record. */ +/** + * What the sole-organization read found ({@link SeedLoaderService} — see + * `resolveSoleOrganizationId` for each answer's meaning). + */ +type SoleOrganizationReading = + | { kind: 'sole'; organizationId: string } + | { kind: 'unowned'; organizationCount: number } + | { kind: 'no-organization-object' }; + +/** + * [ADR-0131 D9] Does a seed row of this object need an organization? + * + * The object carries an `organization_id` column on its REGISTERED schema — + * authored, or injected by the system-column pass — and has not opted out of + * tenancy. The injected half is `resolveInjectedSystemColumns`, the single + * source `applySystemFields` itself consumes, so this answers what the engine + * will register, not what the author happened to type. `organization_id` and + * only it, because that is the column this loader stamps. + */ +function seedRowNeedsOrganization(definition: unknown): boolean { + if (!definition || typeof definition !== 'object') return false; + if (isTenancyDisabled(definition)) return false; + const fields = (definition as { fields?: unknown }).fields; + if (fields && typeof fields === 'object' && Object.prototype.hasOwnProperty.call(fields, 'organization_id')) { + return true; + } + return resolveInjectedSystemColumns(definition).tenant; +} + export class SeedLoaderService implements ISeedLoaderService { private engine: IDataEngine; private metadata: IMetadataService; private logger: Logger; /** - * Tenant org to stamp BUSINESS seed rows with when the caller pinned no + * Organization to stamp EVERY seed row with when the caller pinned no * explicit `config.organizationId` (resolved per {@link resolveSoleOrganizationId}). - * Set once per {@link load}; never applied to `sys_`/`cloud_`/`ai_` platform - * seeds (those stay intentionally global/cross-tenant). + * Set once per {@link load}. + * + * [ADR-0131 D3] `sys_` / `cloud_` / `ai_` seeds take it too. They used to be + * exempt as "intentionally global/cross-tenant"; there are no platform-global + * seeds left, and a seeded `sys_business_unit` is the organization's own. */ private fallbackOrgId?: string; + /** + * [ADR-0131 D9] Why {@link fallbackOrgId} is unset, when it is: the install + * registers the organization object and holds no organization, or several + * (`unowned`), versus a composition with no organization object at all + * (`undefined` here — today's branch, unchanged). Only `unowned` refuses the + * rows of an organization-owned object. Set once per {@link load}. + */ + private unownedOrganizationCount?: number; /** * Roll-up summary values left stale so far in the CURRENT {@link load} — * bumped by {@link reportStaleSummaries}, sampled by {@link loadDataset} @@ -616,14 +662,24 @@ export class SeedLoaderService implements ISeedLoaderService { this.replayIdByAuthoredId.clear(); // When the caller pinned no target org (an in-process publish has no active - // user session — the AI build agent's publish path), BUSINESS seed rows - // would land `organization_id = NULL` and then vanish under strict - // org-scoping. If the tenant has exactly ONE organization, adopt it as a - // fallback so business seeds carry the tenant key like a normal write. - // Zero/many orgs → leave unset (genuinely ambiguous → keep the historical - // global/cross-tenant behavior; the publisher must scope explicitly). - this.fallbackOrgId = - config.organizationId == null ? await this.resolveSoleOrganizationId() : undefined; + // user session — the AI build agent's publish path; the `single`-posture + // boot seed), seed rows would land `organization_id = NULL`. If the install + // has exactly ONE organization, adopt it so every seed row carries the + // tenant key like a normal write. + // + // [ADR-0131 D9] Zero or several organizations no longer mean "keep the + // historical NULL": in a composition that registers the organization object + // there is no owner to derive, and the rows of an organization-owned object + // are REFUSED (see `loadDataset`). Under `single` the Default Organization + // is created at boot before seeds load (D3), so a boot seed never meets the + // zero case. A composition with no organization object keeps today's branch. + this.fallbackOrgId = undefined; + this.unownedOrganizationCount = undefined; + if (config.organizationId == null) { + const reading = await this.resolveSoleOrganizationId(); + if (reading.kind === 'sole') this.fallbackOrgId = reading.organizationId; + else if (reading.kind === 'unowned') this.unownedOrganizationCount = reading.organizationCount; + } // 1. Filter datasets by the scope axes (environment AND locale) const datasets = this.filterDatasets(request.seeds, config); @@ -1107,6 +1163,17 @@ export class SeedLoaderService implements ISeedLoaderService { } }; + // [ADR-0131 D9] With no organization pinned and none derivable on an install + // that registers the organization object, a row of an organization-owned + // object has no owner: it is REFUSED below, never written NULL. Read once + // per dataset, and only when it can matter. + const refuseUnownedRows = + config.organizationId == null && + this.fallbackOrgId === undefined && + this.unownedOrganizationCount !== undefined && + seedRowNeedsOrganization(await this.resolveObjectDefinition(objectName)); + let unownedRefusalLogged = false; + // Pin a single `now()` snapshot for the entire dataset so multi-pass // loads see one logical clock — the M9 determinism guarantee for seeds. const seedNow = new Date(); @@ -1181,13 +1248,43 @@ export class SeedLoaderService implements ISeedLoaderService { // Per-tenant tagging: stamp every seeded row with the target org — the // caller's explicit `config.organizationId`, or (when none was pinned) the - // single-org fallback for BUSINESS objects only. A `sys_`/`cloud_`/`ai_` - // platform seed never takes the fallback: those stay global/cross-tenant. - // A record that supplies its own `organization_id` always wins; objects - // without the column ignore the extra key at the engine. - const tenantOrg = - config.organizationId ?? - (/^(sys_|cloud_|ai_)/.test(objectName) ? undefined : this.fallbackOrgId); + // install's sole organization. [ADR-0131 D3] `sys_` / `cloud_` / `ai_` + // seeds take it too: their exemption ("intentionally global") is + // withdrawn, since no platform-global seeds remain. A record that + // supplies its own `organization_id` always wins; objects without the + // column ignore the extra key at the engine. + const tenantOrg = config.organizationId ?? this.fallbackOrgId; + if (refuseUnownedRows && tenantOrg === undefined && record['organization_id'] == null) { + // [ADR-0131 D9] No owner to stamp — refuse the row, loudly, by name. + errored++; + const count = this.unownedOrganizationCount ?? 0; + const error: ReferenceResolutionError = { + sourceObject: objectName, + field: 'organization_id', + targetObject: 'sys_organization', + targetField: 'id', + attemptedValue: null, + recordIndex: i, + message: + `Seed record #${i} of '${objectName}' was REFUSED: '${objectName}' carries an organization_id ` + + `column, this seed load names no organization (config.organizationId) and none can be derived — ` + + `the install holds ${count === 0 ? 'no organization at all' : 'several organizations'}. Every seed ` + + `row of an organization-owned object is stamped with its organization or refused; writing it would ` + + `store a row no organization owns. Fix it by passing the organization the seed populates as ` + + `config.organizationId, or by setting organization_id on the record. Under the 'single' tenancy ` + + `posture the Default Organization is created at boot, before seeds load, so a single-tenant ` + + `deployment that reaches this has lost that organization.`, + }; + errors.push(error); + allErrors.push(error); + // `error`, not `warn` (#4632): the record is dropped. Said once per + // dataset; every refused row is still counted in `allErrors`. + if (!unownedRefusalLogged) { + unownedRefusalLogged = true; + this.logger.error(`[SeedLoader] ${error.message}`, undefined, { object: objectName, recordIndex: i }); + } + continue; + } // Remember that WE wrote this value, so the reference pass below leaves it // alone. `tenantOrg` is an ID by construction — the caller's target org, // or a resolved `sys_organization.id` — never a natural key. But @@ -1706,18 +1803,27 @@ export class SeedLoaderService implements ISeedLoaderService { // ========================================================================== /** - * Best-effort resolve the tenant's SOLE organization id — used to stamp - * business seed rows when the caller pinned no `config.organizationId` (an - * in-process publish has no active user session). A fresh env has exactly one - * org, so its seeds should carry it like a normal write instead of landing - * org-less (→ invisible under strict org-scoping). Returns undefined when - * there are zero or several orgs (genuinely ambiguous — keep the historical - * global/cross-tenant NULL) or when `sys_organization` is absent. + * Resolve the install's SOLE organization — used to stamp every seed row + * when the caller pinned no `config.organizationId` (an in-process publish + * has no active user session; the `single`-posture boot seed passes none). + * A `single` install has exactly one organization, so its seeds carry it like + * a normal write instead of landing org-less. + * + * Three answers ({@link SoleOrganizationReading}): + * - `sole` — exactly one organization; stamp with it. + * - `unowned` — [ADR-0131 D9] the composition registers the organization + * object and holds zero organizations (a missing table included: it can + * hold no row) or several: no owner is derivable, and the caller refuses + * the rows of an organization-owned object rather than writing NULL. + * - `no-organization-object` — the composition registers no organization + * object at all (a lean runtime); today's branch, unchanged: nothing is + * stamped and nothing refused (ADR-0131 C8 decides whether D9 reaches it). * * [#12852] A read that FAILED for any other reason PROPAGATES — it is not * an emptiness. See the catch below. */ - private async resolveSoleOrganizationId(): Promise { + private async resolveSoleOrganizationId(): Promise { + let count: number; try { const rows = await this.engine.find('sys_organization', { fields: ['id'], @@ -1726,8 +1832,9 @@ export class SeedLoaderService implements ISeedLoaderService { } as any); if (Array.isArray(rows) && rows.length === 1) { const id = (rows[0] as { id?: unknown; _id?: unknown })?.id ?? (rows[0] as { _id?: unknown })?._id; - return id ? String(id) : undefined; + if (id) return { kind: 'sole', organizationId: String(id) }; } + count = Array.isArray(rows) ? rows.length : 0; } catch (error) { // [#12852] Discriminate by error TYPE, the same repair commit 855591fe7 made to // `ObjectQL.probeInstallOrganizations` — the sibling probe with this @@ -1769,11 +1876,28 @@ export class SeedLoaderService implements ISeedLoaderService { // seen. It propagates, envelope intact: the seed run fails loudly instead // of writing a batch of rows nobody will be able to see. No new error code // and no new result field — the caller receives the read's own failure. + // + // [ADR-0131 D9] Those two benign causes now answer differently. The + // engine's refusal names a composition with NO organization object — + // today's branch. A missing table is an organization object whose table + // holds nothing, so it is the zero case, decided below by the same + // registration read as an empty answer. const refused = error as { code?: unknown; object?: unknown } | null | undefined; const refusedThisObject = refused?.code === 'OBJECT_NOT_FOUND' && refused.object === 'sys_organization'; - if (!isMissingTableError(error, 'sys_organization') && !refusedThisObject) throw error; + if (refusedThisObject) return { kind: 'no-organization-object' }; + if (!isMissingTableError(error, 'sys_organization')) throw error; + count = 0; } - return undefined; + // Zero, or several (the read is capped at 2): no owner is derivable — but + // only a composition that REGISTERS the organization object makes that an + // unowned install. The registration read is the same one the engine's own + // decision takes (`resolveSystemWriteOrganization`'s + // `organizationObjectRegistered`), answered from the object definition the + // loader resolves every dataset's object through. + const organizationObject = await this.resolveObjectDefinition('sys_organization'); + return organizationObject + ? { kind: 'unowned', organizationCount: count } + : { kind: 'no-organization-object' }; } /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index a3fec8e5535..e8a4c5aa379 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1339,6 +1339,11 @@ describe('AuthPlugin', () => { (tables[object] ??= []).push(data); return data; }), + update: vi.fn(async (object: string, data: any) => { + const row = (tables[object] ?? []).find((r) => r.id === data.id); + if (row) Object.assign(row, data); + return row ?? null; + }), }; }; @@ -1425,6 +1430,11 @@ describe('AuthPlugin', () => { it('multi-org: bootstrap is NOT wired (the organizations package owns it)', async () => { process.env.OS_MULTI_ORG_ENABLED = 'true'; + // The organizations runtime is present, so the wall is IN FORCE — the + // realistic walled shape. (Without it the request is degraded, which + // behaves as `single` and gets the Default Organization: see below.) + const getService = mockContext.getService; + mockContext.getService = vi.fn((name: string) => (name === 'org-scoping' ? {} : getService(name))); // [#11184] A walled posture now declares its platform owner or refuses // to boot; this fixture's subject is the default-org wiring, so declare // one (the boot-refusal itself is pinned in @@ -1442,12 +1452,107 @@ describe('AuthPlugin', () => { } }); - it('autoDefaultOrganization: false opts out', async () => { + // ── [ADR-0131 D3] the Default Organization is a BOOT INVARIANT under `single` ── + + it('[ADR-0131 D3] single: the Default Organization exists after start(), before kernel:ready, with no admin yet', async () => { + // A fresh deployment: nobody has signed up, so there is no platform admin + // for the owner bind to find — the organization must not wait for one. + ql.tables.sys_user_permission_set = []; + await boot(); + // Before `kernel:ready` — i.e. before `AppPlugin.start()`'s seed (ordered + // after this plugin) and before `kernel:listening`. + expect(ql.tables.sys_organization).toHaveLength(1); + expect(ql.tables.sys_organization[0]).toMatchObject({ slug: 'default', name: 'Default Organization' }); + // Nobody is bound: there is nobody to bind. + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('[ADR-0131 D3] autoDefaultOrganization: false turns off only the OWNER BIND, not the organization', async () => { await boot({ autoDefaultOrganization: false }); await hookCapture.trigger('kernel:ready'); - expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_organization).toHaveLength(1); expect(ql.tables.sys_member).toHaveLength(0); }); + + it('[ADR-0131 D3] an existing Default Organization is reused, never duplicated', async () => { + ql.tables.sys_organization = [{ id: 'org_existing', slug: 'default' }]; + await boot(); + expect(ql.tables.sys_organization).toEqual([{ id: 'org_existing', slug: 'default' }]); + }); + + it('[ADR-0131 D3] several organizations and none is the default: nothing is created', async () => { + ql.tables.sys_organization = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]; + await boot(); + expect(ql.tables.sys_organization.map((o: any) => o.id)).toEqual(['org_a', 'org_b']); + }); + + it('[ADR-0131 D3] a failed Default Organization insert FAILS start() — a boot error, never warn-only', async () => { + ql.insert.mockImplementation(async (object: string) => { + throw new Error(`driver refused the insert into ${object}`); + }); + await expect(boot()).rejects.toThrow(/BOOT REFUSED: the Default Organization could not be created/); + expect(ql.tables.sys_organization).toHaveLength(0); + }); + + it('[ADR-0131 D3] an unreadable store FAILS start() rather than minting a second organization', async () => { + // An unreadable store is not an empty one: reading it as empty would + // create an organization beside the one it could not see. + ql.find.mockImplementation(async () => { throw new Error('connection reset'); }); + await expect(boot()).rejects.toThrow(/connection reset/); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('[ADR-0131 D3] a walled posture in force gets no Default Organization from this plugin', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + const oldOwner = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = 'operator@corp.example'; + const getService = mockContext.getService; + mockContext.getService = vi.fn((name: string) => (name === 'org-scoping' ? {} : getService(name))); + try { + await boot(); + expect(ql.tables.sys_organization).toHaveLength(0); + } finally { + if (oldOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = oldOwner; + } + }); + + // ── [ADR-0131 D3 / ADR-0093 D7] the first admin is the OWNER ── + + it('[ADR-0131 D3] the first admin, bound as `member` by the reconciler, is promoted to owner', async () => { + // The organization exists before the first sign-up now, so the + // reconciler binds the new user as `member` before this bootstrap learns + // they are the platform admin (u1 holds the first-user grant). + await boot(); + const orgId = ql.tables.sys_organization[0].id; + ql.tables.sys_member.push({ id: 'mem_reconciled', organization_id: orgId, user_id: 'u1', role: 'member' }); + await hookCapture.trigger('kernel:ready'); + // Promoted in place — one row, now `owner` — not a second membership. + expect(ql.tables.sys_member).toEqual([ + { id: 'mem_reconciled', organization_id: orgId, user_id: 'u1', role: 'owner' }, + ]); + }); + + it('[ADR-0131 D3] no promotion when the Default Organization already has an owner', async () => { + await boot(); + const orgId = ql.tables.sys_organization[0].id; + ql.tables.sys_member.push( + { id: 'mem_owner', organization_id: orgId, user_id: 'someone_else', role: 'owner' }, + { id: 'mem_reconciled', organization_id: orgId, user_id: 'u1', role: 'member' }, + ); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member.find((m: any) => m.id === 'mem_reconciled').role).toBe('member'); + }); + + it('[ADR-0131 D3] no promotion of a membership in another organization', async () => { + await boot(); + ql.tables.sys_organization.push({ id: 'org_other', slug: 'other' }); + ql.tables.sys_member.push({ id: 'mem_elsewhere', organization_id: 'org_other', user_id: 'u1', role: 'member' }); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_member).toEqual([ + { id: 'mem_elsewhere', organization_id: 'org_other', user_id: 'u1', role: 'member' }, + ]); + }); }); // #2996 — the ADR-0093 D6 membership backfill re-runs on `app:seeded` so @@ -1533,10 +1638,15 @@ describe('AuthPlugin', () => { }); it('app:seeded runs the one-time pass when kernel:ready had no target organization yet', async () => { - // No platform admin at kernel:ready ⇒ no default organization ⇒ the - // pass has no target, decides nothing and records nothing. + // No default organization at kernel:ready ⇒ the pass has no target, + // decides nothing and records nothing. [ADR-0131 D3] `start()` now + // creates that organization on every `single` boot, so the state is + // reached the way it still can be: the organization is gone by + // kernel:ready (deleted), and reappears before the seed settles. ql.tables.sys_user_permission_set = []; await boot(); + expect(ql.tables.sys_organization).toHaveLength(1); + ql.tables.sys_organization.length = 0; await hookCapture.trigger('kernel:ready'); expect(ql.tables.sys_organization).toHaveLength(0); expect(ql.tables.sys_migration).toHaveLength(0); @@ -1582,11 +1692,15 @@ describe('AuthPlugin', () => { }; it('the default-org-created trigger runs the one-time pass', async () => { - // No platform admin at kernel:ready: no organization, the pass has no - // target and records nothing. + // No organization at kernel:ready: the pass has no target and records + // nothing. [ADR-0131 D3] `start()` creates the organization on every + // `single` boot, so the case is the one that remains: it was deleted + // before kernel:ready, and the bootstrap RE-creates it. ql.tables.sys_user_permission_set = []; ql.tables.sys_user.push({ id: 'early_u5' }); await boot(); + expect(ql.tables.sys_organization).toHaveLength(1); + ql.tables.sys_organization.length = 0; await hookCapture.trigger('kernel:ready'); expect(ql.tables.sys_organization).toHaveLength(0); expect(ql.tables.sys_migration).toHaveLength(0); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 93a3bc6eff7..592de2d9315 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -41,7 +41,10 @@ import { DEFAULT_AUTH_BASE_PATH, type AuthManagerOptions, } from './auth-manager.js'; -import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js'; +import { + ensureDefaultOrganizationExists, + isDefaultOrganizationBootstrapTrigger, +} from './ensure-default-organization.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import { withSystemContext } from './objectql-adapter.js'; @@ -191,20 +194,25 @@ export interface AuthPluginOptions extends Partial { */ /** - * The single-org default-organization bootstrap (a pre-repo decision whose + * The single-org default-organization OWNER BIND (a pre-repo decision whose * old label collides with this repo's ADR series — see the citation note - * under ADR-0093 D9; the membership guarantee it backs is D9). In single-org - * mode (`OS_MULTI_ORG_ENABLED` unset/false) nothing else ever creates an - * organization, so sessions carry no `activeOrganizationId` and better-auth - * `organization/invite-member` has no org to resolve — i.e. no way to add a - * user at all. When enabled (default), the plugin idempotently creates the - * `Default Organization` (slug `default`) and binds the platform admin as - * `owner`, on `kernel:ready` and after every write matched by - * `isDefaultOrganizationBootstrapTrigger` (a `sys_user` insert or - * email/email_verified update — the config-anchor trigger set — plus the - * legacy `sys_user_permission_set` insert that `single`-posture first-user - * promotion still writes). Inert in multi-org mode — the enterprise - * organizations package owns the bootstrap there. + * under ADR-0093 D9; the membership guarantee it backs is D9). When enabled + * (default), the plugin binds the platform admin as `owner` of the + * `Default Organization` (slug `default`) — or promotes the `member` row the + * membership reconciler gave them there — on `kernel:ready` and after every + * write matched by `isDefaultOrganizationBootstrapTrigger` (a `sys_user` + * insert or email/email_verified update — the config-anchor trigger set — + * plus the legacy `sys_user_permission_set` insert that `single`-posture + * first-user promotion still writes), recreating the organization if it went + * missing. Inert in multi-org mode — the enterprise organizations package + * owns the bootstrap there. + * + * ⚠️ BREAKING (ADR-0131 D3): `false` no longer means "no organization". + * Under the `single` posture the Default Organization is a boot invariant — + * created in `start()` whatever this option says, before the application + * seeds load, and a failure to create it fails the boot — because every row + * has an owner and `single` derives it from that organization. `false` now + * turns off only the owner bind. * @default true */ autoDefaultOrganization?: boolean; @@ -339,6 +347,8 @@ export class AuthPlugin implements Plugin { private authManager: AuthManager | null = null; /** ADR-0093 D4 — the tenancy service registered in init(); reused at kernel:ready. */ private tenancy: TenancyService | null = null; + /** [ADR-0131 D3] Whether `start()` created the Default Organization (a fresh install). */ + private defaultOrganizationCreatedAtBoot = false; private configuredSocialProviders: SocialProviderConfig | undefined; // ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or // the kernel-cache adapter wired in init). The identity write guard's @@ -703,6 +713,41 @@ export class AuthPlugin implements Plugin { throw new Error('Auth manager not initialized'); } + // [ADR-0131 D3] The Default Organization is a BOOT INVARIANT under the + // `single` posture: it owns every row, so it exists before the application + // seeds load (every `AppPlugin` declares this plugin in its + // `optionalDependencies`, so the kernel starts it first) and before the + // server accepts a request (`kernel:listening` follows every `start()`). + // A failure THROWS and fails the boot — never warn-only. + // + // Judged on the posture IN FORCE (`tenancy.posture`), the same judge + // `AppPlugin` uses to decide on its inline seed: a degraded walled request + // behaves as `single` and seeds inline, so it needs the owner too. + // + // ⛔ Not gated on `autoDefaultOrganization`. That option governs the + // platform admin's OWNER BIND (below, on `kernel:ready`); whether the + // organization exists is not an onboarding convenience any more — under + // `single` no row is org-less (ADR-0131 D11). + if (this.tenancy?.posture === 'single') { + let ql: IDataEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { ql = undefined; } + // An engine that can neither read nor write (mock mode — the same guard + // `ensureDefaultOrganization` opens with) writes no row that needs an + // owner. A real composition always has one: `objectql` is this plugin's + // hard dependency. + if (ql && typeof ql.find === 'function' && typeof ql.insert === 'function') { + const invariant = await ensureDefaultOrganizationExists(ql, { logger: ctx.logger }); + if (invariant.created) this.defaultOrganizationCreatedAtBoot = true; + if (invariant.ambiguous) { + ctx.logger.warn( + `[auth] the 'single' tenancy posture holds ${invariant.ambiguous.organizationCount} organizations and none ` + + 'is the Default Organization (slug \'default\'), so no owner is derivable: system writes and seed rows ' + + 'that need one are refused (ADR-0131 D9). The tenancy organization census names the remedies.', + ); + } + } + } + // [#8009] Move any provider row still holding a CLEARTEXT OIDC client // secret in `oidc_config` into the encrypted column. Registered // unconditionally (not under `registerRoutes`) because the disposition of @@ -1153,7 +1198,12 @@ export class AuthPlugin implements Plugin { // `@objectstack/organizations` calls too): recorded in the // `sys_migration` ledger and latched in-process, after which this hook // still recreates a missing default organization but binds nobody. - const ensureOnce = createEnsureDefaultOrganizationOnce({ logger: ctx.logger }); + const ensureOnce = createEnsureDefaultOrganizationOnce({ + logger: ctx.logger, + // [ADR-0131 D3] The organization `start()` created is this install's + // fresh one; a ledger-less kernel may bind its owner (see the option). + organizationCreatedByThisProcess: () => this.defaultOrganizationCreatedAtBoot, + }); const runEnsure = async () => { try { const ql = ctx.getService('objectql'); diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts index a2d30eeee50..61d32dc95c8 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -20,8 +20,9 @@ * record landed — the bootstrap still (re)creates a missing default * organization but binds nobody and hands over no seed ownership; * - with no ledger on the kernel it binds only on the call that CREATES the - * default organization, so a fresh install still gets its owner and an - * existing organization is never re-bound; + * default organization — or, now that `single` creates it at boot + * (ADR-0131 D3), when this process created it — so a fresh install still + * gets its owner and an existing organization is never re-bound; * - with a ledger that failed to answer it binds nobody on that call (it may * still create the organization) and leaves the decision to the next * trigger. @@ -46,6 +47,16 @@ export interface EnsureDefaultOrganizationOnceOptions { claimSeedOwnership?: EnsureDefaultOrganizationOptions['claimSeedOwnership']; /** The bootstrap to gate — the plugin-auth helper unless a test injects one. */ ensure?: typeof ensureDefaultOrganization; + /** + * [ADR-0131 D3] Did THIS process create the Default Organization at boot + * (`ensureDefaultOrganizationExists`)? Under `single` the organization no + * longer waits for the admin, so the call that creates it is no longer this + * gate's — and a kernel with no ledger binds "only on the call that CREATES + * the default organization". An organization this process created is that + * fresh install, so the bind is admitted for it; one that predates the + * process is still never re-bound. + */ + organizationCreatedByThisProcess?: () => boolean; } /** @@ -80,13 +91,16 @@ export function createEnsureDefaultOrganizationOnce( // answer: the decision may well be recorded, so this call binds nobody — // it can still (re)create the organization — and the next trigger, with a // readable ledger, decides. + const bindOnlyOnCreate = + reading === 'unavailable' && options.organizationCreatedByThisProcess?.() !== true; const res = reading === 'unreadable' ? await ensure(ql, { ...base, bindOwner: false }) - : await ensure(ql, { ...base, bindOnlyOnCreate: reading === 'unavailable' }); - // Decided: the admin was bound now, or already held a membership. - // `no_admin` and the failed writes leave it open for the next trigger. - const actedOn = res.memberCreated || res.reason === 'admin_already_in_org'; + : await ensure(ql, { ...base, bindOnlyOnCreate }); + // Decided: the admin was bound now, promoted from the reconciler's own + // `member` row (ADR-0131 D3), or already held a membership. `no_admin` and + // the failed writes leave it open for the next trigger. + const actedOn = res.memberCreated || res.ownerPromoted === true || res.reason === 'admin_already_in_org'; if (!actedOn) return res; decided = true; if (reading === 'absent') { @@ -95,7 +109,7 @@ export function createEnsureDefaultOrganizationOnce( buildLedgerDecisionRecord( DEFAULT_ORG_OWNER_BIND_MIGRATION_ID, { - outcome: res.memberCreated ? 'bound' : 'admin-already-member', + outcome: res.memberCreated ? 'bound' : res.ownerPromoted ? 'promoted' : 'admin-already-member', ...(res.defaultOrgId ? { organizationId: res.defaultOrgId } : {}), }, new Date().toISOString(), diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 6887b0ce524..221822ece6d 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -8,8 +8,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { ensureDefaultOrganization, + ensureDefaultOrganizationExists, isDefaultOrganizationBootstrapTrigger, } from './ensure-default-organization.js'; +import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; // [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on // the raw value), so every case in this file pins the variable's state instead @@ -497,3 +499,112 @@ describe('[#11663 L5] under a WALLED posture the legacy grant anchors nothing', expect(isDefaultOrganizationBootstrapTrigger({ object: 'sys_user_permission_set', operation: 'insert' })).toBe(true); }); }); + +// ── [ADR-0131 D3] the Default Organization boot invariant, and the owner pin ── + +describe('[ADR-0131 D3] ensureDefaultOrganizationExists — the boot invariant needs no admin', () => { + it('creates the Default Organization on an empty install, with nobody to bind', async () => { + const ql = makeQl({ sys_user_permission_set: [] }); + const res = await ensureDefaultOrganizationExists(ql); + expect(res.created).toBe(true); + expect(ql.tables.sys_organization).toEqual([ + expect.objectContaining({ id: res.organizationId, slug: 'default', name: 'Default Organization' }), + ]); + expect(ql.tables.sys_member).toEqual([]); + }); + + it('answers the existing organization without writing — by slug first, else the sole one', async () => { + const bySlug = makeQl({ sys_organization: [{ id: 'org_x', slug: 'x' }, { id: 'org_d', slug: 'default' }] }); + expect(await ensureDefaultOrganizationExists(bySlug)).toEqual({ organizationId: 'org_d', created: false }); + const sole = makeQl({ sys_organization: [{ id: 'org_renamed', slug: 'acme' }] }); + expect(await ensureDefaultOrganizationExists(sole)).toEqual({ organizationId: 'org_renamed', created: false }); + expect(bySlug.insert).not.toHaveBeenCalled(); + expect(sole.insert).not.toHaveBeenCalled(); + }); + + it('reports several organizations with no default as ambiguous and creates nothing', async () => { + const ql = makeQl({ sys_organization: [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }] }); + expect(await ensureDefaultOrganizationExists(ql)).toEqual({ created: false, ambiguous: { organizationCount: 2 } }); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('a refused insert THROWS (the caller fails the boot) — it is not best-effort', async () => { + const ql = makeQl(); + ql.insert.mockImplementation(async () => { throw new Error('UNIQUE constraint failed: sys_organization.slug'); }); + await expect(ensureDefaultOrganizationExists(ql)).rejects.toThrow( + /BOOT REFUSED: the Default Organization could not be created[\s\S]*UNIQUE constraint failed/, + ); + }); + + it('a failed READ propagates — an unreadable store is not an empty one', async () => { + const ql = makeQl(); + ql.find.mockImplementation(async () => { throw new Error('connection reset'); }); + await expect(ensureDefaultOrganizationExists(ql)).rejects.toThrow('connection reset'); + expect(ql.insert).not.toHaveBeenCalled(); + }); +}); + +describe('[ADR-0131 D3 / ADR-0093 D7] the first admin is the owner even when the reconciler bound them first', () => { + /** A ledger-carrying engine (the `sys_migration` object is registered). */ + function ledgerQl(seed: Partial> = {}) { + const ql = makeQl({ sys_migration: [], ...seed }) as ReturnType & Record; + ql.getObject = (name: string) => (name === 'sys_migration' ? { name } : undefined); + ql.findOne = async (object: string, q: any) => + (ql.tables[object] ?? []).find((r: Row) => r.id === q?.where?.id) ?? null; + ql.update = vi.fn(async (object: string, data: Row) => { + const row = (ql.tables[object] ?? []).find((r: Row) => r.id === data.id); + if (row) Object.assign(row, data); + return row ?? null; + }); + return ql; + } + + const reconciled = (organizationId: string): Row => + ({ id: 'mem_reconciled', organization_id: organizationId, user_id: 'u1', role: 'member' }); + + it('promotes the reconciler-written `member` row in place and records the decision as `promoted`', async () => { + const ql = ledgerQl({ sys_organization: [{ id: 'org_d', slug: 'default' }], sys_member: [reconciled('org_d')] }); + const res = await createEnsureDefaultOrganizationOnce()(ql); + expect(res).toMatchObject({ ownerPromoted: true, memberCreated: false, defaultOrgId: 'org_d' }); + expect(ql.tables.sys_member).toEqual([{ ...reconciled('org_d'), role: 'owner' }]); + const decision = ql.tables.sys_migration.find((r: Row) => r.id === 'adr-0093-default-org-owner-bind'); + expect(JSON.parse(decision.details)).toMatchObject({ outcome: 'promoted', organizationId: 'org_d' }); + }); + + it('never promotes once the owner bind is DECIDED (ADR-0093 D7: a removed owner stays removed)', async () => { + const ql = ledgerQl({ + sys_organization: [{ id: 'org_d', slug: 'default' }], + sys_member: [reconciled('org_d')], + sys_migration: [{ id: 'adr-0093-default-org-owner-bind', details: '{"outcome":"bound"}' }], + }); + const res = await createEnsureDefaultOrganizationOnce()(ql); + expect(res.ownerPromoted).toBeUndefined(); + expect(ql.tables.sys_member[0].role).toBe('member'); + expect(ql.update).not.toHaveBeenCalled(); + }); + + it('never promotes over an existing owner, nor a role other than `member`', async () => { + const withOwner = ledgerQl({ + sys_organization: [{ id: 'org_d', slug: 'default' }], + sys_member: [{ id: 'mem_o', organization_id: 'org_d', user_id: 'u9', role: 'owner' }, reconciled('org_d')], + }); + await ensureDefaultOrganization(withOwner); + expect(withOwner.update).not.toHaveBeenCalled(); + const asAdmin = ledgerQl({ + sys_organization: [{ id: 'org_d', slug: 'default' }], + sys_member: [{ ...reconciled('org_d'), role: 'admin' }], + }); + expect(await ensureDefaultOrganization(asAdmin)).toMatchObject({ reason: 'admin_already_in_org' }); + expect(asAdmin.update).not.toHaveBeenCalled(); + }); + + it('a refused promotion reports at `error` and leaves the bind undecided for the next trigger', async () => { + const ql = ledgerQl({ sys_organization: [{ id: 'org_d', slug: 'default' }], sys_member: [reconciled('org_d')] }); + ql.update.mockImplementation(async () => { throw new Error('write refused'); }); + const error = vi.fn(); + const res = await createEnsureDefaultOrganizationOnce({ logger: { info: vi.fn(), warn: vi.fn(), error } as any })(ql); + expect(res).toMatchObject({ reason: 'owner_promotion_failed' }); + expect(error).toHaveBeenCalledWith(expect.stringContaining('NOT promoted to owner'), undefined, expect.any(Object)); + expect(ql.tables.sys_migration).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index cea8ce4d719..762c95dd7cf 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -61,13 +61,21 @@ * 1. Find the platform admin (two anchors, above). If none, no-op. * 2. If that user already has any `sys_member` row, no-op (they either * created their own org or were invited into one — we respect that and - * never auto-create a "Default Organization" behind their back). + * never auto-create a "Default Organization" behind their back) — except + * the reconciler's own `member` row on the Default Organization, which is + * promoted to `owner` while the bind is undecided (ADR-0131 D3: that + * organization now exists before the admin does). * 3. Re-use a pre-existing `slug='default'` org if present; otherwise * create one. Stable slug keeps human-readable URLs predictable across * cold-boots. * 4. Insert a `sys_member { role: 'owner' }` linking the admin to the * default org. * 5. (optional, injected) hand the org's seeded rows to the admin. + * + * Under the `single` posture the organization itself no longer waits for the + * admin: {@link ensureDefaultOrganizationExists} creates it at boot (ADR-0131 + * D3, a boot invariant whose failure stops the boot), so step 3 finds it and + * this helper's job there is the owner bind. */ import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; @@ -297,12 +305,200 @@ export interface EnsureDefaultOrganizationResult { defaultOrgId?: string; /** Whether a sys_member row was inserted binding the admin to the default org. */ memberCreated: boolean; + /** + * [ADR-0131 D3 / ADR-0093 D7] Whether the admin's OWN `member` row on the + * Default Organization — the one the membership reconciler wrote when the + * admin was created, because the organization already existed — was promoted + * to `owner` instead of a new row being inserted. Only while the owner bind + * is undecided, and only when that organization has no owner. + */ + ownerPromoted?: boolean; /** Human-readable reason when the helper short-circuited. */ - reason?: 'no_admin' | 'admin_already_in_org' | 'org_insert_failed' | 'member_insert_failed' | 'owner_bind_decided'; + reason?: + | 'no_admin' + | 'admin_already_in_org' + | 'org_insert_failed' + | 'member_insert_failed' + | 'owner_promotion_failed' + | 'owner_bind_decided'; /** Count of the default org's seeded rows re-owned to the platform admin. */ ownershipClaimed?: number; } +/** A find result as rows, whichever envelope the engine answered with. */ +function rowsOf(result: unknown): any[] { + if (Array.isArray(result)) return result; + const records = (result as { records?: unknown } | null | undefined)?.records; + return Array.isArray(records) ? records : []; +} + +/** + * The Default Organization the way the `tenancy` service resolves it + * (`resolveDefaultOrgId`): the stable `slug='default'` organization, else the + * install's sole organization. `undefined` when there is neither — none, or + * several with no `default` among them. Reads through {@link tryFind}, so an + * unreadable store answers `undefined` and nothing is promoted on it. + */ +async function findDefaultOrganizationId(ql: any): Promise { + const bySlug = await tryFind(ql, 'sys_organization', { slug: 'default' }, 1); + if (bySlug[0]?.id) return String(bySlug[0].id); + const any = await tryFind(ql, 'sys_organization', {}, 2); + return any.length === 1 && any[0]?.id ? String(any[0].id) : undefined; +} + +/** + * [ADR-0131 D3 / ADR-0093 D7] Promote the platform admin's reconciler-written + * `member` row on the Default Organization to `owner` — or `null` when that is + * not the situation, and the caller's yield (`admin_already_in_org`) stands. + * + * Every condition is required, and each one is what keeps this from being a + * second, unaudited elevation path: + * - the owner bind is UNDECIDED: neither `bindOwner: false` (decided, ADR-0093 + * D7) nor `bindOnlyOnCreate` (no readable ledger, so a decision may exist); + * - the admin holds exactly ONE membership, on the Default Organization, with + * role `member` — the shape the reconciler (`reconcile-membership.ts`) + * writes, and nothing an operator or an invitation produced elsewhere; + * - the Default Organization has NO owner yet. + * + * A failed promotion is a durability failure (the admin stays `member` and + * nothing else fails), logged at `error`; the bind stays undecided, so the + * next bootstrap trigger tries again. + */ +async function promoteReconciledMemberToOwner( + ql: any, + adminUserId: string, + memberships: any[], + options: EnsureDefaultOrganizationOptions, +): Promise { + if (options.bindOwner === false || options.bindOnlyOnCreate) return null; + if (memberships.length !== 1 || typeof ql?.update !== 'function') return null; + const membership = memberships[0]; + if (!membership?.id) return null; + if (String(membership.role ?? '').trim().toLowerCase() !== 'member') return null; + const defaultOrgId = await findDefaultOrganizationId(ql); + if (!defaultOrgId || String(membership.organization_id ?? '') !== defaultOrgId) return null; + const owners = await tryFind(ql, 'sys_member', { organization_id: defaultOrgId, role: 'owner' }, 1); + if (owners.length > 0) return null; + + const logger = options.logger; + try { + await ql.update('sys_member', { id: membership.id, role: 'owner' }, { context: SYSTEM_CTX }); + } catch (e) { + logDurabilityFailure( + logger, + '[default-org] the platform admin was NOT promoted to owner of the Default Organization — their ' + + 'sys_member row stays `member`, so they hold no organization ownership although nothing else ' + + 'fails, and this line is the only notice. Remedy: make the sys_member update land — check the ' + + 'write permission and driver connectivity; the owner bind is still undecided, so the next ' + + 'bootstrap trigger (kernel:ready, a sys_user insert or email/email_verified update, or a legacy ' + + 'sys_user_permission_set insert) promotes it.', + { + object: 'sys_member', + organization: defaultOrgId, + user: adminUserId, + error: (e as Error)?.message ?? String(e), + }, + ); + return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, reason: 'owner_promotion_failed' }; + } + logger?.info?.( + `[default-org] promoted the platform admin to owner of the default organization (${defaultOrgId})`, + { userId: adminUserId, defaultOrgId }, + ); + + // The same optional seed-ownership handoff a fresh bind runs (step 6 below). + let ownershipClaimed = 0; + if (options.claimSeedOwnership) { + try { + const claims = await options.claimSeedOwnership(ql, defaultOrgId, adminUserId, { logger }); + ownershipClaimed = claims.reduce((s, c) => s + c.count, 0); + } catch (e) { + logger?.warn?.('[default-org] seed ownership handoff failed', { error: (e as Error).message }); + } + } + return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, ownerPromoted: true, ownershipClaimed }; +} + +/** The Default Organization boot invariant's answer ({@link ensureDefaultOrganizationExists}). */ +export interface DefaultOrganizationInvariantResult { + /** The organization a `single` install derives every owner from; absent only when `ambiguous`. */ + organizationId?: string; + /** Whether this call created it. */ + created: boolean; + /** + * Several organizations and none with `slug='default'`: there is no single + * Default Organization to name. Nothing is created (a further organization + * would deepen the ambiguity); the `tenancy` service's organization census + * reports the topology at boot, and ADR-0131 D9 refuses every write that + * would need an owner derived from it. + */ + ambiguous?: { organizationCount: number }; +} + +/** + * [ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the + * `single` tenancy posture: find it, or create it — with or without a platform + * admin, who may not exist yet. + * + * Under `single` the organization is the environment and owns every row the + * deployment writes, so it must exist before the first write that needs an + * owner: before the application seed datasets load (`AppPlugin.start()`) and + * before the server accepts a request (`kernel:listening`). AuthPlugin calls + * this from its own `start()`, which the kernel orders ahead of every + * `AppPlugin` (its `optionalDependencies`). + * + * ⛔ Unlike {@link ensureDefaultOrganization}, nothing here is best-effort. + * A read that fails PROPAGATES — an unreadable store is not an empty one, and + * reading it as empty would mint a second organization — and a failed insert + * THROWS, naming the consequence and the remedy. The caller lets either fail + * the boot. Binding the platform admin as `owner` stays with + * {@link ensureDefaultOrganization}, which finds this organization and binds + * (or promotes) them when they appear. + */ +export async function ensureDefaultOrganizationExists( + ql: any, + options: { logger?: BootstrapLogger } = {}, +): Promise { + const read = async (where: Record, limit: number) => + rowsOf(await ql.find('sys_organization', { where, limit }, { context: SYSTEM_CTX })); + + const bySlug = await read({ slug: 'default' }, 1); + if (bySlug[0]?.id) return { organizationId: String(bySlug[0].id), created: false }; + const existing = await read({}, 2); + if (existing.length === 1 && existing[0]?.id) { + return { organizationId: String(existing[0].id), created: false }; + } + if (existing.length > 1) return { created: false, ambiguous: { organizationCount: existing.length } }; + + const newOrgId = genId('org'); + let row: any; + try { + row = await ql.insert( + 'sys_organization', + { id: newOrgId, name: 'Default Organization', slug: 'default', logo: null, metadata: null }, + { context: SYSTEM_CTX }, + ); + } catch (e) { + throw Object.assign( + new Error( + '[default-org] BOOT REFUSED: the Default Organization could not be created. Under the \'single\' ' + + 'tenancy posture it owns every row this deployment writes (ADR-0131 D3), so it must exist ' + + 'before the application seeds load and before the server accepts a request; booting without it ' + + 'would serve writes that are refused for want of an owner. Remedy: make the sys_organization ' + + 'insert land — check the datasource\'s write permission and connectivity, and whether a legacy ' + + `unique index on \`slug\` refuses 'default'. Cause: ${(e as Error)?.message ?? String(e)}`, + ), + { cause: e }, + ); + } + const organizationId = String(row?.id ?? newOrgId); + options.logger?.info( + `[default-org] created the Default Organization (${organizationId}) — the owner of every row under the 'single' tenancy posture`, + { defaultOrgId: organizationId }, + ); + return { organizationId, created: true }; +} + /** * Ensure the platform admin has a Default Organization to operate in. * Safe to call multiple times — idempotent on stable slug `default` @@ -374,8 +570,19 @@ export async function ensureDefaultOrganization( // 3. Respect existing membership — never auto-create a default org // behind an admin who already belongs somewhere. - const memberships = await tryFind(ql, 'sys_member', { user_id: adminUserId }, 1); + // + // [ADR-0131 D3 / ADR-0093 D7] With ONE exception, which is not a choice + // the admin made: the reconciler's own `member` row on the Default + // Organization. That organization now exists before the first sign-up + // (a boot invariant under `single`), so the reconciler binds the first + // admin as `member` the moment they are created — before this helper + // learns who they are. ADR-0093 D7 rules the sequence "safe in either + // order"; promoting that one row while the bind is undecided is what keeps + // it so. See {@link promoteReconciledMemberToOwner} for the conditions. + const memberships = await tryFind(ql, 'sys_member', { user_id: adminUserId }, 2); if (memberships.length > 0) { + const promotion = await promoteReconciledMemberToOwner(ql, adminUserId, memberships, options); + if (promotion) return promotion; return { defaultOrgCreated: false, memberCreated: false, diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index cc4e1685338..f5ca47d64a5 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -118,8 +118,15 @@ export class AppPlugin implements Plugin { * composed on engine-less kernels too (metadata-only one-shot commands, * mock-engine tests) where it degrades: every `objectql` touch in init * is behind a try/catch. + * + * `com.objectstack.auth` (ADR-0131 D3): under the `single` posture the + * auth plugin creates the Default Organization in its `start()`, and that + * organization must exist before this plugin's `start()` loads the inline + * seed — every seed row is stamped with it or refused. Order-if-present: + * a composition without the auth plugin registers no organization object, + * and its seeds keep today's unstamped branch. */ - optionalDependencies = ['com.objectstack.engine.objectql']; + optionalDependencies = ['com.objectstack.engine.objectql', 'com.objectstack.auth']; /** * The one init-time service this plugin CANNOT degrade without: a * non-empty bundle is registered via `getService('manifest').register()` From 38d3a0523767ff6f643feb8af34041e2f39544a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:05:19 +0000 Subject: [PATCH 03/18] wip(tests): re-pin seed and system-write tests to ADR-0131 D3/D9 Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...seed-loader-engine-schema-fallback.test.ts | 7 +- ...ader-existing-records-read-failure.test.ts | 13 +- .../seed-loader-organization-stamp.test.ts | 190 ++++++++++++++++++ ...der-sole-organization-read-failure.test.ts | 44 ++-- .../engine-organization-probe-outage.test.ts | 17 +- .../protocol-publish-package-drafts.test.ts | 6 +- .../src/seed-loader-org-fallback.test.ts | 34 +++- 7 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts diff --git a/packages/metadata-protocol/src/seed-loader-engine-schema-fallback.test.ts b/packages/metadata-protocol/src/seed-loader-engine-schema-fallback.test.ts index 3f8452f5125..321fdb51e69 100644 --- a/packages/metadata-protocol/src/seed-loader-engine-schema-fallback.test.ts +++ b/packages/metadata-protocol/src/seed-loader-engine-schema-fallback.test.ts @@ -220,7 +220,12 @@ describe('seed reference graph — engine schema fallback (marketplace objects)' expect(result.success).toBe(true); const acmeId = store.crm_account.find((r) => r.name === 'Acme Corporation')!.id; expect(store.crm_contact.find((r) => r.email === 'john.smith@acme.example.com')!.crm_account).toBe(acmeId); - expect((engine as any).getSchema).not.toHaveBeenCalled(); + // No engine probe for any SEEDED object. (The only engine question asked is + // the ADR-0131 D9 registration read for `sys_organization`, which this + // metadata double does not know: does the composition register an + // organization object at all? It answers from the same resolver.) + const probed = (engine as any).getSchema.mock.calls.map((c: unknown[]) => c[0]); + expect(probed.filter((name: string) => name !== 'sys_organization')).toEqual([]); }); it('survives an engine without getSchema (contract-minimal engines keep the old behavior)', async () => { diff --git a/packages/metadata-protocol/src/seed-loader-existing-records-read-failure.test.ts b/packages/metadata-protocol/src/seed-loader-existing-records-read-failure.test.ts index af1270817f1..d828c6821a1 100644 --- a/packages/metadata-protocol/src/seed-loader-existing-records-read-failure.test.ts +++ b/packages/metadata-protocol/src/seed-loader-existing-records-read-failure.test.ts @@ -154,12 +154,21 @@ const WIDGET = { }, }; +/** + * Answers for the one object this fixture defines, and nothing else. A mock + * that answered EVERY name also claimed an organization object, and since + * ADR-0131 D9 a composition with that object and no organization refuses the + * seed rows of an organization-owned object — a question these cases are not + * about. Composition here: the widget, no organization object (today's branch). + */ function createMetadata(): IMetadataService { + const only = async (_typeOrName: string, name?: string) => + ((name ?? _typeOrName) === WIDGET.name ? WIDGET : undefined); return { - getObject: vi.fn(async () => WIDGET), + getObject: vi.fn(async (name: string) => only(name)), listObjects: vi.fn(async () => [WIDGET]), register: vi.fn(async () => {}), - get: vi.fn(async () => WIDGET), + get: vi.fn(async (type: string, name: string) => only(type, name)), list: vi.fn(async () => []), unregister: vi.fn(async () => {}), exists: vi.fn(async () => false), diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts new file mode 100644 index 00000000000..1b283e7f0f5 --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3 / D9] Every seed row is stamped with its organization, or the + * load refuses it. + * + * Two changes, pinned with their discriminating controls: + * + * 1. The `sys_` / `cloud_` / `ai_` exemption is WITHDRAWN. Platform seeds used + * to skip the sole-organization fallback as "intentionally global"; there + * are no platform-global seeds left, and a seeded `sys_business_unit` is + * the organization's own business unit. It now carries the organization. + * 2. Zero or several organizations no longer mean "keep the historical NULL". + * In a composition that REGISTERS the organization object, a row of an + * organization-owned object with no owner to stamp is refused — counted, + * named, nothing written. The composition with NO organization object + * keeps today's branch (ADR-0131 C8 decides whether D9 reaches it); that + * case is pinned as-is. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +type Row = Record & { id?: string }; + +const BUSINESS_UNIT = { + name: 'sys_business_unit', + fields: { name: { type: 'text' }, code: { type: 'text' } }, +}; +const WIDGET = { + name: 'my_app_widget', + fields: { name: { type: 'text' }, sku: { type: 'text' } }, +}; +/** ADR-0066: the declared way to hold rows that belong to no organization. */ +const LICENSE = { + name: 'billing_license', + tenancy: { enabled: false }, + fields: { name: { type: 'text' }, sku: { type: 'text' } }, +}; +const ORGANIZATION = { name: 'sys_organization', fields: { name: { type: 'text' }, slug: { type: 'text' } } }; + +function createEngine(store: Record) { + let idCounter = 0; + const inserted: Array<{ object: string; row: Row }> = []; + const engine = { + find: vi.fn(async (object: string, query?: { where?: Record; limit?: number }) => { + let rows = store[object] ?? []; + if (query?.where) { + rows = rows.filter((r) => Object.entries(query.where!).every(([k, v]) => r[k] === v)); + } + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }), + findOne: vi.fn(async () => null), + insert: vi.fn(async (object: string, data: Row | Row[]) => { + const write = (d: Row) => { + const row = { ...d, id: d.id ?? `gen-${++idCounter}` }; + (store[object] ??= []).push(row); + inserted.push({ object, row }); + return row; + }; + return Array.isArray(data) ? data.map(write) : write(data); + }), + update: vi.fn(async (_object: string, data: Row) => data), + delete: vi.fn(async () => ({ deleted: 0 })), + count: vi.fn(async (object: string) => (store[object] ?? []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + return { engine, inserted }; +} + +/** Object definitions by name; `sys_organization` is present only when `registered`. */ +function createMetadata(registered: boolean): IMetadataService { + const defs: Record = { + sys_business_unit: BUSINESS_UNIT, + my_app_widget: WIDGET, + billing_license: LICENSE, + ...(registered ? { sys_organization: ORGANIZATION } : {}), + }; + return { + getObject: vi.fn(async (name: string) => defs[name]), + listObjects: vi.fn(async () => Object.values(defs)), + register: vi.fn(async () => {}), + get: vi.fn(async (_type: string, name: string) => defs[name]), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +const silentLogger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as never; + +function seeds(organizationId?: string) { + return { + seeds: [ + { object: 'sys_business_unit', externalId: 'code', mode: 'insert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Head Office', code: 'HQ' }] }, + { object: 'my_app_widget', externalId: 'sku', mode: 'insert', env: ['prod', 'dev', 'test'], + records: [ + { name: 'Gizmo', sku: 'G-1' }, + // Carries its own organization: that IS an explicit owner. + { name: 'Pinned', sku: 'P-1', organization_id: 'org_named_by_author' }, + ] }, + { object: 'billing_license', externalId: 'sku', mode: 'insert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Site licence', sku: 'L-1' }] }, + ], + config: { + dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1000, + transaction: false, ...(organizationId ? { organizationId } : {}), + }, + } as never; +} + +const orgOf = (inserted: Array<{ object: string; row: Row }>, object: string) => + inserted.filter((i) => i.object === object).map((i) => i.row.organization_id ?? null); + +describe('[ADR-0131 D3] the platform-namespace seed exemption is withdrawn', () => { + it('a `sys_business_unit` seed carries the install\'s sole organization, like a business seed', async () => { + const { engine, inserted } = createEngine({ sys_organization: [{ id: 'org_default' }] }); + const result = await new SeedLoaderService(engine, createMetadata(true), silentLogger()).load(seeds()); + expect(result.errors).toEqual([]); + expect(orgOf(inserted, 'sys_business_unit')).toEqual(['org_default']); + // The business object takes it too; the author's explicit organization wins. + expect(orgOf(inserted, 'my_app_widget')).toEqual(['org_default', 'org_named_by_author']); + }); + + it('a pinned `config.organizationId` stamps the platform seed as well', async () => { + const { engine, inserted } = createEngine({ sys_organization: [{ id: 'org_a' }, { id: 'org_b' }] }); + const result = await new SeedLoaderService(engine, createMetadata(true), silentLogger()).load(seeds('org_b')); + expect(result.errors).toEqual([]); + expect(orgOf(inserted, 'sys_business_unit')).toEqual(['org_b']); + }); +}); + +describe('[ADR-0131 D9] no derivable owner — the rows of an organization-owned object are REFUSED', () => { + it.each([ + ['NO organization', [] as Row[], 'no organization at all'], + ['several organizations', [{ id: 'org_a' }, { id: 'org_b' }], 'several organizations'], + ])('%s: refused by name, nothing written, and the controls still land', async (_label, organizations, phrase) => { + const { engine, inserted } = createEngine({ sys_organization: organizations }); + const result = await new SeedLoaderService(engine, createMetadata(true), silentLogger()).load(seeds()); + + // The two unowned rows (the platform seed and the unpinned business row) + // are refused, counted, and named — never written NULL. + const refused = result.errors.filter((e) => e.field === 'organization_id'); + expect(refused.map((e) => e.sourceObject).sort()).toEqual(['my_app_widget', 'sys_business_unit']); + for (const e of refused) { + expect(e.message).toContain('was REFUSED'); + expect(e.message).toContain(phrase); + expect(e.message).toContain('config.organizationId'); + } + expect(result.success).toBe(false); + expect(orgOf(inserted, 'sys_business_unit')).toEqual([]); + + // CONTROL 1 — a row that names its own organization is not refused. + expect(orgOf(inserted, 'my_app_widget')).toEqual(['org_named_by_author']); + // CONTROL 2 — an object that declares no tenancy has no owner to need. + expect(orgOf(inserted, 'billing_license')).toEqual([null]); + }); +}); + +describe('[ADR-0131 Q2, held as-is] a composition with NO organization object keeps today\'s branch', () => { + it('nothing is stamped and nothing is refused', async () => { + // The engine answers an empty organization read, but the composition + // registers no organization object: no organization can exist here. + const { engine, inserted } = createEngine({}); + const result = await new SeedLoaderService(engine, createMetadata(false), silentLogger()).load(seeds()); + expect(result.errors).toEqual([]); + expect(orgOf(inserted, 'sys_business_unit')).toEqual([null]); + expect(orgOf(inserted, 'my_app_widget')).toEqual([null, 'org_named_by_author']); + }); + + it('the engine refusing the unregistered organization name reads the same way', async () => { + const { engine, inserted } = createEngine({}); + const find = engine.find as unknown as ReturnType; + const base = find.getMockImplementation()!; + find.mockImplementation(async (object: string, q: unknown) => { + if (object === 'sys_organization') { + throw Object.assign(new Error("object 'sys_organization' not found"), { + code: 'OBJECT_NOT_FOUND', object: 'sys_organization', + }); + } + return base(object, q); + }); + const result = await new SeedLoaderService(engine, createMetadata(false), silentLogger()).load(seeds()); + expect(result.errors).toEqual([]); + expect(orgOf(inserted, 'sys_business_unit')).toEqual([null]); + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader-sole-organization-read-failure.test.ts b/packages/metadata-protocol/src/seed-loader-sole-organization-read-failure.test.ts index 348b7b22528..0eacd844747 100644 --- a/packages/metadata-protocol/src/seed-loader-sole-organization-read-failure.test.ts +++ b/packages/metadata-protocol/src/seed-loader-sole-organization-read-failure.test.ts @@ -229,7 +229,13 @@ describe('[#12852] seed loader — a sole-organization read that FAILED is not " expect(widgets(store)[0].organization_id).toBe('org_solo'); }); - it('control: a probe that RUNS and finds NO org leaves the row org-less — the declared ambiguity', async () => { + // [ADR-0131 D9] `createMetadata` answers every object name, so this + // composition REGISTERS the organization object. With no owner derivable, + // the row of an organization-owned object is REFUSED — it used to be + // written org-less as "the declared ambiguity". A composition with no + // organization object keeps that branch: `seed-loader-organization-stamp.test.ts`. + + it('control: a probe that RUNS and finds NO org REFUSES the row — no owner to derive', async () => { const { engine, store, findCalls } = createEngine(); const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ @@ -238,20 +244,24 @@ describe('[#12852] seed loader — a sole-organization read that FAILED is not " }); expect(findCalls).toContain('sys_organization'); - expect(result.summary.totalInserted).toBe(1); - expect(widgets(store)[0].organization_id).toBeUndefined(); + expect(result.summary.totalInserted).toBe(0); + expect(result.summary.totalErrored).toBe(1); + expect(result.errors[0].message).toContain('no organization at all'); + expect(widgets(store)).toHaveLength(0); }); - it('control: a probe that RUNS and finds SEVERAL orgs leaves the row org-less — the declared ambiguity', async () => { + it('control: a probe that RUNS and finds SEVERAL orgs REFUSES the row — no owner to derive', async () => { const { engine, store } = createEngine(); store.sys_organization = [{ id: 'org_a' }, { id: 'org_b' }]; - await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ + const result = await new SeedLoaderService(engine, createMetadata(), createLogger()).load({ seeds: seedOf([{ name: 'Fresh', sku: 'W-A' }]), config: CONFIG, }); - expect(widgets(store)[0].organization_id).toBeUndefined(); + expect(result.summary.totalErrored).toBe(1); + expect(result.errors[0].message).toContain('several organizations'); + expect(widgets(store)).toHaveLength(0); }); // ── THE FIX — a probe that could not run must surface, not invent "none". @@ -297,10 +307,11 @@ describe('[#12852] seed loader — a sole-organization read that FAILED is not " }); // ── THE ONE BENIGN CASE — an unprovisioned table can hold no org, so - // "no sole organization" IS the truth and the historical - // global/cross-tenant NULL is the right answer. + // "no sole organization" IS the truth: the load PROCEEDS (it is not an + // outage) and reads it as the zero case. [ADR-0131 D9] The zero case + // no longer writes the historical NULL: the row is refused by name. - it('an UNPROVISIONED sys_organization is truthful emptiness: the seed writes its rows org-less', async () => { + it('an UNPROVISIONED sys_organization is truthful emptiness: the load proceeds and refuses the unowned row', async () => { const { engine, store, findCalls, failReadsOf } = createEngine(); failReadsOf('sys_organization', tableNotProvisioned()); @@ -309,14 +320,13 @@ describe('[#12852] seed loader — a sole-organization read that FAILED is not " config: CONFIG, }); - expect(result.summary.totalInserted).toBe(1); - expect(result.summary.totalErrored).toBe(0); + expect(result.summary.totalInserted).toBe(0); + expect(result.summary.totalErrored).toBe(1); + expect(result.errors[0].message).toContain('no organization at all'); // Proof the benign branch was actually EXERCISED — the probe ran and - // threw. Without this, the passing insert above would be consistent - // with a harness that never probes at all. + // threw, and the load still RESOLVED (an outage rejects; see above). expect(findCalls).toContain('sys_organization'); - expect(widgets(store)).toHaveLength(1); - expect(widgets(store)[0].organization_id).toBeUndefined(); + expect(widgets(store)).toHaveLength(0); }); it('an UNPROVISIONED sys_organization in the postgres phrasing (42P01) is benign too', async () => { @@ -331,9 +341,9 @@ describe('[#12852] seed loader — a sole-organization read that FAILED is not " config: CONFIG, }); - expect(result.summary.totalInserted).toBe(1); + expect(result.summary.totalErrored).toBe(1); expect(findCalls).toContain('sys_organization'); - expect(widgets(store)[0].organization_id).toBeUndefined(); + expect(widgets(store)).toHaveLength(0); }); it('a missing COLUMN on an existing sys_organization stays loud (the superstring case)', async () => { diff --git a/packages/objectql/src/engine-organization-probe-outage.test.ts b/packages/objectql/src/engine-organization-probe-outage.test.ts index fb85cbf3f49..6e93e44074c 100644 --- a/packages/objectql/src/engine-organization-probe-outage.test.ts +++ b/packages/objectql/src/engine-organization-probe-outage.test.ts @@ -210,13 +210,20 @@ describe('#9261 a non-benign probe failure no longer becomes "no organizations"' }); describe('#9261 the benign causes still answer the empty probe', () => { - it('[cause 2] an unprovisioned `sys_organization` table proceeds unstamped', async () => { + it('[cause 2] an unprovisioned `sys_organization` table answers ZERO — not an outage', async () => { // Schema sync has not run; the table cannot hold a row, so zero IS the - // measurement and first boot must not be refused. + // measurement — the probe's own failure does NOT propagate (that is the + // #9261 distinction this case pins). [ADR-0131 D9] What zero then means + // changed: the Default Organization is a boot invariant under `single` + // (it exists before anything writes), so a registered organization object + // holding none is a write with no owner — refused by name, not landed + // org-less as it was when "zero" meant "first boot". const { engine, observed } = await makeEngine({ organizationFind: () => { throw missingTable(); } }); - await systemInsert(engine, 'first boot'); - expect(lastWrite(observed, 'dispatch_order')?.method).toBe('create'); - expect(lastWrite(observed, 'dispatch_order')?.options?.tenantId).toBeUndefined(); + const refusal = await systemInsert(engine, 'first boot').then(() => undefined, (e) => e); + expect(refusal?.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal?.status).toBe(500); + expect(refusal?.reason).toBe('no-organization'); + expect(lastWrite(observed, 'dispatch_order')).toBeUndefined(); }); it('an UNROUTABLE organization object never reaches the probe, so it needs no predicate', async () => { diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index 40c3b3187a6..cf6b4c3bbc6 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -523,7 +523,10 @@ describe('protocol.applySeedBodies — real loader smoke test', () => { const protocol = new ObjectStackProtocolImplementation({} as never); const inserted: Array<{ object: string; record: any }> = []; (protocol as any).engine = { - find: async () => [], + // [ADR-0131 D3] A `single` install holds its Default Organization from + // boot, and the un-pinned publish stamps every seed row with it. (With + // the organization object registered and none of it, D9 refuses the row.) + find: async (object: string) => (object === 'sys_organization' ? [{ id: 'org_default' }] : []), insert: async (object: string, data: any) => { // Mirror the real engine's array-form insert (bulk path): an array in // → an array of created records out, same order — see framework#2678. @@ -556,6 +559,7 @@ describe('protocol.applySeedBodies — real loader smoke test', () => { ); expect(inserted.map((i) => i.record.name)).toEqual(['Apollo', 'Gemini']); + expect(inserted.map((i) => i.record.organization_id)).toEqual(['org_default', 'org_default']); expect(res.success).toBe(true); expect(res.inserted).toBe(2); }); diff --git a/packages/objectql/src/seed-loader-org-fallback.test.ts b/packages/objectql/src/seed-loader-org-fallback.test.ts index ec771156329..683ddd1ea64 100644 --- a/packages/objectql/src/seed-loader-org-fallback.test.ts +++ b/packages/objectql/src/seed-loader-org-fallback.test.ts @@ -3,9 +3,14 @@ // The SeedLoader stamps business seed rows with the tenant's organization key so // they don't vanish under strict org-scoping. When the caller pins no org (an // in-process publish has no active user session — the AI build agent's publish -// path), the loader adopts the tenant's SOLE organization as a fallback. A -// `sys_`/platform seed never takes the fallback (those stay global). Zero or -// many orgs → leave rows org-less (genuinely ambiguous → historical behavior). +// path), the loader adopts the tenant's SOLE organization as a fallback. +// +// [ADR-0131 D3 / D9] A `sys_`/platform seed takes the fallback too — the +// "intentionally global" exemption is withdrawn. Zero or many orgs no longer +// leave rows org-less: this double's metadata answers every name, so the +// composition REGISTERS the organization object, and a row with no derivable +// owner is REFUSED. The composition with no organization object keeps the +// org-less branch (`metadata-protocol/src/seed-loader-organization-stamp.test.ts`). import { describe, it, expect } from 'vitest'; import { SeedLoaderService } from '@objectstack/metadata-protocol'; @@ -53,31 +58,38 @@ describe('SeedLoader org-key fallback (un-pinned publish)', () => { expect(inserted[0]?.record.organization_id).toBe('org_only'); }); - it('does NOT take the fallback for sys_/platform seeds (they stay global)', async () => { + it('[ADR-0131 D3] takes the fallback for sys_/platform seeds too — the exemption is withdrawn', async () => { const { svc, inserted } = harness([{ id: 'org_only' }]); await svc.load({ seeds: [{ object: 'sys_widget_pref', records: [{ name: 'X' }] }] as never, config: cfg(), }); - expect(inserted[0]?.record.organization_id).toBeUndefined(); + expect(inserted).toHaveLength(1); + expect(inserted[0].record.organization_id).toBe('org_only'); }); - it('leaves rows org-less when the tenant org is ambiguous (≠ exactly one)', async () => { + it('[ADR-0131 D9] REFUSES the row when the tenant org is ambiguous (≠ exactly one)', async () => { const { svc, inserted } = harness([{ id: 'a' }, { id: 'b' }]); - await svc.load({ + const result = await svc.load({ seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never, config: cfg(), }); - expect(inserted[0]?.record.organization_id).toBeUndefined(); + expect(inserted).toEqual([]); + expect(result.errors.map((e: { message: string }) => e.message)).toEqual([ + expect.stringContaining('several organizations'), + ]); }); - it('leaves rows org-less when there is no organization at all', async () => { + it('[ADR-0131 D9] REFUSES the row when there is no organization at all', async () => { const { svc, inserted } = harness([]); - await svc.load({ + const result = await svc.load({ seeds: [{ object: 'project', records: [{ name: 'Apollo' }] }] as never, config: cfg(), }); - expect(inserted[0]?.record.organization_id).toBeUndefined(); + expect(inserted).toEqual([]); + expect(result.errors.map((e: { message: string }) => e.message)).toEqual([ + expect.stringContaining('no organization at all'), + ]); }); it('an explicitly pinned org still wins over the fallback path', async () => { From 28496644809332681f773f9f741c50ac812629b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:38:34 +0000 Subject: [PATCH 04/18] wip(runtime,plugin-auth,metadata-protocol): changesets; re-pin first-boot tests to ADR-0131 D3/D9 Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tadata-protocol-seed-organization-stamp.md | 17 +++++ .../15195-objectql-no-organization-refused.md | 17 +++++ ...uth-default-organization-boot-invariant.md | 19 +++++ .../15195-runtime-app-plugin-after-auth.md | 7 ++ .../seed-loader-organization-stamp.test.ts | 10 ++- .../src/ensure-default-organization.test.ts | 2 +- .../packages-seed-apply-disclosure.test.ts | 5 +- ...ckages-seed-apply-read-decorations.test.ts | 3 + packages/runtime/src/http-dispatcher.test.ts | 6 +- ...nancy-autonumber-split.integration.test.ts | 73 ++++++++++++++----- ...nancy-autonumber-split.integration.test.ts | 35 +++++---- 11 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 .changeset/15195-metadata-protocol-seed-organization-stamp.md create mode 100644 .changeset/15195-objectql-no-organization-refused.md create mode 100644 .changeset/15195-plugin-auth-default-organization-boot-invariant.md create mode 100644 .changeset/15195-runtime-app-plugin-after-auth.md diff --git a/.changeset/15195-metadata-protocol-seed-organization-stamp.md b/.changeset/15195-metadata-protocol-seed-organization-stamp.md new file mode 100644 index 00000000000..d4471c3b069 --- /dev/null +++ b/.changeset/15195-metadata-protocol-seed-organization-stamp.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +Every seed row is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9). + +- **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. +- **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id`, and a row of an object declaring `tenancy: { enabled: false }`, still load. +- **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. + +**The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the plant a seed populates on a walled deployment), or set `organization_id` on the record. diff --git a/.changeset/15195-objectql-no-organization-refused.md b/.changeset/15195-objectql-no-organization-refused.md new file mode 100644 index 00000000000..e8e82dc9fac --- /dev/null +++ b/.changeset/15195-objectql-no-organization-refused.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': minor +--- + +A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9). + +- **Before.** Under the `single` posture, in a composition that registers the organization object, a system-context insert on a tenant-scoped object with no organization anywhere landed with `organization_id` NULL when the install held no organization yet. That was the normal state of a first boot: the organization arrived with the first sign-up. +- **Now.** The Default Organization is a boot invariant under `single` (ADR-0131 D3): `@objectstack/plugin-auth` creates it before the application seeds load and before the server accepts a request. An install that registers the organization object and holds none of it therefore has no owner to derive, and the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, `reason: 'no-organization'`). The message names the missing organization. Nothing is written. +- **Unchanged.** Exactly one organization is derived and stamped. Several organizations, or a walled posture, are refused as before. A write that carries an organization, on the execution context or on the record, is never refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. The platform-namespace objects the tenancy inventory has not admitted keep their per-object exclusion; ADR-0131 C8 retires it. A composition that registers no organization object at all (a lean embedding) still lands the write unstamped. + +**The remedy.** On a `single` deployment, the refusal means the Default Organization is missing: restart, and the auth plugin recreates it at boot, or carry the organization on the write (`{ context: { isSystem: true, tenantId } }`, or `organization_id` on the record). A TypeScript caller of `resolveSystemWriteOrganization` passes whether its composition registers the organization object. diff --git a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md new file mode 100644 index 00000000000..a8e36b8235d --- /dev/null +++ b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-auth': minor +--- + +Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner + +Clause-②: no (narrowing) + + + +**BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3). + +- **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner. +- **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues. +- **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. +- **The first admin is the owner.** Because the organization now exists before the first sign-up, the membership reconciler binds every new user, the first admin included, as `member` the moment they are created. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place. A decided bind, an existing owner, or a membership elsewhere is never touched. +- **Membership from the first boot.** The one-time membership backfill now has its target at the first `kernel:ready`, so users present then (a seeded directory, say) are bound as members on the first boot rather than when the first admin appears. + +**The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. diff --git a/.changeset/15195-runtime-app-plugin-after-auth.md b/.changeset/15195-runtime-app-plugin-after-auth.md new file mode 100644 index 00000000000..a68d179a8b9 --- /dev/null +++ b/.changeset/15195-runtime-app-plugin-after-auth.md @@ -0,0 +1,7 @@ +--- +'@objectstack/runtime': patch +--- + +`AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads + +`AppPlugin` now declares the auth plugin (`com.objectstack.auth`) among its order-if-present dependencies. Under the `single` posture the auth plugin creates the Default Organization in its `start()` (ADR-0131 D3), and every seed row is stamped with it; the declaration makes the kernel start the auth plugin first instead of relying on the order plugins were registered in. A composition without the auth plugin is unaffected. diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts index 1b283e7f0f5..ed8ed2a0505 100644 --- a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -19,7 +19,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { SeedLoaderService } from './seed-loader'; +import { SeedLoaderService } from './seed-loader.js'; import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; type Row = Record & { id?: string }; @@ -143,7 +143,8 @@ describe('[ADR-0131 D9] no derivable owner — the rows of an organization-owned // The two unowned rows (the platform seed and the unpinned business row) // are refused, counted, and named — never written NULL. - const refused = result.errors.filter((e) => e.field === 'organization_id'); + type RefusalRow = { field: string; sourceObject: string; message: string }; + const refused = (result.errors as RefusalRow[]).filter((e) => e.field === 'organization_id'); expect(refused.map((e) => e.sourceObject).sort()).toEqual(['my_app_widget', 'sys_business_unit']); for (const e of refused) { expect(e.message).toContain('was REFUSED'); @@ -173,9 +174,10 @@ describe('[ADR-0131 Q2, held as-is] a composition with NO organization object ke it('the engine refusing the unregistered organization name reads the same way', async () => { const { engine, inserted } = createEngine({}); - const find = engine.find as unknown as ReturnType; + type Find = (object: string, q?: unknown) => Promise; + const find = engine.find as unknown as { getMockImplementation(): Find | undefined; mockImplementation(f: Find): void }; const base = find.getMockImplementation()!; - find.mockImplementation(async (object: string, q: unknown) => { + find.mockImplementation(async (object: string, q?: unknown) => { if (object === 'sys_organization') { throw Object.assign(new Error("object 'sys_organization' not found"), { code: 'OBJECT_NOT_FOUND', object: 'sys_organization', diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 221822ece6d..00813a9fcb7 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -568,7 +568,7 @@ describe('[ADR-0131 D3 / ADR-0093 D7] the first admin is the owner even when the expect(res).toMatchObject({ ownerPromoted: true, memberCreated: false, defaultOrgId: 'org_d' }); expect(ql.tables.sys_member).toEqual([{ ...reconciled('org_d'), role: 'owner' }]); const decision = ql.tables.sys_migration.find((r: Row) => r.id === 'adr-0093-default-org-owner-bind'); - expect(JSON.parse(decision.details)).toMatchObject({ outcome: 'promoted', organizationId: 'org_d' }); + expect(JSON.parse(String(decision?.details))).toMatchObject({ outcome: 'promoted', organizationId: 'org_d' }); }); it('never promotes once the owner bind is DECIDED (ADR-0093 D7: a removed owner stays removed)', async () => { diff --git a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts index 2d263da554c..de58d33d5d4 100644 --- a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts @@ -175,7 +175,10 @@ function makeDoor(opts: { const insert = vi.fn().mockImplementation(async (_object: string, rec: any) => ( Array.isArray(rec) ? rec.map((r: any) => ({ id: `id_${r.name}` })) : { id: `id_${rec.name}` } )); - const find = vi.fn().mockResolvedValue([]); + // [ADR-0131 D3] The install holds its Default Organization from boot; the + // un-pinned publish stamps every seed row with it (D9 refuses an unowned row). + const find = vi.fn().mockImplementation(async (object: string) => + (object === 'sys_organization' ? [{ id: 'org_default' }] : [])); const getObject = opts.failGetObject ? vi.fn().mockImplementation(async () => { throw new Error(DRIVER_TEXT); }) : vi.fn().mockResolvedValue({ diff --git a/packages/runtime/src/domains/packages-seed-apply-read-decorations.test.ts b/packages/runtime/src/domains/packages-seed-apply-read-decorations.test.ts index f58c558707e..32922b8d606 100644 --- a/packages/runtime/src/domains/packages-seed-apply-read-decorations.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-read-decorations.test.ts @@ -160,6 +160,9 @@ function makeEngine() { if (!t) { t = []; tables.set(name, t); } return t; }; + // [ADR-0131 D3] The install holds its Default Organization from boot; the + // un-pinned publish stamps every seed row with it (D9 refuses an unowned row). + tableOf('sys_organization').push({ id: 'org_default' }); const engine: any = { registry: { listItems: () => [], diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 4f48e3d3ae9..de38bc6e45c 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2115,7 +2115,11 @@ describe('HttpDispatcher', () => { const insert = vi.fn().mockImplementation(async (_obj: string, rec: any) => ( Array.isArray(rec) ? rec.map((r) => ({ id: `id_${r.name}` })) : { id: `id_${rec.name}` } )); - const find = vi.fn().mockResolvedValue([]); // no existing rows → all insert + // No existing rows → all insert. [ADR-0131 D3] The install holds its + // Default Organization from boot, and the un-pinned publish stamps + // every seed row with it (with none of it, D9 refuses the rows). + const find = vi.fn().mockImplementation(async (obj: string) => + (obj === 'sys_organization' ? [{ id: 'org_default' }] : [])); (kernel as any).getService = vi.fn().mockImplementation((name: string) => { if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItem }); if (name === 'objectql') return Promise.resolve({ insert, find, update: vi.fn(), registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); diff --git a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts index 95ff7cb1d17..62a4d033bdd 100644 --- a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts @@ -17,6 +17,12 @@ * - the unique index correctly enforces `(COALESCE(organization_id, * '__global__'), )`, which is what the declaration asks for. * + * [ADR-0131 D3/D9] The first bullet no longer holds on a fresh install: the + * Default Organization exists before the seeds load under `single`, and a seed + * with no derivable owner is refused rather than written NULL. The cases below + * therefore reproduce the split from the residue an install seeded BEFORE that + * change carries (`landPreC1SeedResidue`), which is what the repair still owes. + * * Three correct components, one broken outcome. A test built on doubles would * have to encode the very disagreement it is meant to detect, so this file uses * the real `SeedLoaderService`, a real `ObjectQL` engine and a real `SqlDriver` @@ -106,7 +112,14 @@ async function bootInstall() { return { driver, engine }; } -/** Seed the app's declared data on a FRESH database — zero organizations exist. */ +/** + * Seed the app's declared data on a FRESH database — zero organizations exist. + * + * [ADR-0131 D9] Since the Default Organization became a boot invariant under + * `single` (D3), this load is REFUSED row by row: the install registers the + * organization object and holds none of it, so no owner is derivable. Pinned in + * `[ADR-0131 D9] a fresh install's seed is refused` below. + */ async function seedFreshInstall(engine: ObjectQL) { const loader = new SeedLoaderService( engine as any, @@ -125,6 +138,20 @@ async function seedFreshInstall(engine: ObjectQL) { } as any); } +/** + * The RESIDUE an install seeded before ADR-0131 carries: the same rows the + * pre-C1 loader wrote when no organization existed yet, through the very driver + * call the engine made for them — no `tenantId`, so the driver mints each + * number on the `__global__` counter. The loader and the engine now refuse that + * write; an install that made it before keeps the rows, and repairing them is + * what the backfill and the handoff below exist for (ADR-0131 C7's residue). + */ +async function landPreC1SeedResidue(driver: SqlDriver, count = SEEDED_ROWS) { + for (let i = 0; i < count; i++) { + await (driver as any).create('crm_case', { subject: `seeded ${i + 1}` }); + } +} + /** The sign-up that first brings an organization into existence. */ const createOrganization = (engine: ObjectQL) => engine.insert('sys_organization', { id: ORG_ID, name: 'Acme' }, { context: { isSystem: true } } as any); @@ -176,11 +203,23 @@ afterEach(async () => { }); describe('#8686 seed/API tenancy split — autonumber scope', () => { - it('[the defect] a fresh install splits one object across two counters and two partitions', async () => { + it('[ADR-0131 D9] a fresh install\'s seed is REFUSED at zero organizations — nothing can split', async () => { + const { driver, engine } = await bootInstall(); + const result = await seedFreshInstall(engine); + + // Every row refused, by name; nothing written, no counter minted. + expect(result.summary.totalInserted).toBe(0); + expect(result.summary.totalErrored).toBe(SEEDED_ROWS); + expect(result.errors[0].message).toContain('no organization at all'); + expect(await countUntenanted(driver)).toBe(0); + expect(await readSequences(driver)).toEqual([]); + }); + + it('[the defect, pre-ADR-0131] a fresh install split one object across two counters and two partitions', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); - // The seed landed untenanted, because at seed time no organization exists. + // The seed landed untenanted, because at seed time no organization existed. expect(await countUntenanted(driver)).toBe(SEEDED_ROWS); expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: SEEDED_ROWS }]); @@ -201,7 +240,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { it('[ruling: option 1] the handoff adopts the seed rows, and the fresh install never duplicates', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); await createOrganization(engine); // The fix: the moment an organization exists, the untenanted seed rows are @@ -233,7 +272,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { it('[ruling: shape 2] an install that ALREADY minted duplicates is repaired, and they are reported not renumbered', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); await createOrganization(engine); // The damage the card measured on 17.0.0 GA, reproduced before repairing it. for (let i = 0; i < 4; i++) await apiCreate(engine, `api ${i + 1}`); @@ -270,8 +309,8 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { }); it('[ruling: idempotent] a second run of the backfill is a no-op', async () => { - const { engine } = await bootInstall(); - await seedFreshInstall(engine); + const { driver, engine } = await bootInstall(); + await landPreC1SeedResidue(driver); await createOrganization(engine); await backfillSeedTenancy(resolveSeedTenancySeam(engine), createLogger() as any); @@ -286,7 +325,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { it('[ruling: multi-tenant] a walled install is SKIPPED loudly and its data is left untouched', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); await createOrganization(engine); // The posture the boot banner prints — read from the one protocol-level @@ -312,7 +351,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { it('[ruling: no guessing] a single-tenant install with several organizations is skipped too', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); await createOrganization(engine); await engine.insert( 'sys_organization', @@ -337,7 +376,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { // `sys_organization` gaining its first row is what triggers the adoption, with // no restart and no explicit call — the fresh-install half of the fix. const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); const { AppPlugin } = await import('./app-plugin.js'); const ctx = { logger: createLogger() } as any; @@ -376,7 +415,7 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { */ it('[#12394] a burned number survives the handoff — the merged counter is written, not destroyed', async () => { const { driver, engine } = await bootInstall(); - await seedFreshInstall(engine); + await landPreC1SeedResidue(driver); // Burn the top number. The counter stays at 38; the data max is now 37. await (driver as any).knex('crm_case').where({ case_number: 'CASE-00038' }).delete(); @@ -440,13 +479,11 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { engine.registry.registerObject(ORG_OBJECT, '#12394'); await driver.initObjects([ticketObject, ORG_OBJECT]); - // Seed-shaped writes: untenanted, because no organization exists yet. + // Seed-shaped writes: untenanted, because no organization existed yet — + // the pre-ADR-0131 residue, landed through the driver call the engine made + // for it then (the engine now refuses it at zero organizations, D9). const seed = (region: string, n: number) => - engine.insert( - 'crm_ticket', - { subject: `seeded ${region} ${n}`, region }, - { context: { isSystem: true } } as any, - ); + (driver as any).create('crm_ticket', { subject: `seeded ${region} ${n}`, region }); for (let i = 1; i <= 3; i++) await seed('EU', i); for (let i = 1; i <= 2; i++) await seed('US', i); diff --git a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts index 03a4ba432a7..e5fd7ed7133 100644 --- a/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts @@ -211,26 +211,29 @@ describe('#8844 system-context writes — the runtime autonumber fork', () => { expect(await readSequences(driver)).toEqual([{ tenant: SECOND_ORG_ID, lastValue: 1 }]); }); - it('[first boot] before any organization exists nothing is refused, and #8686 still adopts the rows', async () => { - // Seeds and boot-time system writes land before the admin signs up, so there - // is no organization to derive — and no second partition to fork away from - // either. Refusing here would refuse first boot itself. This is the case - // that keeps #8844's refusal from swallowing #8686's handoff seam. + it('[ADR-0131 D9] with NO organization the system write is REFUSED — no row, no `__global__` counter', async () => { + // Before ADR-0131 this was "first boot": seeds and boot-time system writes + // landed before the admin signed up and created the organization, so a + // refusal here would have refused first boot itself. The Default + // Organization is now a boot invariant under `single` (D3) — it exists + // before anything writes — so an install that registers the organization + // object and holds none of it has no owner to derive, and D9 refuses. const { driver, engine } = await bootInstall(); - await systemWrite(engine, 'boot-time'); - expect(await countUntenanted(driver)).toBe(1); - expect(await readSequences(driver)).toEqual([{ tenant: GLOBAL_TENANT, lastValue: 1 }]); + const refusal = await systemWrite(engine, 'no owner').catch((e) => e); + expect(refusal.code).toBe('ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'); + expect(refusal.status).toBe(500); + expect(refusal.reason).toBe('no-organization'); + // Refused before the driver: no row, and no `__global__` counter minted. + expect(await countUntenanted(driver)).toBe(0); + expect(await readSequences(driver)).toEqual([]); - // The sign-up arrives; from here on the runtime producer is stamped, which - // is what stops the split REOPENING after #8686's backfill has closed it. + // DISCRIMINATING CONTROL: the organization exists, and the identical write + // is derived and stamped — one counter, under the organization. await createOrganization(engine); - await systemWrite(engine, 'after sign-up'); - expect(await countUntenanted(driver)).toBe(1); // still just the boot-time row - expect(await readSequences(driver)).toEqual([ - { tenant: GLOBAL_TENANT, lastValue: 1 }, - { tenant: ORG_ID, lastValue: 1 }, - ]); + expect((await systemWrite(engine, 'owned')).document_no).toBe('WI-00001'); + expect(await countUntenanted(driver)).toBe(0); + expect(await readSequences(driver)).toEqual([{ tenant: ORG_ID, lastValue: 1 }]); }); it('[#8686 regression] the repaired install no longer re-splits on the next system write', async () => { From c49f46bab1005dd70b1afde42b4d980b1b002aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:39:42 +0000 Subject: [PATCH 05/18] wip(plugin-auth,metadata-protocol,runtime): keep the boot invariant internal, stamp only seed rows that carry an organization, real-kernel pin Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../seed-loader-organization-stamp.test.ts | 5 + packages/metadata-protocol/src/seed-loader.ts | 35 ++- .../plugins/plugin-auth/src/auth-plugin.ts | 6 +- .../src/default-org-bootstrap-once.ts | 12 +- .../src/default-organization-invariant.ts | 112 ++++++++++ .../src/ensure-default-organization.test.ts | 2 +- .../src/ensure-default-organization.ts | 94 +------- ...lt-organization-boot-invariant.pin.test.ts | 206 ++++++++++++++++++ 8 files changed, 360 insertions(+), 112 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/default-organization-invariant.ts create mode 100644 packages/runtime/src/default-organization-boot-invariant.pin.test.ts diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts index ed8ed2a0505..826d3b3d535 100644 --- a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -123,6 +123,9 @@ describe('[ADR-0131 D3] the platform-namespace seed exemption is withdrawn', () expect(orgOf(inserted, 'sys_business_unit')).toEqual(['org_default']); // The business object takes it too; the author's explicit organization wins. expect(orgOf(inserted, 'my_app_widget')).toEqual(['org_default', 'org_named_by_author']); + // CONTROL — an object with no organization column gets no stamp: the real + // engine refuses an undeclared `organization_id` and the row would be lost. + expect(orgOf(inserted, 'billing_license')).toEqual([null]); }); it('a pinned `config.organizationId` stamps the platform seed as well', async () => { @@ -130,6 +133,8 @@ describe('[ADR-0131 D3] the platform-namespace seed exemption is withdrawn', () const result = await new SeedLoaderService(engine, createMetadata(true), silentLogger()).load(seeds('org_b')); expect(result.errors).toEqual([]); expect(orgOf(inserted, 'sys_business_unit')).toEqual(['org_b']); + // The pinned organization is no stamp on an object with no organization column either. + expect(orgOf(inserted, 'billing_license')).toEqual([null]); }); }); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 2b6a2f8e208..c0e971714f2 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1163,15 +1163,26 @@ export class SeedLoaderService implements ISeedLoaderService { } }; + // [ADR-0131 D3 / D9] Does a row of this object carry an organization at + // all? Read once per dataset, from the object definition the loader + // resolves every dataset through. Since the namespace exemption is gone, + // this is the ONLY thing that keeps the stamp off an object with no + // `organization_id` column — `tenancy: { enabled: false }` (ADR-0066), or a + // platform object whose injected column was dropped (ADR-0131 D7) — where + // the engine refuses the stamped row as an unknown field and the seed row + // is lost. A definition the loader cannot resolve keeps the stamp (the + // behaviour before this read existed): the column is then the engine's to + // judge, and nothing here refuses a row for an owner it cannot prove. + const objectDefinition = await this.resolveObjectDefinition(objectName); + const rowsCarryOrganization = objectDefinition == null || seedRowNeedsOrganization(objectDefinition); // [ADR-0131 D9] With no organization pinned and none derivable on an install // that registers the organization object, a row of an organization-owned - // object has no owner: it is REFUSED below, never written NULL. Read once - // per dataset, and only when it can matter. + // object has no owner: it is REFUSED below, never written NULL. const refuseUnownedRows = config.organizationId == null && this.fallbackOrgId === undefined && this.unownedOrganizationCount !== undefined && - seedRowNeedsOrganization(await this.resolveObjectDefinition(objectName)); + seedRowNeedsOrganization(objectDefinition); let unownedRefusalLogged = false; // Pin a single `now()` snapshot for the entire dataset so multi-pass @@ -1246,14 +1257,16 @@ export class SeedLoaderService implements ISeedLoaderService { */ const requiredDeferrals: Array<{ field: string; targetObject: string; attemptedValue: unknown }> = []; - // Per-tenant tagging: stamp every seeded row with the target org — the - // caller's explicit `config.organizationId`, or (when none was pinned) the - // install's sole organization. [ADR-0131 D3] `sys_` / `cloud_` / `ai_` - // seeds take it too: their exemption ("intentionally global") is - // withdrawn, since no platform-global seeds remain. A record that - // supplies its own `organization_id` always wins; objects without the - // column ignore the extra key at the engine. - const tenantOrg = config.organizationId ?? this.fallbackOrgId; + // Per-tenant tagging: stamp every seeded row of an object that carries an + // organization with the target org — the caller's explicit + // `config.organizationId`, or (when none was pinned) the install's sole + // organization. [ADR-0131 D3] `sys_` / `cloud_` / `ai_` seeds take it + // too: their exemption ("intentionally global") is withdrawn, since no + // platform-global seeds remain. A record that supplies its own + // `organization_id` always wins. An object with no organization column + // gets no stamp (`rowsCarryOrganization` above): the engine refuses an + // undeclared field, so a stamp there would drop the row. + const tenantOrg = rowsCarryOrganization ? (config.organizationId ?? this.fallbackOrgId) : undefined; if (refuseUnownedRows && tenantOrg === undefined && record['organization_id'] == null) { // [ADR-0131 D9] No owner to stamp — refuse the row, loudly, by name. errored++; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index adacdaa3dc8..b9578e7d5e5 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -42,10 +42,8 @@ import { DEFAULT_AUTH_BASE_PATH, type AuthManagerOptions, } from './auth-manager.js'; -import { - ensureDefaultOrganizationExists, - isDefaultOrganizationBootstrapTrigger, -} from './ensure-default-organization.js'; +import { isDefaultOrganizationBootstrapTrigger } from './ensure-default-organization.js'; +import { ensureDefaultOrganizationExists } from './default-organization-invariant.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; import { runAttributedToUser } from './auth-actor-attribution.js'; import { withSystemContext } from './objectql-adapter.js'; diff --git a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts index 61d32dc95c8..50fbf9da021 100644 --- a/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts +++ b/packages/plugins/plugin-auth/src/default-org-bootstrap-once.ts @@ -49,12 +49,12 @@ export interface EnsureDefaultOrganizationOnceOptions { ensure?: typeof ensureDefaultOrganization; /** * [ADR-0131 D3] Did THIS process create the Default Organization at boot - * (`ensureDefaultOrganizationExists`)? Under `single` the organization no - * longer waits for the admin, so the call that creates it is no longer this - * gate's — and a kernel with no ledger binds "only on the call that CREATES - * the default organization". An organization this process created is that - * fresh install, so the bind is admitted for it; one that predates the - * process is still never re-bound. + * (`ensureDefaultOrganizationExists`, `default-organization-invariant.ts`)? + * Under `single` the organization no longer waits for the admin, so the call + * that creates it is no longer this gate's — and a kernel with no ledger + * binds "only on the call that CREATES the default organization". An + * organization this process created is that fresh install, so the bind is + * admitted for it; one that predates the process is still never re-bound. */ organizationCreatedByThisProcess?: () => boolean; } diff --git a/packages/plugins/plugin-auth/src/default-organization-invariant.ts b/packages/plugins/plugin-auth/src/default-organization-invariant.ts new file mode 100644 index 00000000000..96f3f619daf --- /dev/null +++ b/packages/plugins/plugin-auth/src/default-organization-invariant.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the + * `single` tenancy posture — the find-or-create `AuthPlugin.start()` runs + * before any application seed loads and before the server accepts a request. + * + * ⛔ Internal on purpose: this module is NOT re-exported from the package + * barrel. Its one caller is `AuthPlugin`; the owner bind it hands over to stays + * with `ensureDefaultOrganization` (`ensure-default-organization.ts`), which is + * the published helper. + */ + +const SYSTEM_CTX = { isSystem: true }; + +/** The logger channel this module writes; `AuthPlugin` passes its kernel logger. */ +interface InvariantLogger { + info: (message: string, meta?: Record) => void; +} + +function genId(prefix: string): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `${prefix}_${ts}${rand}`; +} + +/** A find result as rows, whichever envelope the engine answered with. */ +function rowsOf(result: unknown): any[] { + if (Array.isArray(result)) return result; + const records = (result as { records?: unknown } | null | undefined)?.records; + return Array.isArray(records) ? records : []; +} + +/** The Default Organization boot invariant's answer ({@link ensureDefaultOrganizationExists}). */ +export interface DefaultOrganizationInvariantResult { + /** The organization a `single` install derives every owner from; absent only when `ambiguous`. */ + organizationId?: string; + /** Whether this call created it. */ + created: boolean; + /** + * Several organizations and none with `slug='default'`: there is no single + * Default Organization to name. Nothing is created (a further organization + * would deepen the ambiguity); the `tenancy` service's organization census + * reports the topology at boot, and ADR-0131 D9 refuses every write that + * would need an owner derived from it. + */ + ambiguous?: { organizationCount: number }; +} + +/** + * [ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the + * `single` tenancy posture: find it, or create it — with or without a platform + * admin, who may not exist yet. + * + * Under `single` the organization is the environment and owns every row the + * deployment writes, so it must exist before the first write that needs an + * owner: before the application seed datasets load (`AppPlugin.start()`) and + * before the server accepts a request (`kernel:listening`). AuthPlugin calls + * this from its own `start()`, which the kernel orders ahead of every + * `AppPlugin` (its `optionalDependencies`). + * + * ⛔ Unlike {@link ensureDefaultOrganization}, nothing here is best-effort. + * A read that fails PROPAGATES — an unreadable store is not an empty one, and + * reading it as empty would mint a second organization — and a failed insert + * THROWS, naming the consequence and the remedy. The caller lets either fail + * the boot. Binding the platform admin as `owner` stays with + * {@link ensureDefaultOrganization}, which finds this organization and binds + * (or promotes) them when they appear. + */ +export async function ensureDefaultOrganizationExists( + ql: any, + options: { logger?: InvariantLogger } = {}, +): Promise { + const read = async (where: Record, limit: number) => + rowsOf(await ql.find('sys_organization', { where, limit }, { context: SYSTEM_CTX })); + + const bySlug = await read({ slug: 'default' }, 1); + if (bySlug[0]?.id) return { organizationId: String(bySlug[0].id), created: false }; + const existing = await read({}, 2); + if (existing.length === 1 && existing[0]?.id) { + return { organizationId: String(existing[0].id), created: false }; + } + if (existing.length > 1) return { created: false, ambiguous: { organizationCount: existing.length } }; + + const newOrgId = genId('org'); + let row: any; + try { + row = await ql.insert( + 'sys_organization', + { id: newOrgId, name: 'Default Organization', slug: 'default', logo: null, metadata: null }, + { context: SYSTEM_CTX }, + ); + } catch (e) { + throw Object.assign( + new Error( + '[default-org] BOOT REFUSED: the Default Organization could not be created. Under the \'single\' ' + + 'tenancy posture it owns every row this deployment writes (ADR-0131 D3), so it must exist ' + + 'before the application seeds load and before the server accepts a request; booting without it ' + + 'would serve writes that are refused for want of an owner. Remedy: make the sys_organization ' + + 'insert land — check the datasource\'s write permission and connectivity, and whether a legacy ' + + `unique index on \`slug\` refuses 'default'. Cause: ${(e as Error)?.message ?? String(e)}`, + ), + { cause: e }, + ); + } + const organizationId = String(row?.id ?? newOrgId); + options.logger?.info( + `[default-org] created the Default Organization (${organizationId}) — the owner of every row under the 'single' tenancy posture`, + { defaultOrgId: organizationId }, + ); + return { organizationId, created: true }; +} diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 00813a9fcb7..7621d2aedde 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -8,9 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { ensureDefaultOrganization, - ensureDefaultOrganizationExists, isDefaultOrganizationBootstrapTrigger, } from './ensure-default-organization.js'; +import { ensureDefaultOrganizationExists } from './default-organization-invariant.js'; import { createEnsureDefaultOrganizationOnce } from './default-org-bootstrap-once.js'; // [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 762c95dd7cf..6ec9c36482a 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -73,9 +73,10 @@ * 5. (optional, injected) hand the org's seeded rows to the admin. * * Under the `single` posture the organization itself no longer waits for the - * admin: {@link ensureDefaultOrganizationExists} creates it at boot (ADR-0131 - * D3, a boot invariant whose failure stops the boot), so step 3 finds it and - * this helper's job there is the owner bind. + * admin: `AuthPlugin.start()` creates it at boot (`ensureDefaultOrganizationExists` + * in `default-organization-invariant.ts`; ADR-0131 D3, a boot invariant whose + * failure stops the boot), so step 3 finds it and this helper's job there is + * the owner bind. */ import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core'; @@ -325,13 +326,6 @@ export interface EnsureDefaultOrganizationResult { ownershipClaimed?: number; } -/** A find result as rows, whichever envelope the engine answered with. */ -function rowsOf(result: unknown): any[] { - if (Array.isArray(result)) return result; - const records = (result as { records?: unknown } | null | undefined)?.records; - return Array.isArray(records) ? records : []; -} - /** * The Default Organization the way the `tenancy` service resolves it * (`resolveDefaultOrgId`): the stable `slug='default'` organization, else the @@ -419,86 +413,6 @@ async function promoteReconciledMemberToOwner( return { defaultOrgCreated: false, defaultOrgId, memberCreated: false, ownerPromoted: true, ownershipClaimed }; } -/** The Default Organization boot invariant's answer ({@link ensureDefaultOrganizationExists}). */ -export interface DefaultOrganizationInvariantResult { - /** The organization a `single` install derives every owner from; absent only when `ambiguous`. */ - organizationId?: string; - /** Whether this call created it. */ - created: boolean; - /** - * Several organizations and none with `slug='default'`: there is no single - * Default Organization to name. Nothing is created (a further organization - * would deepen the ambiguity); the `tenancy` service's organization census - * reports the topology at boot, and ADR-0131 D9 refuses every write that - * would need an owner derived from it. - */ - ambiguous?: { organizationCount: number }; -} - -/** - * [ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the - * `single` tenancy posture: find it, or create it — with or without a platform - * admin, who may not exist yet. - * - * Under `single` the organization is the environment and owns every row the - * deployment writes, so it must exist before the first write that needs an - * owner: before the application seed datasets load (`AppPlugin.start()`) and - * before the server accepts a request (`kernel:listening`). AuthPlugin calls - * this from its own `start()`, which the kernel orders ahead of every - * `AppPlugin` (its `optionalDependencies`). - * - * ⛔ Unlike {@link ensureDefaultOrganization}, nothing here is best-effort. - * A read that fails PROPAGATES — an unreadable store is not an empty one, and - * reading it as empty would mint a second organization — and a failed insert - * THROWS, naming the consequence and the remedy. The caller lets either fail - * the boot. Binding the platform admin as `owner` stays with - * {@link ensureDefaultOrganization}, which finds this organization and binds - * (or promotes) them when they appear. - */ -export async function ensureDefaultOrganizationExists( - ql: any, - options: { logger?: BootstrapLogger } = {}, -): Promise { - const read = async (where: Record, limit: number) => - rowsOf(await ql.find('sys_organization', { where, limit }, { context: SYSTEM_CTX })); - - const bySlug = await read({ slug: 'default' }, 1); - if (bySlug[0]?.id) return { organizationId: String(bySlug[0].id), created: false }; - const existing = await read({}, 2); - if (existing.length === 1 && existing[0]?.id) { - return { organizationId: String(existing[0].id), created: false }; - } - if (existing.length > 1) return { created: false, ambiguous: { organizationCount: existing.length } }; - - const newOrgId = genId('org'); - let row: any; - try { - row = await ql.insert( - 'sys_organization', - { id: newOrgId, name: 'Default Organization', slug: 'default', logo: null, metadata: null }, - { context: SYSTEM_CTX }, - ); - } catch (e) { - throw Object.assign( - new Error( - '[default-org] BOOT REFUSED: the Default Organization could not be created. Under the \'single\' ' - + 'tenancy posture it owns every row this deployment writes (ADR-0131 D3), so it must exist ' - + 'before the application seeds load and before the server accepts a request; booting without it ' - + 'would serve writes that are refused for want of an owner. Remedy: make the sys_organization ' - + 'insert land — check the datasource\'s write permission and connectivity, and whether a legacy ' - + `unique index on \`slug\` refuses 'default'. Cause: ${(e as Error)?.message ?? String(e)}`, - ), - { cause: e }, - ); - } - const organizationId = String(row?.id ?? newOrgId); - options.logger?.info( - `[default-org] created the Default Organization (${organizationId}) — the owner of every row under the 'single' tenancy posture`, - { defaultOrgId: organizationId }, - ); - return { organizationId, created: true }; -} - /** * Ensure the platform admin has a Default Organization to operate in. * Safe to call multiple times — idempotent on stable slug `default` diff --git a/packages/runtime/src/default-organization-boot-invariant.pin.test.ts b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts new file mode 100644 index 00000000000..b5d9c3ca758 --- /dev/null +++ b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3 / D9] The Default Organization is load-bearing under `single`, + * pinned on a REAL kernel — the plugin set `objectstack dev` / `serve` compose, + * registered in the order `@objectstack/verify`'s `bootStack` registers it. + * + * A fresh deployment with nobody signed up yet (no dev admin: `NODE_ENV` is not + * `development` here), which is the shape a production install boots in: + * + * - (a) the Default Organization exists before the HTTP listener accepts + * (`kernel:listening`), and before the first seed row is written; an + * `isSystem` insert on a tenant-column object with no organization lands + * stamped with it; + * - (d) every seed row is stamped with it — the `sys_business_unit` seed + * included, whose `sys_` namespace used to exempt it; + * - (c) the first user to sign up is the organization's OWNER, although the + * membership reconciler binds every new user as `member` the moment they + * are created (the organization now predates them) — and keeps the + * platform-admin standing the first-user promotion gives them. + * + * `AppPlugin` is registered BEFORE `AuthPlugin` on purpose: the kernel has to + * start the auth plugin first from `AppPlugin`'s declared order-if-present + * dependency, not from where a host happened to list it. + * + * Controls: an object declaring `tenancy: { enabled: false }` takes no + * organization, seeded or inserted, so the stamp is a per-object derivation and + * not a blanket write; the platform-admin standing of the first user is read + * back unchanged. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/rest'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AppPlugin } from './app-plugin.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const BOOT_TIMEOUT = 180_000; +const ORIGIN = 'http://localhost:3000'; +const API = '/api/v1'; +const SYS = { isSystem: true }; +const FIRST_USER = { email: 'first-owner-15195@example.invalid', password: 'First-Owner-15195', name: 'First owner' }; + +const PIN_APP: any = { + manifest: { id: 'com.pin.defaultorg15195', name: 'Default organization pins', version: '1.0.0' }, + objects: [ + { name: 'pin_ledger', label: 'Pin ledger', fields: { title: { type: 'text', label: 'Title' } } }, + { + // ADR-0066: the declared way to hold rows that belong to no organization. + name: 'pin_licence', + label: 'Pin licence', + tenancy: { enabled: false }, + fields: { title: { type: 'text', label: 'Title' } }, + }, + ], + data: [ + { + object: 'sys_business_unit', + mode: 'upsert', + externalId: 'id', + records: [{ id: 'bu_pin_15195', name: 'Pin Head Office', code: 'PIN-HQ', kind: 'company', active: true }], + }, + { object: 'pin_ledger', mode: 'upsert', externalId: 'title', records: [{ title: 'seeded one' }, { title: 'seeded two' }] }, + { object: 'pin_licence', mode: 'upsert', externalId: 'title', records: [{ title: 'seeded licence' }] }, + ], +}; + +/** What the probe saw while the kernel booted. */ +const seen: { inserts: string[]; orgsAtListening?: Array> } = { inserts: [] }; + +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + +/** + * Records every insert's object in order, and the organizations that exist when + * `kernel:listening` fires — the hook the HTTP server opens its listener on. + */ +const PROBE: any = { + name: 'pin.default-org-15195.probe', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: any) { + const ql = ctx.getService('objectql'); + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'insert') seen.inserts.push(String(opCtx.object)); + await next(); + }); + ctx.hook('kernel:listening', async () => { + seen.orgsAtListening = rowsOf(await ql.find('sys_organization', { fields: ['id', 'slug'], limit: 5, context: SYS })); + }); + }, +}; + +let kernel: any; +let httpServer: any; +let app: any; +let engine: any; + +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); +const json = (payload: unknown, token?: string): RequestInit => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(payload), +}); + +async function find(object: string, where: Record = {}): Promise>> { + return rowsOf(await engine.find(object, { where, limit: 50, context: SYS })); +} + +beforeAll(async () => { + 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()); + // The production default: `autoDefaultOrganization` on. + await kernel.use(new AuthPlugin({ secret: 'default-org-15195-pin-secret' })); + await kernel.use(PROBE); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); + await kernel.use(new SharingServicePlugin()); + await kernel.use(createRestApiPlugin({})); + await kernel.use(createDispatcherPlugin({})); + await kernel.bootstrap(); + + httpServer = await kernel.getServiceAsync('http-server'); + app = httpServer.getRawApp(); + engine = await kernel.getServiceAsync('objectql'); +}, BOOT_TIMEOUT); + +afterAll(async () => { + try { await httpServer?.close?.(); } catch { /* best-effort */ } + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } +}, 60_000); + +describe('[ADR-0131 D3] (a) the Default Organization exists before the listener and before the seeds', () => { + it('kernel:listening sees exactly the Default Organization, on a boot nobody has signed up to', async () => { + expect(seen.orgsAtListening?.map((o) => o.slug)).toEqual(['default']); + // Nobody signed up: the organization did not wait for an admin. + expect(await find('sys_user')).toEqual([]); + }); + + it('the organization row is written before the first seed row', () => { + const organization = seen.inserts.indexOf('sys_organization'); + const firstSeed = seen.inserts.findIndex((o) => o === 'sys_business_unit' || o === 'pin_ledger'); + // Both were observed — the order below compares two real writes. + expect(organization).toBeGreaterThanOrEqual(0); + expect(firstSeed).toBeGreaterThanOrEqual(0); + expect(organization).toBeLessThan(firstSeed); + }); + + it('an isSystem insert on a tenant-column object with no organization lands stamped with it', async () => { + const [org] = await find('sys_organization'); + const row = await engine.insert('pin_ledger', { title: 'system write, no organization' }, { context: SYS }); + expect((await find('pin_ledger', { id: row.id }))[0]?.organization_id).toBe(org.id); + // CONTROL — an object that declares no tenancy takes no organization. + const licence = await engine.insert('pin_licence', { title: 'system write, tenancy off' }, { context: SYS }); + expect((await find('pin_licence', { id: licence.id }))[0]?.organization_id ?? null).toBeNull(); + }); +}); + +describe('[ADR-0131 D3 / D9] (d) every seed row carries the Default Organization, `sys_` seeds included', () => { + it('the app seed and the sys_business_unit seed are stamped; the tenancy-off control is not', async () => { + const [org] = await find('sys_organization'); + const ledger = await find('pin_ledger', { title: { $in: ['seeded one', 'seeded two'] } }); + expect(ledger.map((r) => r.organization_id)).toEqual([org.id, org.id]); + const units = await find('sys_business_unit', { id: 'bu_pin_15195' }); + expect(units.map((r) => r.organization_id)).toEqual([org.id]); + // CONTROL — the seed WAS written, and with no organization, where the + // object declares none: the census reads the column, it is not a constant. + const licences = await find('pin_licence', { title: 'seeded licence' }); + expect(licences.map((r) => r.organization_id ?? null)).toEqual([null]); + }); +}); + +describe('[ADR-0131 D3 / ADR-0093 D7] (c) the first user of a fresh `single` deployment is the owner', () => { + it('signs up as the first user, ends as the one owner of the Default Organization, and keeps platform-admin standing', async () => { + const signUp = await req('/auth/sign-up/email', json(FIRST_USER)); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const signIn = await req('/auth/sign-in/email', json({ email: FIRST_USER.email, password: FIRST_USER.password })); + expect(signIn.status).toBe(200); + const token = ((await signIn.json()) as { token: string }).token; + + const [org] = await find('sys_organization'); + const [user] = await find('sys_user', { email: FIRST_USER.email }); + const memberships = (await find('sys_member', { user_id: user.id })) + .map((m) => ({ organization_id: m.organization_id, role: m.role })); + // ONE membership, promoted in place: not `member`, and not a second row. + expect(memberships).toEqual([{ organization_id: org.id, role: 'owner' }]); + + // CONTROL — the platform-admin standing the first-user promotion gives is + // unchanged by the organization existing first. + const session = await app.request(`${ORIGIN}${API}/auth/get-session`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const sessionUser = ((await session.json()) as { user?: { isPlatformAdmin?: boolean; positions?: string[] } }).user; + expect(sessionUser?.isPlatformAdmin).toBe(true); + expect(sessionUser?.positions).toContain('platform_admin'); + }); +}); From 558793dd9f87da3a14eb0a7ce3605e119edf552a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:15:21 +0000 Subject: [PATCH 06/18] wip(runtime): the owner pin boots the dev-admin shape, where the reconciler binds the first admin before the owner bind Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...lt-organization-boot-invariant.pin.test.ts | 270 +++++++++++------- 1 file changed, 163 insertions(+), 107 deletions(-) diff --git a/packages/runtime/src/default-organization-boot-invariant.pin.test.ts b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts index b5d9c3ca758..67c8408c31e 100644 --- a/packages/runtime/src/default-organization-boot-invariant.pin.test.ts +++ b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts @@ -5,8 +5,8 @@ * pinned on a REAL kernel — the plugin set `objectstack dev` / `serve` compose, * registered in the order `@objectstack/verify`'s `bootStack` registers it. * - * A fresh deployment with nobody signed up yet (no dev admin: `NODE_ENV` is not - * `development` here), which is the shape a production install boots in: + * Kernel A — a fresh deployment nobody has signed up to (no dev admin: + * `NODE_ENV` is not `development`), the shape a production install boots in: * * - (a) the Default Organization exists before the HTTP listener accepts * (`kernel:listening`), and before the first seed row is written; an @@ -14,10 +14,18 @@ * stamped with it; * - (d) every seed row is stamped with it — the `sys_business_unit` seed * included, whose `sys_` namespace used to exempt it; - * - (c) the first user to sign up is the organization's OWNER, although the - * membership reconciler binds every new user as `member` the moment they - * are created (the organization now predates them) — and keeps the - * platform-admin standing the first-user promotion gives them. + * - the first user to sign up over HTTP (the first-run owner wizard's path) + * is its owner. + * + * Kernel B — `objectstack dev`'s shape: the dev admin is created on + * `kernel:ready`, AFTER the organization exists, so the membership reconciler + * binds them as `member` before the owner bind learns they are the platform + * admin: + * + * - (c) the first admin ends as the ONE owner of the Default Organization — + * the reconciler's `member` row promoted in place (ADR-0093 D7: while the + * once-only bind is undecided) — and keeps the platform-admin standing the + * first-user promotion gives them. * * `AppPlugin` is registered BEFORE `AuthPlugin` on purpose: the kernel has to * start the auth plugin first from `AppPlugin`'s declared order-if-present @@ -25,7 +33,7 @@ * * Controls: an object declaring `tenancy: { enabled: false }` takes no * organization, seeded or inserted, so the stamp is a per-object derivation and - * not a blanket write; the platform-admin standing of the first user is read + * not a blanket write; the platform-admin standing of the first admin is read * back unchanged. */ @@ -47,6 +55,8 @@ const ORIGIN = 'http://localhost:3000'; const API = '/api/v1'; const SYS = { isSystem: true }; const FIRST_USER = { email: 'first-owner-15195@example.invalid', password: 'First-Owner-15195', name: 'First owner' }; +/** `AuthPlugin`'s dev-admin defaults (`OS_SEED_ADMIN_*` unset). */ +const DEV_ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; const PIN_APP: any = { manifest: { id: 'com.pin.defaultorg15195', name: 'Default organization pins', version: '1.0.0' }, @@ -72,135 +82,181 @@ const PIN_APP: any = { ], }; -/** What the probe saw while the kernel booted. */ -const seen: { inserts: string[]; orgsAtListening?: Array> } = { inserts: [] }; - const rowsOf = (r: unknown): Array> => Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); -/** - * Records every insert's object in order, and the organizations that exist when - * `kernel:listening` fires — the hook the HTTP server opens its listener on. - */ -const PROBE: any = { - name: 'pin.default-org-15195.probe', - dependencies: ['com.objectstack.engine.objectql'], - async init(ctx: any) { - const ql = ctx.getService('objectql'); - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - if (opCtx.operation === 'insert') seen.inserts.push(String(opCtx.object)); - await next(); - }); - ctx.hook('kernel:listening', async () => { - seen.orgsAtListening = rowsOf(await ql.find('sys_organization', { fields: ['id', 'slug'], limit: 5, context: SYS })); - }); - }, -}; - -let kernel: any; -let httpServer: any; -let app: any; -let engine: any; - -const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); -const json = (payload: unknown, token?: string): RequestInit => ({ +const json = (payload: unknown): RequestInit => ({ method: 'POST', - headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); -async function find(object: string, where: Record = {}): Promise>> { - return rowsOf(await engine.find(object, { where, limit: 50, context: SYS })); +interface Booted { + kernel: any; + httpServer: any; + engine: any; + request: (path: string, init?: RequestInit) => Promise; + find: (object: string, where?: Record) => Promise>>; + /** Every insert's object, in order, as the engine saw it. */ + inserts: string[]; + /** The `role` of every `sys_member` row inserted, in order. */ + memberInsertRoles: unknown[]; + /** The organizations that existed when `kernel:listening` fired (the listener opens on it). */ + orgsAtListening?: Array>; } -beforeAll(async () => { - kernel = new ObjectKernel(); +async function boot(secret: string): Promise { + const booted = { inserts: [], memberInsertRoles: [] } as unknown as Booted; + const probe: any = { + name: 'pin.default-org-15195.probe', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: any) { + const ql = ctx.getService('objectql'); + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'insert') { + booted.inserts.push(String(opCtx.object)); + if (opCtx.object === 'sys_member') booted.memberInsertRoles.push(opCtx.data?.role); + } + await next(); + }); + ctx.hook('kernel:listening', async () => { + booted.orgsAtListening = rowsOf( + await ql.find('sys_organization', { fields: ['id', 'slug'], limit: 5, context: SYS }), + ); + }); + }, + }; + const 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()); // The production default: `autoDefaultOrganization` on. - await kernel.use(new AuthPlugin({ secret: 'default-org-15195-pin-secret' })); - await kernel.use(PROBE); + await kernel.use(new AuthPlugin({ secret })); + await kernel.use(probe); await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); await kernel.use(new SharingServicePlugin()); await kernel.use(createRestApiPlugin({})); await kernel.use(createDispatcherPlugin({})); await kernel.bootstrap(); - httpServer = await kernel.getServiceAsync('http-server'); - app = httpServer.getRawApp(); - engine = await kernel.getServiceAsync('objectql'); -}, BOOT_TIMEOUT); - -afterAll(async () => { - try { await httpServer?.close?.(); } catch { /* best-effort */ } - try { await kernel?.shutdown?.(); } catch { /* best-effort */ } -}, 60_000); - -describe('[ADR-0131 D3] (a) the Default Organization exists before the listener and before the seeds', () => { - it('kernel:listening sees exactly the Default Organization, on a boot nobody has signed up to', async () => { - expect(seen.orgsAtListening?.map((o) => o.slug)).toEqual(['default']); - // Nobody signed up: the organization did not wait for an admin. - expect(await find('sys_user')).toEqual([]); - }); + booted.kernel = kernel; + booted.httpServer = await kernel.getServiceAsync('http-server'); + booted.engine = await kernel.getServiceAsync('objectql'); + const app = booted.httpServer.getRawApp(); + booted.request = (path, init) => app.request(`${ORIGIN}${API}${path}`, init); + booted.find = async (object, where = {}) => + rowsOf(await booted.engine.find(object, { where, limit: 50, context: SYS })); + return booted; +} + +async function shutdown(booted: Booted | undefined): Promise { + try { await booted?.httpServer?.close?.(); } catch { /* best-effort */ } + try { await booted?.kernel?.shutdown?.(); } catch { /* best-effort */ } +} - it('the organization row is written before the first seed row', () => { - const organization = seen.inserts.indexOf('sys_organization'); - const firstSeed = seen.inserts.findIndex((o) => o === 'sys_business_unit' || o === 'pin_ledger'); - // Both were observed — the order below compares two real writes. - expect(organization).toBeGreaterThanOrEqual(0); - expect(firstSeed).toBeGreaterThanOrEqual(0); - expect(organization).toBeLessThan(firstSeed); +/** Sign in, then read the session the way the console does. */ +async function sessionOf(booted: Booted, who: { email: string; password: string }) { + const signIn = await booted.request('/auth/sign-in/email', json(who)); + expect(signIn.status, await signIn.clone().text()).toBe(200); + const token = ((await signIn.json()) as { token: string }).token; + const session = await booted.request('/auth/get-session', { headers: { Authorization: `Bearer ${token}` } }); + return ((await session.json()) as { user?: { isPlatformAdmin?: boolean; positions?: string[] } }).user; +} + +async function membershipsOf(booted: Booted, email: string) { + const [user] = await booted.find('sys_user', { email }); + return (await booted.find('sys_member', { user_id: user.id })) + .map((m) => ({ organization_id: m.organization_id, role: m.role })); +} + +describe('kernel A — a fresh `single` deployment nobody has signed up to', () => { + let A: Booted; + beforeAll(async () => { A = await boot('default-org-15195-pin-secret-a'); }, BOOT_TIMEOUT); + afterAll(async () => { await shutdown(A); }, 60_000); + + describe('[ADR-0131 D3] (a) the Default Organization exists before the listener and before the seeds', () => { + it('kernel:listening sees exactly the Default Organization, on a boot nobody has signed up to', async () => { + expect(A.orgsAtListening?.map((o) => o.slug)).toEqual(['default']); + // Nobody signed up: the organization did not wait for an admin. + expect(await A.find('sys_user')).toEqual([]); + }); + + it('the organization row is written before the first seed row', () => { + const organization = A.inserts.indexOf('sys_organization'); + const firstSeed = A.inserts.findIndex((o) => o === 'sys_business_unit' || o === 'pin_ledger'); + // Both were observed — the order below compares two real writes. + expect(organization).toBeGreaterThanOrEqual(0); + expect(firstSeed).toBeGreaterThanOrEqual(0); + expect(organization).toBeLessThan(firstSeed); + }); + + it('an isSystem insert on a tenant-column object with no organization lands stamped with it', async () => { + const [org] = await A.find('sys_organization'); + const row = await A.engine.insert('pin_ledger', { title: 'system write, no organization' }, { context: SYS }); + expect((await A.find('pin_ledger', { id: row.id }))[0]?.organization_id).toBe(org.id); + // CONTROL — an object that declares no tenancy takes no organization. + const licence = await A.engine.insert('pin_licence', { title: 'system write, tenancy off' }, { context: SYS }); + expect((await A.find('pin_licence', { id: licence.id }))[0]?.organization_id ?? null).toBeNull(); + }); }); - it('an isSystem insert on a tenant-column object with no organization lands stamped with it', async () => { - const [org] = await find('sys_organization'); - const row = await engine.insert('pin_ledger', { title: 'system write, no organization' }, { context: SYS }); - expect((await find('pin_ledger', { id: row.id }))[0]?.organization_id).toBe(org.id); - // CONTROL — an object that declares no tenancy takes no organization. - const licence = await engine.insert('pin_licence', { title: 'system write, tenancy off' }, { context: SYS }); - expect((await find('pin_licence', { id: licence.id }))[0]?.organization_id ?? null).toBeNull(); + describe('[ADR-0131 D3 / D9] (d) every seed row carries the Default Organization, `sys_` seeds included', () => { + it('the app seed and the sys_business_unit seed are stamped; the tenancy-off control is not', async () => { + const [org] = await A.find('sys_organization'); + const ledger = await A.find('pin_ledger', { title: { $in: ['seeded one', 'seeded two'] } }); + expect(ledger.map((r) => r.organization_id)).toEqual([org.id, org.id]); + const units = await A.find('sys_business_unit', { id: 'bu_pin_15195' }); + expect(units.map((r) => r.organization_id)).toEqual([org.id]); + // CONTROL — the seed WAS written, and with no organization, where the + // object declares none: the census reads the column, it is not a constant. + const licences = await A.find('pin_licence', { title: 'seeded licence' }); + expect(licences.map((r) => r.organization_id ?? null)).toEqual([null]); + }); }); -}); -describe('[ADR-0131 D3 / D9] (d) every seed row carries the Default Organization, `sys_` seeds included', () => { - it('the app seed and the sys_business_unit seed are stamped; the tenancy-off control is not', async () => { - const [org] = await find('sys_organization'); - const ledger = await find('pin_ledger', { title: { $in: ['seeded one', 'seeded two'] } }); - expect(ledger.map((r) => r.organization_id)).toEqual([org.id, org.id]); - const units = await find('sys_business_unit', { id: 'bu_pin_15195' }); - expect(units.map((r) => r.organization_id)).toEqual([org.id]); - // CONTROL — the seed WAS written, and with no organization, where the - // object declares none: the census reads the column, it is not a constant. - const licences = await find('pin_licence', { title: 'seeded licence' }); - expect(licences.map((r) => r.organization_id ?? null)).toEqual([null]); + describe('[ADR-0131 D3] the first user to sign up over HTTP is the owner', () => { + it('ends as the one owner of the Default Organization, with platform-admin standing', async () => { + const signUp = await A.request('/auth/sign-up/email', json(FIRST_USER)); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const [org] = await A.find('sys_organization'); + expect(await membershipsOf(A, FIRST_USER.email)).toEqual([{ organization_id: org.id, role: 'owner' }]); + const user = await sessionOf(A, FIRST_USER); + expect(user?.isPlatformAdmin).toBe(true); + expect(user?.positions).toContain('platform_admin'); + }); }); }); -describe('[ADR-0131 D3 / ADR-0093 D7] (c) the first user of a fresh `single` deployment is the owner', () => { - it('signs up as the first user, ends as the one owner of the Default Organization, and keeps platform-admin standing', async () => { - const signUp = await req('/auth/sign-up/email', json(FIRST_USER)); - expect(signUp.status, await signUp.clone().text()).toBe(200); - const signIn = await req('/auth/sign-in/email', json({ email: FIRST_USER.email, password: FIRST_USER.password })); - expect(signIn.status).toBe(200); - const token = ((await signIn.json()) as { token: string }).token; - - const [org] = await find('sys_organization'); - const [user] = await find('sys_user', { email: FIRST_USER.email }); - const memberships = (await find('sys_member', { user_id: user.id })) - .map((m) => ({ organization_id: m.organization_id, role: m.role })); - // ONE membership, promoted in place: not `member`, and not a second row. - expect(memberships).toEqual([{ organization_id: org.id, role: 'owner' }]); - - // CONTROL — the platform-admin standing the first-user promotion gives is - // unchanged by the organization existing first. - const session = await app.request(`${ORIGIN}${API}/auth/get-session`, { - headers: { Authorization: `Bearer ${token}` }, +describe('kernel B — `objectstack dev`: the dev admin is created after the organization exists', () => { + let B: Booted; + let prevNodeEnv: string | undefined; + beforeAll(async () => { + prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it + B = await boot('default-org-15195-pin-secret-b'); + }, BOOT_TIMEOUT); + afterAll(async () => { + await shutdown(B); + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prevNodeEnv; + }, 60_000); + + describe('[ADR-0131 D3 / ADR-0093 D7] (c) the first admin of a fresh `single` deployment is the owner', () => { + it('the reconciler bound them `member`, and that row was promoted in place to `owner`', async () => { + // The precondition the promotion exists for: the organization predates + // the admin, so the reconciler's `member` row is the first membership. + expect(B.memberInsertRoles).toEqual(['member']); + const [org] = await B.find('sys_organization'); + // ONE membership, promoted in place: not `member`, and not a second row. + expect(await membershipsOf(B, DEV_ADMIN.email)).toEqual([{ organization_id: org.id, role: 'owner' }]); + }); + + it('CONTROL — the platform-admin standing the first-user promotion gives is unchanged', async () => { + const user = await sessionOf(B, DEV_ADMIN); + expect(user?.isPlatformAdmin).toBe(true); + expect(user?.positions).toContain('platform_admin'); }); - const sessionUser = ((await session.json()) as { user?: { isPlatformAdmin?: boolean; positions?: string[] } }).user; - expect(sessionUser?.isPlatformAdmin).toBe(true); - expect(sessionUser?.positions).toContain('platform_admin'); }); }); From 955a951d2cd1fda44d8a9f356ab0736dbe4d61be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:24:54 +0000 Subject: [PATCH 07/18] =?UTF-8?q?wip(changesets):=20column-aware=20seed=20?= =?UTF-8?q?stamp,=20every=20user=20bound=20from=20the=20first=20boot,=20th?= =?UTF-8?q?e=20measured=20Clause-=E2=91=A1=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...5-metadata-protocol-seed-organization-stamp.md | 15 ++++++++------- .../15195-objectql-no-organization-refused.md | 2 +- ...in-auth-default-organization-boot-invariant.md | 9 +++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.changeset/15195-metadata-protocol-seed-organization-stamp.md b/.changeset/15195-metadata-protocol-seed-organization-stamp.md index d4471c3b069..990370a1a3e 100644 --- a/.changeset/15195-metadata-protocol-seed-organization-stamp.md +++ b/.changeset/15195-metadata-protocol-seed-organization-stamp.md @@ -2,16 +2,17 @@ '@objectstack/metadata-protocol': minor --- -Every seed row is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row +Every seed row of an object that carries an organization is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row -Clause-②: no (narrowing) +Clause-②: yes (narrowing) - + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9). -- **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. -- **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id`, and a row of an object declaring `tenancy: { enabled: false }`, still load. -- **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. +- **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row of an object that carries an `organization_id` column. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. +- **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id` still loads. +- **No column, no stamp.** A row of an object with no organization column (`tenancy: { enabled: false }`, or a platform object whose injected column was dropped) is written without one, whether the organization was named by the load or derived. Before this change such a row was stamped anyway, and the engine refused it as an unknown field, so the row was lost. +- **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it on every row that carries the column, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. -**The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the plant a seed populates on a walled deployment), or set `organization_id` on the record. +**The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the organization the seed populates on a walled deployment), or set `organization_id` on the record. diff --git a/.changeset/15195-objectql-no-organization-refused.md b/.changeset/15195-objectql-no-organization-refused.md index e8e82dc9fac..9cf1c94e9a1 100644 --- a/.changeset/15195-objectql-no-organization-refused.md +++ b/.changeset/15195-objectql-no-organization-refused.md @@ -4,7 +4,7 @@ A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner -Clause-②: no (narrowing) +Clause-②: yes (narrowing) diff --git a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md index a8e36b8235d..cbaea63fe48 100644 --- a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md +++ b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md @@ -4,16 +4,17 @@ Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner -Clause-②: no (narrowing) +Clause-②: yes (narrowing) - + **BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3). - **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner. - **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues. - **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. -- **The first admin is the owner.** Because the organization now exists before the first sign-up, the membership reconciler binds every new user, the first admin included, as `member` the moment they are created. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place. A decided bind, an existing owner, or a membership elsewhere is never touched. -- **Membership from the first boot.** The one-time membership backfill now has its target at the first `kernel:ready`, so users present then (a seeded directory, say) are bound as members on the first boot rather than when the first admin appears. +- **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`. +- **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched. +- **The in-memory driver.** `@objectstack/driver-memory` refuses any read the engine hands a tenant scope, and every session now carries the Default Organization, so a `single` deployment on that driver answers a signed-in user's data reads `503`. The platform admin met the same refusal before this change, once the Default Organization existed; a SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment. **The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. From fff1f0390d418842917caf9ec27083a3f39d1ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:41:26 +0000 Subject: [PATCH 08/18] wip(tests,docs): pin the new engine doubles to the engine contracts; the tenant-audit census counts the two new system writes Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 16 +++++---- .../seed-loader-organization-stamp.test.ts | 22 ++++++++++-- .../plugin-auth/src/auth-plugin.test.ts | 7 ++-- .../src/ensure-default-organization.test.ts | 5 ++- 5 files changed, 54 insertions(+), 30 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 96cfa145fef..02b27baadc9 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 48 of them — just over a fifth +typed `any` have no type to read, and there are 50 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **54 of the 233 sites are spelled that way**. A +forwarding shim cannot, and **54 of the 235 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **233** | +| 175 write call sites | quoted in the merged changeset | **235** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 235** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 48 sites reached +one, and both are counted in the generated tables below: the 50 sites reached through an erased (`any`) receiver, and the 51 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 233 (53%) as decidably elevated, with 102 more whose elevation is a +125 of 235 (53%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 235`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **233** | -| …whose object name is statically decidable | 155 | +| write call sites on the application surface | **235** | +| …whose object name is statically decidable | 157 | | …whose object name is chosen at run time | 78 | -| …against an object with tenancy ENABLED | 154 | +| …against an object with tenancy ENABLED | 156 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 171 | +| threading a tenant context | 173 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 54 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 185 | -| receiver erased, placed by the object NAME | 28 | +| receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 104 | +| object name spelled inline | 106 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 59 | @@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-08 at `955a951d2`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 612 | +| tracked non-test sources scanned | 614 | | engine-shaped types recognised | 70 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index abbf102cbbb..1b04b10a303 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 233 | -| Object name statically decidable | 155 | +| Write call sites | 235 | +| Object name statically decidable | 157 | | Object name chosen at run time | 78 | -| Against a tenancy-enabled object | 154 | +| Against a tenancy-enabled object | 156 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 171 | +| Threading a tenant context | 173 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 54 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-07 at `e1171ca48`. +Measured on 2026-10-08 at `955a951d2`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 612 | +| tracked non-test sources scanned | 614 | | engine-shaped types recognised | 70 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | @@ -133,7 +133,9 @@ Measured on 2026-10-07 at `e1171ca48`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/default-organization-invariant.ts` | `insert` | `sys_organization` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts index 826d3b3d535..e9f92f2989b 100644 --- a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -21,6 +21,11 @@ import { describe, it, expect, vi } from 'vitest'; import { SeedLoaderService } from './seed-loader.js'; import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; type Row = Record & { id?: string }; @@ -51,7 +56,12 @@ function createEngine(store: Record) { } return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; }), - findOne: vi.fn(async () => null), + // Pinned to ObjectQL's own contracts (`check:engine-double-contract`): a + // fake looser than the engine would let a call the engine refuses pass here. + findOne: vi.fn(async (object: string, query?: any) => { + assertEngineFindOnePredicate(object, query); + return null; + }), insert: vi.fn(async (object: string, data: Row | Row[]) => { const write = (d: Row) => { const row = { ...d, id: d.id ?? `gen-${++idCounter}` }; @@ -61,8 +71,14 @@ function createEngine(store: Record) { }; return Array.isArray(data) ? data.map(write) : write(data); }), - update: vi.fn(async (_object: string, data: Row) => data), - delete: vi.fn(async () => ({ deleted: 0 })), + update: vi.fn(async (_object: string, data: Row, options?: any) => { + assertEngineUpdateDispatch(data, options); + return data; + }), + delete: vi.fn(async (_object: string, options?: any) => { + assertEngineDeleteDispatch(options); + return { deleted: 0 }; + }), count: vi.fn(async (object: string) => (store[object] ?? []).length), aggregate: vi.fn(async () => []), } as unknown as IDataEngine; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index e8a4c5aa379..59117093927 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthPlugin } from './auth-plugin'; import { AuthManager } from './auth-manager'; import type { PluginContext } from '@objectstack/core'; -import { assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql'; describe('AuthPlugin', () => { let mockContext: PluginContext; @@ -1339,7 +1339,10 @@ describe('AuthPlugin', () => { (tables[object] ??= []).push(data); return data; }), - update: vi.fn(async (object: string, data: any) => { + update: vi.fn(async (object: string, data: any, options?: any) => { + // Pinned to ObjectQL.update's dispatch predicate: the promotion is a + // by-id update, and a looser fake would pass one the engine refuses. + assertEngineUpdateDispatch(data, options); const row = (tables[object] ?? []).find((r) => r.id === data.id); if (row) Object.assign(row, data); return row ?? null; diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index 7621d2aedde..1cc0185a960 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { ensureDefaultOrganization, isDefaultOrganizationBootstrapTrigger, @@ -551,7 +552,9 @@ describe('[ADR-0131 D3 / ADR-0093 D7] the first admin is the owner even when the ql.getObject = (name: string) => (name === 'sys_migration' ? { name } : undefined); ql.findOne = async (object: string, q: any) => (ql.tables[object] ?? []).find((r: Row) => r.id === q?.where?.id) ?? null; - ql.update = vi.fn(async (object: string, data: Row) => { + ql.update = vi.fn(async (object: string, data: Row, options?: any) => { + // Pinned to ObjectQL.update's dispatch predicate (the promotion is by id). + assertEngineUpdateDispatch(data, options); const row = (ql.tables[object] ?? []).find((r: Row) => r.id === data.id); if (row) Object.assign(row, data); return row ?? null; From 3e799dc0d4dc6e1de218a582ca1fd5df03e763db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:03:09 +0000 Subject: [PATCH 09/18] wip(tests): the seed-stamp fake declares only the verbs the loader calls; the pinned-double ledger records the auth-plugin update double Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../seed-loader-organization-stamp.test.ts | 19 ------------------- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts index e9f92f2989b..b1852408596 100644 --- a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -21,11 +21,6 @@ import { describe, it, expect, vi } from 'vitest'; import { SeedLoaderService } from './seed-loader.js'; import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; -import { - assertEngineDeleteDispatch, - assertEngineFindOnePredicate, - assertEngineUpdateDispatch, -} from '@objectstack/metadata-core'; type Row = Record & { id?: string }; @@ -56,12 +51,6 @@ function createEngine(store: Record) { } return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; }), - // Pinned to ObjectQL's own contracts (`check:engine-double-contract`): a - // fake looser than the engine would let a call the engine refuses pass here. - findOne: vi.fn(async (object: string, query?: any) => { - assertEngineFindOnePredicate(object, query); - return null; - }), insert: vi.fn(async (object: string, data: Row | Row[]) => { const write = (d: Row) => { const row = { ...d, id: d.id ?? `gen-${++idCounter}` }; @@ -71,14 +60,6 @@ function createEngine(store: Record) { }; return Array.isArray(data) ? data.map(write) : write(data); }), - update: vi.fn(async (_object: string, data: Row, options?: any) => { - assertEngineUpdateDispatch(data, options); - return data; - }), - delete: vi.fn(async (_object: string, options?: any) => { - assertEngineDeleteDispatch(options); - return { deleted: 0 }; - }), count: vi.fn(async (object: string) => (store[object] ?? []).length), aggregate: vi.fn(async () => []), } as unknown as IDataEngine; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1388536ef22..c226da46024 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2741,6 +2741,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/auth-plugin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts", "verb": "findOne", From a5cf44a14cc8d3e46fece28309ca29699a4791cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:30:52 +0000 Subject: [PATCH 10/18] wip(tests): the pin's probe resolves the engine by its contract type; the seed-stamp double refuses a WHERE combinator Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/seed-loader-organization-stamp.test.ts | 14 +++++++++++++- ...default-organization-boot-invariant.pin.test.ts | 11 ++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts index b1852408596..d268f58f12b 100644 --- a/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts +++ b/packages/metadata-protocol/src/seed-loader-organization-stamp.test.ts @@ -40,6 +40,17 @@ const LICENSE = { }; const ORGANIZATION = { name: 'sys_organization', fields: { name: { type: 'text' }, slug: { type: 'text' } } }; +/** + * Equality only: a combinator this double does not implement is refused, never + * read as a field name (`check:where-matcher`). + */ +function matchesWhere(row: Row, where: Record): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + return row[k] === v; + }); +} + function createEngine(store: Record) { let idCounter = 0; const inserted: Array<{ object: string; row: Row }> = []; @@ -47,7 +58,8 @@ function createEngine(store: Record) { find: vi.fn(async (object: string, query?: { where?: Record; limit?: number }) => { let rows = store[object] ?? []; if (query?.where) { - rows = rows.filter((r) => Object.entries(query.where!).every(([k, v]) => r[k] === v)); + const where = query.where; + rows = rows.filter((r) => matchesWhere(r, where)); } return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; }), diff --git a/packages/runtime/src/default-organization-boot-invariant.pin.test.ts b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts index 67c8408c31e..e32fccfa34c 100644 --- a/packages/runtime/src/default-organization-boot-invariant.pin.test.ts +++ b/packages/runtime/src/default-organization-boot-invariant.pin.test.ts @@ -38,8 +38,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; -import { ObjectQLPlugin } from '@objectstack/objectql'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/rest'; import { AuthPlugin } from '@objectstack/plugin-auth'; @@ -107,11 +107,12 @@ interface Booted { async function boot(secret: string): Promise { const booted = { inserts: [], memberInsertRoles: [] } as unknown as Booted; - const probe: any = { + const probe: Plugin = { name: 'pin.default-org-15195.probe', + version: '0.0.0', dependencies: ['com.objectstack.engine.objectql'], - async init(ctx: any) { - const ql = ctx.getService('objectql'); + init: async (ctx: PluginContext) => { + const ql = ctx.getService('objectql'); ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { if (opCtx.operation === 'insert') { booted.inserts.push(String(opCtx.object)); From fbc0e914e2d9b57f469c020bcd1418e1baa7aa5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:38:02 +0000 Subject: [PATCH 11/18] docs(audits): the tenant-audit census regenerated after the main merge, with the two system writes C1 adds (234 to 236) Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 34 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 16 +++++---- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 2fb5784a496..48bc126ed17 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 48 of them — just over a fifth +typed `any` have no type to read, and there are 50 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **54 of the 234 sites are spelled that way**. A +forwarding shim cannot, and **54 of the 236 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **234** | +| 175 write call sites | quoted in the merged changeset | **236** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 234** decidable, **80** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 103 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 236** decidable, **80** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 103 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 48 sites reached +one, and both are counted in the generated tables below: the 50 sites reached through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 234 (53%) as decidably elevated, with 103 more whose elevation is a +125 of 236 (53%) as decidably elevated, with 103 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 234`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **234** | -| …whose object name is statically decidable | 154 | +| write call sites on the application surface | **236** | +| …whose object name is statically decidable | 156 | | …whose object name is chosen at run time | 80 | -| …against an object with tenancy ENABLED | 153 | +| …against an object with tenancy ENABLED | 155 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 172 | +| threading a tenant context | 174 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 54 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 103 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 28 | +| receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 104 | +| object name spelled inline | 106 | | object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 61 | @@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `7f9500afd`. +Measured on 2026-10-08 at `cd7f73b25`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 615 | +| tracked non-test sources scanned | 618 | | engine-shaped types recognised | 70 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 1bac8f1ce75..e2ab3ac312b 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 234 | -| Object name statically decidable | 154 | +| Write call sites | 236 | +| Object name statically decidable | 156 | | Object name chosen at run time | 80 | -| Against a tenancy-enabled object | 153 | +| Against a tenancy-enabled object | 155 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 172 | +| Threading a tenant context | 174 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 54 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 103 | @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `7f9500afd`. +Measured on 2026-10-08 at `cd7f73b25`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 615 | +| tracked non-test sources scanned | 618 | | engine-shaped types recognised | 70 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 160 | @@ -133,7 +133,9 @@ Measured on 2026-10-08 at `7f9500afd`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/default-organization-invariant.ts` | `insert` | `sys_organization` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | From 5c97ee0d4a306d05ea1a3e05e30ba44ce802f658 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:40:00 +0000 Subject: [PATCH 12/18] wip(verify): bootStack boots the production single shape; orgContext keeps only the vacuity guard Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../verify/src/harness.org-context.test.ts | 20 +++-- packages/verify/src/harness.ts | 90 +++++++++---------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/packages/verify/src/harness.org-context.test.ts b/packages/verify/src/harness.org-context.test.ts index 0d4b0f6643d..ae749e7b0f5 100644 --- a/packages/verify/src/harness.org-context.test.ts +++ b/packages/verify/src/harness.org-context.test.ts @@ -1,7 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#7762] `bootStack({ orgContext: true })` — the harness's only way to mint an -// admin whose RESOLVED execution context carries an `organizationId`. +// [#7762] `bootStack({ orgContext: true })` — the harness's assertion that the +// admin's RESOLVED execution context carries an `organizationId`. Since +// ADR-0131 D3 every `single` boot binds the admin (see the default-boot case +// below); the flag keeps the guard that refuses a boot where it did not. // // Why it exists: before this option, `bootStack`'s admin resolved org-less, so // every `organization_id`-filtered read in the platform was structurally @@ -157,17 +159,19 @@ describe('bootStack orgContext (#7762)', () => { ); it( - 'the DEFAULT boot is unchanged — no organization, no active org on the session', + '[ADR-0131 D3] the DEFAULT boot is the production `single` shape — the Default Organization, carried by the admin\'s session', async () => { - // The other half of the contract, and the reason the existing 579-test - // dogfood suite is unaffected: `orgContext` is opt-in, and the org-less - // admin every current fixture asserts against stays org-less. + // The flag is now only the vacuity guard: every `single` boot has the + // Default Organization (a boot invariant), and the admin is bound to it + // without asking — the shape `objectstack dev` / `serve` boot. There is + // no org-less `single` boot left to keep a fixture on (ADR-0131 D11). const stack = await bootStack(app, {}); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ql = await stack.kernel.getServiceAsync('objectql'); - expect(rowsOf(await ql.find('sys_organization', { limit: 10, context: SYS }))).toHaveLength(0); - expect(await sessionOrgId(stack)).toBeNull(); + const orgs = rowsOf(await ql.find('sys_organization', { limit: 10, context: SYS })); + expect(orgs.map((o) => o.slug)).toEqual(['default']); + expect(await sessionOrgId(stack)).toBe(orgs[0].id); } finally { await stack.stop(); } diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 6edabfac1b8..a7d5e7b8a69 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -248,41 +248,40 @@ export interface BootOptions { */ multiTenant?: boolean | 'posture-only'; /** - * Bind the harness admin to a real organization, so the execution context - * every request of theirs resolves CARRIES an `organizationId`. Default - * `false`. - * - * Mechanically it is one flip: `AuthPlugin`'s cloud ADR-0081 D1 default-org - * bootstrap (`autoDefaultOrganization`), which the harness otherwise pins - * OFF (see the AuthPlugin registration below). The bootstrap mints a - * `sys_organization` and binds the platform admin to it as `owner`; the - * `session.create.before` hook then stamps that org onto the session as - * `activeOrganizationId`, which is the ONE wire field - * `resolveAuthzContext` reads into `tenantId` → `ExecutionContext`. Same - * path `objectstack dev` / `serve` give a real single-tenant deployment — - * nothing here is simulated. - * - * ## What it buys + * ASSERT that the harness admin is bound to an organization, so the execution + * context every request of theirs resolves CARRIES an `organizationId` — and + * refuse the boot when it is not. Default `false`. + * + * [ADR-0131 D3] The bind itself is no longer opt-in: every `single` boot has + * the Default Organization (`AuthPlugin.start()` creates it before the seeds + * load), the membership reconciler makes every sign-up its member, and the + * owner bind makes the platform admin its owner — the production shape + * `objectstack dev` / `serve` boot, which `bootStack` boots too. The + * `session.create.before` hook stamps that organization onto the session as + * `activeOrganizationId`, the ONE wire field `resolveAuthzContext` reads into + * `tenantId` → `ExecutionContext`. This flag adds the vacuity guard below: + * a fixture whose subject IS the org-bound caller fails loudly at boot if a + * future composition stops binding, instead of asserting nothing. + * + * ## What the bound caller buys * * Application-level `organization_id`-scoped READS engage their filter, * because they read the org id off the resolved context directly. Before - * this flag, no fixture in the open core could reach that branch over HTTP: + * #7762, no fixture in the open core could reach that branch over HTTP: * `bootStack`'s admin resolved org-less, so a filtered read returned * whatever the UNfiltered one did and a test asserting on the difference - * asserted nothing (#7762). `sys_sharing_rule` listing (#7676), + * asserted nothing. `sys_sharing_rule` listing (#7676), * `sys_business_unit` approver expansion (#3807) and `sys_metadata` * pending-draft listing are the same shape. * * ## ⛔ It performs NO tenant isolation whatsoever * - * It stamps the CALLER's org; it does not stand up an organization wall. - * With no `org-scoping` service registered — and this flag registers none — - * `SecurityPlugin` STRIPS the wildcard `organization_id` RLS policies that - * ship in the default permission sets (`collectRLSPolicies`; see the - * `multiTenant` doc block above). So a fixture asserting "tenant B cannot - * read tenant A's rows" and booting this way would assert nothing and pass: - * the #4700 constant-false capability probe wearing the opposite mask, and - * the precise trap this option's NAME is chosen to stay clear of. + * A bound caller is not an organization wall. With no `org-scoping` service + * registered — and nothing here registers one — `SecurityPlugin` STRIPS the + * wildcard `organization_id` RLS policies that ship in the default + * permission sets (`collectRLSPolicies`; see the `multiTenant` doc block + * above). So a fixture asserting "tenant B cannot read tenant A's rows" on a + * `single` boot asserts nothing and passes. * * Cross-tenant isolation has exactly one honest proof in this repo: * `multiTenant: true` with the real `@objectstack/organizations` installed @@ -305,15 +304,13 @@ export interface BootOptions { * * - with `multiTenant: true`, the enterprise `@objectstack/organizations` * package OWNS the org bootstrap and already binds the admin — this flag - * would be a second, competing owner of one invariant; - * - with `multiTenant: 'posture-only'`, it would be a NO-OP that looks like - * a feature. That mode requests the `isolated` posture, and the open + * would assert on a second owner's invariant; + * - with `multiTenant: 'posture-only'`, it would assert something that + * cannot hold. That mode requests the `isolated` posture, and the open * default-org bootstrap deliberately abstains under every WALLED posture * (`postureEnforcesWall`, cloud ADR-0081 D1) — the open package never * bootstraps an organization for a deployment whose multi-organization - * runtime it does not provide. The admin would resolve org-less while the - * fixture read as org-bound: vacuity, which is the whole defect class - * #7762 exists to close. + * runtime it does not provide. The admin resolves org-less there. * * A posture-gated seam that ALSO needs an org-bound caller therefore has no * harness answer today; it needs the real enterprise package. @@ -544,31 +541,24 @@ export async function bootStack( const cryptoProvider = harnessCryptoProvider(); await kernel.use(new SettingsServicePlugin({ cryptoProvider })); await kernel.use(opts.analytics ?? new AnalyticsServicePlugin()); - // `autoDefaultOrganization: false` (cloud ADR-0081 D1): the harness proves the two - // ENDS of the isolation spectrum — pure single-tenant (no org, no scoping) - // and, via `opts.multiTenant`, full multi-org (the enterprise plugin owns - // the org bootstrap). AuthPlugin's single-org default-org bootstrap is a - // product onboarding convenience for `objectstack dev`/`serve`; letting it - // run here would mint a Default Organization + bind the dev admin as owner, - // giving every "single-tenant" fixture an active org — which turns on - // org-scoped RLS and reparents seeded rows, silently breaking the pure - // single-tenant baseline these dogfood proofs assert (ADR-0057 identity - // create, ADR-0062 federation, ADR-0086 two-doors). The bootstrap itself is - // covered by plugin-auth unit tests + browser E2E. + // [ADR-0131 D3 / D11] The production `single` shape, as `objectstack dev` / + // `serve` boot it: `AuthPlugin`'s defaults, owner bind included. Under + // `single` the Default Organization is a boot invariant — `AuthPlugin.start()` + // creates it before any seed loads, whatever `autoDefaultOrganization` says — + // so every seed row and system write is owned by it, every sign-up is its + // member, and the platform admin is its owner. There is no organization-less + // `single` deployment to model any more, so the harness does not pin one: + // `autoDefaultOrganization: false` would leave only a harness-only admin who + // is a `member` and not the owner, a standing no deployment has. Full + // multi-org stays `opts.multiTenant` (the enterprise plugin owns the walled + // bootstrap; the open one abstains under every walled posture). // // [ADR-0108 / #3723] Nothing to wire: the organization-role vocabulary is // closed, and a stack's declared `position` / `permission` names are // positions, not org roles. `membership-role-vocabulary.dogfood.test.ts` // boots through this harness and asserts exactly that. - // - // [#7762] `opts.orgContext` is the one thing that turns that bootstrap back - // on — the harness's ONLY way to mint an admin whose resolved execution - // context carries an `organizationId`. It is the SAME bootstrap `objectstack - // dev`/`serve` run, not a harness-local imitation, and it lights up no - // organization wall (see BootOptions.orgContext). Default stays `false`. await kernel.use(new AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET, - autoDefaultOrganization: !!opts.orgContext, })); // ADR-0062 — datasource connection service (registers 'datasource-connection'), @@ -764,7 +754,7 @@ export async function bootStack( const admin = opts.admin ?? { email: DEFAULT_ADMIN_EMAIL, password: DEFAULT_ADMIN_PASSWORD }; - // [#7762] The vacuity guard for `orgContext`. `ensureDefaultOrganization` is + // [#7762] The vacuity guard for `orgContext`. The owner bind is // deliberately best-effort — it swallows every failure so a login can never // break on org bookkeeping — which means a fixture that asked for an // org-bound admin and silently got an org-LESS one is exactly the shape this From cee4376fee712a33c03e3e8498790b6479e2d209 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 07:08:17 +0000 Subject: [PATCH 13/18] wip(dogfood): re-pin the 24 fixtures that encoded an org-less single boot, each on its own cause Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...tics-adhoc-query-isolation.dogfood.test.ts | 30 ++++- ...lytics-contains-membership.dogfood.test.ts | 32 ++++- ...s-inline-dataset-admission.dogfood.test.ts | 39 +++++- ...s-inline-dataset-isolation.dogfood.test.ts | 30 ++++- .../qa/dogfood/test/armed.dogfood.test.ts | 124 ++++++++++-------- packages/qa/dogfood/test/armed.ts | 31 +++++ .../delegated-admin-invite.dogfood.test.ts | 24 ++-- .../test/delegation-of-duty.dogfood.test.ts | 21 ++- ...nvitation-ledger-row-scope.dogfood.test.ts | 25 ++-- ...mbership-actor-attribution.dogfood.test.ts | 22 ++-- ...rship-ended-session-revoke.dogfood.test.ts | 12 +- .../membership-reconciler.dogfood.test.ts | 23 ++-- ...membership-role-vocabulary.dogfood.test.ts | 16 +-- ...org-admin-affordance-reach.dogfood.test.ts | 15 ++- ...n-delete-federated-fixture.dogfood.test.ts | 19 ++- ...-write-refusal-not-visible.dogfood.test.ts | 19 ++- ...ite-unreadable-not-matched.dogfood.test.ts | 19 ++- ...aring-rule-org-less-caller.dogfood.test.ts | 59 +++++++-- ...gle-tenant-identity-create.dogfood.test.ts | 18 ++- ...or-unreadable-is-not-found.dogfood.test.ts | 19 ++- 20 files changed, 416 insertions(+), 181 deletions(-) diff --git a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts index 5988eb794c4..bb9402ef61f 100644 --- a/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-adhoc-query-isolation.dogfood.test.ts @@ -109,7 +109,31 @@ const adhocStack = defineStack({ /** A suffix measure no configured cube declares — `ensureCube` appends it. */ const APPENDED = 'region_count_distinct'; -const DRIVERS = ['sqlite-wasm', 'memory'] as const; +/** + * [ADR-0131 C1] The two analytics strategies, each on the SQL in-memory driver + * (`sqlite-wasm`, `:memory:`): the `sqlite-wasm` leg lets the analytics plugin + * take its NativeSQL strategy, and the `objectql-strategy` leg withholds the + * native-SQL capability (`queryCapabilities`) so the ObjectQL strategy answers. + * That leg used to be the in-memory driver, whose only route to the ObjectQL + * strategy was having no SQL. Under `single` every session now carries the + * Default Organization, and `driver-memory` refuses a tenant-scoped read (503) + * until ADR-0131 D8 gives `single` no read predicate. + * Restart-when: #15212 closed — add the `memory` leg back then. + */ +const DRIVERS = ['sqlite-wasm', 'objectql-strategy'] as const; + +/** The analytics plugin a leg boots; the ObjectQL-strategy leg withholds native SQL. */ +function analyticsFor( + leg: (typeof DRIVERS)[number], + options: ConstructorParameters[0] = {}, +): AnalyticsServicePlugin { + return new AnalyticsServicePlugin({ + ...options, + ...(leg === 'objectql-strategy' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }); +} const DOORS = ['/analytics/query', '/analytics/sql'] as const; type Door = (typeof DOORS)[number]; @@ -186,8 +210,8 @@ async function expectRefused(res: Response): Promise { async function bootFor(driver: (typeof DRIVERS)[number], door: Door): Promise { const stack = await bootStack(adhocStack as never, { security: admissionFixtureSecurity(), - databaseDriver: driver, - analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, WALLED_SUMMARY] }), + databaseDriver: 'sqlite-wasm', + analytics: analyticsFor(driver, { cubes: [OPEN_SUMMARY, WALLED_SUMMARY] }), }); const adminToken = await stack.signIn(); const slug = door.replace(/\W+/g, '-'); diff --git a/packages/qa/dogfood/test/analytics-contains-membership.dogfood.test.ts b/packages/qa/dogfood/test/analytics-contains-membership.dogfood.test.ts index 76bddf81b9f..26042205074 100644 --- a/packages/qa/dogfood/test/analytics-contains-membership.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-contains-membership.dogfood.test.ts @@ -5,7 +5,7 @@ // of a multi-valued field, through the REAL stack: HTTP -> REST execution // context -> SecurityPlugin row policy -> the AnalyticsServicePlugin's // `getReadScope` auto-bridge -> the NativeSQL strategy on SQLite, and the -// ObjectQL strategy on the memory driver. +// ObjectQL strategy (native SQL withheld; see `DRIVERS`). // // ## The defect // @@ -29,6 +29,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; @@ -117,7 +118,31 @@ const OPEN = [ { name: 'o4' }, ]; -const DRIVERS = ['sqlite-wasm', 'memory'] as const; +/** + * [ADR-0131 C1] The two analytics strategies, each on the SQL in-memory driver + * (`sqlite-wasm`, `:memory:`): the `sqlite-wasm` leg lets the analytics plugin + * take its NativeSQL strategy, and the `objectql-strategy` leg withholds the + * native-SQL capability (`queryCapabilities`) so the ObjectQL strategy answers. + * That leg used to be the in-memory driver, whose only route to the ObjectQL + * strategy was having no SQL. Under `single` every session now carries the + * Default Organization, and `driver-memory` refuses a tenant-scoped read (503) + * until ADR-0131 D8 gives `single` no read predicate. + * Restart-when: #15212 closed — add the `memory` leg back then. + */ +const DRIVERS = ['sqlite-wasm', 'objectql-strategy'] as const; + +/** The analytics plugin a leg boots; the ObjectQL-strategy leg withholds native SQL. */ +function analyticsFor( + leg: (typeof DRIVERS)[number], + options: ConstructorParameters[0] = {}, +): AnalyticsServicePlugin { + return new AnalyticsServicePlugin({ + ...options, + ...(leg === 'objectql-strategy' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }); +} /** The single count a one-measure answer carries, whichever envelope the door uses. */ function countOf(body: unknown, measure: string): number { @@ -137,7 +162,8 @@ describe.each(DRIVERS)('dogfood: analytics counts a $contains policy on a multi- defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], fallbackPermissionSet: memberSet.name, }), - databaseDriver: driver, + databaseDriver: 'sqlite-wasm', + analytics: analyticsFor(driver), }); adminToken = await stack.signIn(); memberToken = await stack.signUp(`mbr-member-${driver}@verify.test`); diff --git a/packages/qa/dogfood/test/analytics-inline-dataset-admission.dogfood.test.ts b/packages/qa/dogfood/test/analytics-inline-dataset-admission.dogfood.test.ts index 4e8548ed7bc..c800b78dbbd 100644 --- a/packages/qa/dogfood/test/analytics-inline-dataset-admission.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-inline-dataset-admission.dogfood.test.ts @@ -21,10 +21,11 @@ // // ## Why this file boots TWICE // -// The two drivers reach the analytics service through DIFFERENT strategies — -// `NativeSQLStrategy` on `sqlite-wasm`, `ObjectQLStrategy` on `memory` (which -// cannot run raw SQL) — and the defect was precisely that the two disagreed. A -// gate written against one driver cannot see that class of divergence at all, +// The two boots reach the analytics service through DIFFERENT strategies — +// `NativeSQLStrategy`, and `ObjectQLStrategy` (the leg that withholds native +// SQL; see `DRIVERS` for why that leg is no longer the `memory` driver) — and +// the defect was precisely that the two disagreed. A gate written against one +// strategy cannot see that class of divergence at all, // which is how it shipped. Every case below therefore runs from one table // against both boots, and the verdicts are compared to `/data`'s rather than to // a hard-coded expectation: the assertion is AGREEMENT, so it stays honest if @@ -40,6 +41,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { admissionFixtureStack, admissionFixtureSecurity, @@ -71,7 +73,31 @@ const inlineGrouped = (object: string) => ({ /** `403` / `200` / `` — the admission verdict, nothing finer. */ type Verdict = string; -const DRIVERS = ['sqlite-wasm', 'memory'] as const; +/** + * [ADR-0131 C1] The two analytics strategies, each on the SQL in-memory driver + * (`sqlite-wasm`, `:memory:`): the `sqlite-wasm` leg lets the analytics plugin + * take its NativeSQL strategy, and the `objectql-strategy` leg withholds the + * native-SQL capability (`queryCapabilities`) so the ObjectQL strategy answers. + * That leg used to be the in-memory driver, whose only route to the ObjectQL + * strategy was having no SQL. Under `single` every session now carries the + * Default Organization, and `driver-memory` refuses a tenant-scoped read (503) + * until ADR-0131 D8 gives `single` no read predicate. + * Restart-when: #15212 closed — add the `memory` leg back then. + */ +const DRIVERS = ['sqlite-wasm', 'objectql-strategy'] as const; + +/** The analytics plugin a leg boots; the ObjectQL-strategy leg withholds native SQL. */ +function analyticsFor( + leg: (typeof DRIVERS)[number], + options: ConstructorParameters[0] = {}, +): AnalyticsServicePlugin { + return new AnalyticsServicePlugin({ + ...options, + ...(leg === 'objectql-strategy' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }); +} interface Boot { stack: VerifyStack; @@ -84,7 +110,8 @@ const boots = new Map(); async function bootFor(driver: (typeof DRIVERS)[number]): Promise { const stack = await bootStack(admissionFixtureStack as never, { security: admissionFixtureSecurity(), - databaseDriver: driver, + databaseDriver: 'sqlite-wasm', + analytics: analyticsFor(driver), }); const adminToken = await stack.signIn(); const memberToken = await stack.signUp(`admission-${driver}@verify.test`); diff --git a/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts index 2977376ef0d..f655e8e4f26 100644 --- a/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts @@ -111,7 +111,31 @@ const inlineDataset = (name: string, object: string) => ({ measures: [{ name: 'inline_total', label: 'Inline total', aggregate: 'count' }], }); -const DRIVERS = ['sqlite-wasm', 'memory'] as const; +/** + * [ADR-0131 C1] The two analytics strategies, each on the SQL in-memory driver + * (`sqlite-wasm`, `:memory:`): the `sqlite-wasm` leg lets the analytics plugin + * take its NativeSQL strategy, and the `objectql-strategy` leg withholds the + * native-SQL capability (`queryCapabilities`) so the ObjectQL strategy answers. + * That leg used to be the in-memory driver, whose only route to the ObjectQL + * strategy was having no SQL. Under `single` every session now carries the + * Default Organization, and `driver-memory` refuses a tenant-scoped read (503) + * until ADR-0131 D8 gives `single` no read predicate. + * Restart-when: #15212 closed — add the `memory` leg back then. + */ +const DRIVERS = ['sqlite-wasm', 'objectql-strategy'] as const; + +/** The analytics plugin a leg boots; the ObjectQL-strategy leg withholds native SQL. */ +function analyticsFor( + leg: (typeof DRIVERS)[number], + options: ConstructorParameters[0] = {}, +): AnalyticsServicePlugin { + return new AnalyticsServicePlugin({ + ...options, + ...(leg === 'objectql-strategy' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }); +} interface Boot { stack: VerifyStack; @@ -161,8 +185,8 @@ function countOf(body: unknown, measure: string): number { async function bootFor(driver: (typeof DRIVERS)[number]): Promise { const stack = await bootStack(isolationStack as never, { security: admissionFixtureSecurity(), - databaseDriver: driver, - analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, HIDDEN_SUMMARY] }), + databaseDriver: 'sqlite-wasm', + analytics: analyticsFor(driver, { cubes: [OPEN_SUMMARY, HIDDEN_SUMMARY] }), }); const adminToken = await stack.signIn(); const tokenA = await stack.signUp(`isolation-a-${driver}@verify.test`); diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index d84a006516e..a1f733f07e2 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -9,10 +9,15 @@ // // So every probe is measured twice, and the disarmed halves are not synthetic: // -// principalArmed DISARMED on an org-LESS boot — a live reproduction of -// #8023, where a fresh sign-up holds `['everyone']` and the -// `org_member`-gated write floor never applies -// ARMED on `bootStack(..., { orgContext: true })` +// principalArmed DISARMED for a principal OUTSIDE the organization — a +// live reproduction of #8023's shape, where the caller +// holds no `org_member` and the `org_member`-gated write +// floor never applies. [ADR-0131 D3] #8023 met it as an +// org-less boot; every `single` boot now has the Default +// Organization, so the disarmed principal is a member +// removed from it (`leaveOrganization`) +// ARMED for a member of the organization, on +// `bootStack(..., { orgContext: true })` // authSettingArmed DISARMED on the default auth config — a live reproduction // of #8049, where `passwordHistoryCount` is 0/undefined and // the reuse control has nothing to reject against @@ -39,6 +44,7 @@ import { assertArmed, armedWhen, authSettingArmed, + leaveOrganization, principalArmed, resolveAuthzFor, seededArmed, @@ -48,8 +54,9 @@ import { /** The control #8023's fixture measures, named the way that card names it. */ const WRITE_FLOOR = 'the wildcard row-level write floor (`owner_only_writes`)'; const WRITE_FLOOR_DISARM = - 'an org-less boot: no organization ⇒ no `sys_member` row ⇒ the principal never holds ' + - '`org_member` ⇒ the positions-gated floor never applies. Boot with `orgContext: true`.'; + 'a principal outside the organization: no `sys_member` row ⇒ the principal never holds ' + + '`org_member` ⇒ the positions-gated floor never applies. Keep the principal a member of ' + + 'the organization (the membership reconciler binds every sign-up).'; /** The control #8049's fixture measures. */ const REUSE_CONTROL = "ADR-0069 D1's password-reuse rejection"; @@ -58,29 +65,36 @@ const REUSE_DISARM = 'has nothing to reject against. Arm it through `applyConfigPatch`.'; describe('[#8074] assertArmed: the guard against assertions that cannot fail', () => { - /** The org-LESS stack — #8023's and #8049's disarmed shapes, booted for real. */ - let orgless: VerifyStack; - /** The org-BOUND stack — the same probe, armed. */ - let orgbound: VerifyStack; + /** + * The stack whose plain member has LEFT the organization — #8023's disarmed + * shape — and the default auth config, #8049's. Both booted for real. + */ + let outside: VerifyStack; + /** The stack whose plain member is IN the organization — the same probe, armed. */ + let inside: VerifyStack; - let orglessMember: string; - let orgboundMember: string; + let outsideMember: string; + let insideMember: string; beforeAll(async () => { - orgless = await bootStack(showcaseStack, {}); - orgbound = await bootStack(showcaseStack, { orgContext: true }); + outside = await bootStack(showcaseStack, {}); + inside = await bootStack(showcaseStack, { orgContext: true }); // The first user is the seeded dev admin; a fresh sign-up is the plain // member #8023's fixture measures. - await orgless.signIn(); - await orgbound.signIn(); - orglessMember = await orgless.signUp('armed-orgless@verify.test'); - orgboundMember = await orgbound.signUp('armed-orgbound@verify.test'); + await outside.signIn(); + await inside.signIn(); + outsideMember = await outside.signUp('armed-outside@verify.test'); + insideMember = await inside.signUp('armed-inside@verify.test'); + // [ADR-0131 D3] The reconciler bound both sign-ups to the Default + // Organization; the outside member is then removed from it, which is how a + // real `single` deployment holds a principal outside the `org_member` domain. + outsideMember = await leaveOrganization(outside, 'armed-outside@verify.test'); }, 300_000); afterAll(async () => { - await orgless?.stop?.(); - await orgbound?.stop?.(); + await outside?.stop?.(); + await inside?.stop?.(); }); // ── the fixture's own integrity ─────────────────────────────────────────── @@ -88,14 +102,14 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( // Every reading below is worthless if the two stacks are not actually the two // shapes they are named for, so that is asserted before anything is measured. - it('[integrity] the two stacks really are the org-less and org-bound shapes', async () => { - const less = await resolveAuthzFor(orgless, orglessMember); - const bound = await resolveAuthzFor(orgbound, orgboundMember); - expect(less.userId, 'the org-less member resolved as a real principal').toBeTruthy(); - expect(bound.userId, 'the org-bound member resolved as a real principal').toBeTruthy(); - expect(less.positions, 'org-less: a fresh sign-up holds only the everyone anchor') + it('[integrity] the two members really are outside and inside the organization', async () => { + const less = await resolveAuthzFor(outside, outsideMember); + const bound = await resolveAuthzFor(inside, insideMember); + expect(less.userId, 'the outside member resolved as a real principal').toBeTruthy(); + expect(bound.userId, 'the inside member resolved as a real principal').toBeTruthy(); + expect(less.positions, 'outside: the member removed from the organization holds no org_member') .not.toContain('org_member'); - expect(bound.positions, 'org-bound: the membership reconciler bound the sign-up') + expect(bound.positions, 'inside: the membership reconciler bound the sign-up') .toContain('org_member'); }); @@ -112,38 +126,38 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( disarmedBy: WRITE_FLOOR_DISARM, }); - it('reports DISARMED on an org-less boot, and names what it saw', async () => { - const verdict = await probeOn(orgless, orglessMember).read(); - expect(verdict.armed, 'org-less: the floor is outside this principal’s domain').toBe(false); + it('reports DISARMED for a principal outside the organization, and names what it saw', async () => { + const verdict = await probeOn(outside, outsideMember).read(); + expect(verdict.armed, 'outside: the floor is outside this principal’s domain').toBe(false); expect(verdict.observed).toContain('missing positions'); expect(verdict.observed).toContain('org_member'); }); it('reports ARMED on `orgContext: true` — the same probe, the other direction', async () => { - const verdict = await probeOn(orgbound, orgboundMember).read(); - expect(verdict.armed, 'org-bound: the principal is inside the floor’s domain').toBe(true); + const verdict = await probeOn(inside, insideMember).read(); + expect(verdict.armed, 'inside: the principal is inside the floor’s domain').toBe(true); expect(verdict.observed).toContain('org_member'); }); it('assertArmed REJECTS the disarmed stack, naming the control and the default', async () => { - await expect(assertArmed([probeOn(orgless, orglessMember)])).rejects.toThrow( + await expect(assertArmed([probeOn(outside, outsideMember)])).rejects.toThrow( /this fixture is DISARMED/, ); - const err = await assertArmed([probeOn(orgless, orglessMember)]).catch((e: Error) => e); + const err = await assertArmed([probeOn(outside, outsideMember)]).catch((e: Error) => e); expect(String(err)).toContain(WRITE_FLOOR); // The remedy, not just the symptom — the sentence the next author needs. - expect(String(err)).toContain('orgContext: true'); + expect(String(err)).toContain('Keep the principal a member of the organization'); }); it('[positive control] assertArmed RESOLVES on the armed stack', async () => { // Without this, every rejection assertion above would be satisfied by an // `assertArmed` that simply always threw. - await expect(assertArmed([probeOn(orgbound, orgboundMember)])).resolves.toBeUndefined(); + await expect(assertArmed([probeOn(inside, insideMember)])).resolves.toBeUndefined(); }); it('a principal that resolves to nothing is DISARMED, not skipped', async () => { const verdict = await principalArmed({ - stack: orgbound, + stack: inside, token: 'not-a-token-8074', who: 'a bogus credential', positions: ['org_member'], @@ -170,27 +184,27 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reads DISARMED by default and ARMED after the patch the fixture uses', async () => { // Read the default FIRST, then arm, then restore — so this case is // atomic and cannot depend on the order vitest runs the file in. - const before = await probe(orgless).read(); + const before = await probe(outside).read(); expect(before.armed, 'the platform default leaves the reuse control off').toBe(false); expect(before.observed).toContain('passwordHistoryCount='); - const auth = await orgless.kernel.getServiceAsync('auth'); + const auth = await outside.kernel.getServiceAsync('auth'); try { auth.applyConfigPatch({ passwordHistoryCount: 3 }); - const after = await probe(orgless).read(); + const after = await probe(outside).read(); expect(after.armed, 'the same seam the settings service writes arms it').toBe(true); expect(after.observed).toContain('passwordHistoryCount=3'); - await expect(assertArmed([probe(orgless)])).resolves.toBeUndefined(); + await expect(assertArmed([probe(outside)])).resolves.toBeUndefined(); } finally { auth.applyConfigPatch({ passwordHistoryCount: undefined }); } - const restored = await probe(orgless).read(); + const restored = await probe(outside).read(); expect(restored.armed, 'and the restore really disarmed it again').toBe(false); }); it('assertArmed REJECTS the default config, naming the 0 default', async () => { - const err = await assertArmed([probe(orgless)]).catch((e: Error) => e); + const err = await assertArmed([probe(outside)]).catch((e: Error) => e); expect(String(err)).toContain(REUSE_CONTROL); expect(String(err)).toContain('defaults to 0'); }); @@ -215,7 +229,7 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( describe('seededArmed', () => { it('[control] finds a set that really seeded — so its negative reading is a real negative', async () => { const verdict = await seededArmed({ - stack: orgless, + stack: outside, object: 'sys_permission_set', where: { name: 'member_default' }, control: 'the platform baseline permission set', @@ -227,7 +241,7 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED for a set that does not exist', async () => { const verdict = await seededArmed({ - stack: orgless, + stack: outside, object: 'sys_permission_set', where: { name: 'no_such_set_8074' }, control: 'an app-declared permission set', @@ -239,7 +253,7 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('reports DISARMED when the row exists but fails its own predicate', async () => { const verdict = await seededArmed({ - stack: orgless, + stack: outside, object: 'sys_permission_set', where: { name: 'member_default' }, armed: () => false, @@ -290,8 +304,8 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('principalArmed with nothing required throws at CONSTRUCTION', () => { expect(() => principalArmed({ - stack: orgless, - token: orglessMember, + stack: outside, + token: outsideMember, who: 'nobody in particular', control: WRITE_FLOOR, disarmedBy: WRITE_FLOOR_DISARM, @@ -302,7 +316,7 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('seededArmed with an empty `where` throws at CONSTRUCTION', () => { expect(() => seededArmed({ - stack: orgless, + stack: outside, object: 'sys_permission_set', where: {}, control: 'anything', @@ -327,15 +341,15 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( it('names EVERY disarmed control, not just the first', async () => { const err = await assertArmed([ principalArmed({ - stack: orgless, - token: orglessMember, + stack: outside, + token: outsideMember, who: 'the plain member', positions: ['org_member'], control: WRITE_FLOOR, disarmedBy: WRITE_FLOOR_DISARM, }), authSettingArmed({ - stack: orgless, + stack: outside, setting: 'passwordHistoryCount', armed: (v) => Number(v) >= 1, control: REUSE_CONTROL, @@ -352,15 +366,15 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( await expect( assertArmed([ principalArmed({ - stack: orgbound, - token: orgboundMember, + stack: inside, + token: insideMember, who: 'the org-bound member', positions: ['org_member'], control: WRITE_FLOOR, disarmedBy: WRITE_FLOOR_DISARM, }), seededArmed({ - stack: orgbound, + stack: inside, object: 'sys_permission_set', where: { name: 'member_default' }, control: 'the platform baseline permission set', diff --git a/packages/qa/dogfood/test/armed.ts b/packages/qa/dogfood/test/armed.ts index 35d9f4a2712..e3c5fcc590c 100644 --- a/packages/qa/dogfood/test/armed.ts +++ b/packages/qa/dogfood/test/armed.ts @@ -224,6 +224,37 @@ export async function resolveAuthzFor(stack: VerifyStack, token: string): Promis }; } +/** + * [ADR-0131 D3] Take a signed-up user OUT of the organization, the way a real + * `single` deployment produces a principal outside the `org_member` domain: an + * administrator removed them. Every `bootStack` boot now has the Default + * Organization and the membership reconciler binds every sign-up to it, so an + * org-less BOOT no longer exists to provide that principal (ADR-0131 D11); a + * user with no `sys_member` row does. + * + * Deletes the user's memberships in system context, then signs in again: the + * fresh session is minted with no membership to read, so it carries no active + * organization. Returns that session's token; the user's earlier token is not + * reused. Throws when a membership survives — a principal still inside the + * domain would make the "outside" class measure the inside one. + */ +export async function leaveOrganization( + stack: VerifyStack, + email: string, + password = 'Member-Pass-123', +): Promise { + const ql = await stack.kernel.getServiceAsync('objectql'); + const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + const user = await ql.findOne('sys_user', { where: { email }, context: SYS }); + if (!user?.id) throw new Error(`leaveOrganization: no sys_user for ${email}`); + for (const m of rowsOf(await ql.find('sys_member', { where: { user_id: user.id }, limit: 20, context: SYS }))) { + await ql.delete('sys_member', { where: { id: m.id }, context: SYS }); + } + const left = rowsOf(await ql.find('sys_member', { where: { user_id: user.id }, limit: 1, context: SYS })); + if (left.length > 0) throw new Error(`leaveOrganization: ${email} still holds a membership`); + return stack.signIn(email, password); +} + /** * A principal is inside the domain the policy under test is gated to. * diff --git a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts index 208d646aa50..99192c21afd 100644 --- a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts @@ -25,10 +25,10 @@ * 3. a plain `member` still cannot invite at all — so it is the ROLE that * opened the endpoint, not some unrelated loosening. * - * Harness note: `bootStack` disables the default-org bootstrap, so this file - * mints the Default Organization itself (system context — exactly what the - * bootstrap would do) and sets membership roles the same way, which is the - * only writer better-auth-managed tables accept (ADR-0092). + * Harness note: `bootStack` boots the production `single` shape, so the + * Default Organization exists from the boot (ADR-0131 D3); this file sets + * membership roles in system context, which is the only writer + * better-auth-managed tables accept (ADR-0092). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -66,16 +66,16 @@ describe('#3697: delegated_admin may invite — as `member` only', () => { adminToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); - const org = await ql.insert( - 'sys_organization', - { name: 'Default Organization', slug: 'default' }, - { context: SYSTEM_CTX }, - ); + // [ADR-0131 D3] The Default Organization is a boot invariant under + // `single`: the boot created it before the seeds loaded. Read it — minting + // a second `slug: 'default'` row is refused as a duplicate. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); - // The dev admin predates the org row, so give them the owner membership the - // single-org bootstrap would have. They are who PROVISIONS a delegate, and - // the reference for "an org owner's behavior is unchanged by any of this". + // The owner bind made the dev admin the Default Organization's owner; this + // re-asserts it. They are who PROVISIONS a delegate, and the reference for + // "an org owner's behavior is unchanged by any of this". const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUser.id }, 5); if (adminMembers.length > 0) { diff --git a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts index 55d4d6efee3..9f09d82c436 100644 --- a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -53,7 +53,10 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { const u = await ql.findOne('sys_user', { where: { email }, context: SYS.context }); return String(u?.id ?? ''); }; - const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: {} }); + // [ADR-0131 D3] Every sign-up is a member of the Default Organization, so the + // delegate's real session carries it as the active organization. + let orgId: string; + const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: { activeOrganizationId: orgId } }); beforeAll(async () => { stack = await bootStack(showcaseStack, { @@ -70,12 +73,20 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { delegatorId = await idOf(DELEGATOR); delegateId = await idOf(DELEGATE); + // [ADR-0131 D3] The boot created the Default Organization and the + // delegator is its member, so the gate reads the delegator's OWN + // organization's position row and assignments (ADR-0091 D3 rule 5): both + // are the Default Organization's rows, as an org-bound deployment holds them. + const org = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS.context }); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); + orgId = String(org.id); + // A delegatable position + a non-delegatable one (system inserts sidestep // authoring rules). The delegator DIRECTLY holds the delegatable one. - await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true }, SYS); - await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true }, SYS); - await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver' }, SYS); - await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty' }, SYS); + await ql.insert('sys_position', { id: 'pos_vac_appr', name: 'vacation_approver', label: 'Vacation Approver', delegatable: true, active: true, organization_id: orgId }, SYS); + await ql.insert('sys_position', { id: 'pos_locked', name: 'locked_duty', label: 'Locked Duty', delegatable: false, active: true, organization_id: orgId }, SYS); + await ql.insert('sys_user_position', { id: 'hold_boss', user_id: delegatorId, position: 'vacation_approver', organization_id: orgId }, SYS); + await ql.insert('sys_user_position', { id: 'hold_boss_locked', user_id: delegatorId, position: 'locked_duty', organization_id: orgId }, SYS); }, 90_000); afterAll(async () => { await stack?.stop(); }); diff --git a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts index c1f3210dbe6..887b68c484a 100644 --- a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts @@ -59,10 +59,11 @@ * Layer 0 / Layer 1 composition and the REST layer. So every assertion below * reads a real `GET /api/v1/data/sys_invitation` response body. * - * Harness note: `bootStack` disables the default-org bootstrap, so this file - * mints the organization itself and sets membership roles through the system - * context — the only writer better-auth-managed tables accept (ADR-0092) and - * exactly what the single-org bootstrap would do. The two invitation rows are + * Harness note: `bootStack` boots the production `single` shape, so the + * organization is the Default Organization the boot creates (ADR-0131 D3) and + * every sign-up is its member; this file sets membership roles through the + * system context — the only writer better-auth-managed tables accept + * (ADR-0092). The two invitation rows are * created through the REAL `invite-member` endpoint, so they carry whatever * better-auth actually writes rather than a hand-built approximation. Sessions * are stamped with the active organization the way a real org switch does @@ -169,16 +170,16 @@ describe('#8095/#8240: the sys_invitation ledger, read by four personas', () => const adminToken = await stack.signIn(); // the seeded dev admin (platform admin) ql = await stack.kernel.getServiceAsync('objectql'); - const org = await ql.insert( - 'sys_organization', - { name: 'Acme Test Org', slug: 'acme-8095' }, - { context: SYSTEM_CTX }, - ); + // [ADR-0131 D3] Under `single` the one organization is the Default + // Organization the boot created, and the membership reconciler binds every + // sign-up below to it — so it is the organization this ledger is about. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); - // The seeded dev admin predates the org row; give it the owner membership - // the single-org bootstrap would have, so `invite-member` has an authorized - // caller for the fixture rows. + // The owner bind made the seeded dev admin the organization's owner; this + // re-asserts that membership, so `invite-member` has an authorized caller + // for the fixture rows. const adminUserId = await userIdOf(ql, 'admin@objectos.ai'); const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); if (adminMembers.length > 0) { diff --git a/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts b/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts index a2cdb4c22a9..cac72c2d4d1 100644 --- a/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts @@ -27,10 +27,10 @@ * caller better-auth does not authorize, no matter who the write would be * credited to. * - * Harness note: `bootStack` disables the default-org bootstrap and installs no - * audit plugin, so this file mints the Default Organization itself (system - * context — exactly what the bootstrap would do) and adds `AuditPlugin`, which - * is what turns `trackHistory` into rows. + * Harness note: `bootStack` boots the production `single` shape, so the + * Default Organization exists from the boot (ADR-0131 D3); it installs no audit + * plugin, so this file adds `AuditPlugin`, which is what turns `trackHistory` + * into rows. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -118,11 +118,11 @@ describe('#4586: the better-auth actor reaches sys_member history and the grant' adminToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); - const org = await ql.insert( - 'sys_organization', - { name: 'Default Organization', slug: 'default' }, - { context: SYSTEM_CTX }, - ); + // [ADR-0131 D3] The Default Organization is a boot invariant under + // `single`: the boot created it before the seeds loaded. Read it — minting + // a second `slug: 'default'` row is refused as a duplicate. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); // A second org, so invite-accept has somewhere to add a membership that the @@ -134,8 +134,8 @@ describe('#4586: the better-auth actor reaches sys_member history and the grant' ); partnerOrgId = String(partner.id); - // The dev admin predates the org rows — give them the owner membership the - // single-org bootstrap would have, in both orgs. + // The owner bind made the dev admin the Default Organization's owner; this + // re-asserts it and adds the partner org's owner membership. const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); adminUserId = String(adminUser.id); const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); diff --git a/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts index 83c6f45f27c..149c161a5e5 100644 --- a/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts @@ -58,14 +58,18 @@ describe('#15784: a membership that ends takes the session\'s claim on that orga adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const org = await ql.insert('sys_organization', { name: 'Default Organization', slug: 'default' }, { context: SYSTEM_CTX }); + // [ADR-0131 D3] The Default Organization is a boot invariant under + // `single`: the boot created it before the seeds loaded. Read it — minting + // a second `slug: 'default'` row is refused as a duplicate. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); const partner = await ql.insert('sys_organization', { name: 'Partner Organization', slug: 'partner' }, { context: SYSTEM_CTX }); partnerOrgId = String(partner.id); - // The dev admin predates the org rows — give them the owner membership the - // single-org bootstrap would have, in both orgs, so remove-member is - // authorized in either. + // The owner bind made the dev admin the Default Organization's owner; this + // re-asserts it and adds the partner org's owner membership, so remove-member + // is authorized in either. const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); const adminUserId = String(adminUser.id); const adminMembers = await findRows(ql, 'sys_member', { user_id: adminUserId }, 5); diff --git a/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts b/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts index df2c56c29e5..aecc3264241 100644 --- a/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts @@ -9,12 +9,10 @@ * ADR-0093 D2 gives the invariant ONE owner — a reconciler composed into * better-auth's `user.create.after` hook. * - * Harness note: `bootStack` deliberately disables the default-org bootstrap - * (`autoDefaultOrganization: false` — the ADR-0057 "single-tenant, no org row" - * posture) and does not enable the better-auth admin plugin. So these tests - * mint the single-org Default Organization themselves (system context, exactly - * what the bootstrap would do) and drive the invariant through the REAL - * better-auth sign-up pipeline — the path with no endpoint-side bind, where a + * Harness note: `bootStack` boots the production `single` shape, so the + * Default Organization exists from the boot (ADR-0131 D3); it does not enable + * the better-auth admin plugin. So these tests read that organization and + * drive the invariant through the REAL better-auth sign-up pipeline — the path with no endpoint-side bind, where a * membership can only come from the `user.create.after` reconciler. The * endpoint-side create-user bind has its own unit coverage * (plugin-auth/src/admin-user-endpoints.test.ts). @@ -41,14 +39,11 @@ describe('ADR-0093: membership lifecycle (single-org, real stack)', () => { stack = await bootStack(showcaseStack, {}); // single-org (no OS_MULTI_ORG_ENABLED) await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - // Mint the Default Organization the single-org bootstrap would create - // (the harness disables that bootstrap — see header). System context is - // the legitimate writer for better-auth-managed tables (ADR-0092). - const org = await ql.insert( - 'sys_organization', - { name: 'Default Organization', slug: 'default' }, - { context: SYSTEM_CTX }, - ); + // [ADR-0131 D3] The Default Organization is a boot invariant under + // `single`: the boot created it before the seeds loaded. Read it — minting + // a second `slug: 'default'` row is refused as a duplicate. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); defaultOrgId = String(org.id); }, 120_000); diff --git a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts index 1882ff35794..00568724035 100644 --- a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts @@ -74,14 +74,14 @@ describe('ADR-0108: the membership-role vocabulary is closed; capability goes th ownerToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); - // `bootStack` disables the default-org bootstrap, so mint the org the way - // the bootstrap would (system context — the only writer better-auth-managed - // tables accept, ADR-0092) and bind the dev admin as its owner. - const org = await ql.insert( - 'sys_organization', - { name: 'Default Organization', slug: 'default' }, - { context: SYSTEM_CTX }, - ); + // [ADR-0131 D3] The Default Organization is a boot invariant under + // `single`: the boot created it before the seeds loaded. Read it — minting + // a second `slug: 'default'` row is refused as a duplicate. + // The owner bind made the dev admin its owner; the block below re-asserts + // that membership (system context — the only writer better-auth-managed + // tables accept, ADR-0092), so the fixture does not lean on the bind. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); const [adminUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); diff --git a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts index 9df2b5d9ec4..489bd38e0b8 100644 --- a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -38,10 +38,11 @@ * `false`. A handful of door probes then show the verdicts are the door's own: * a hidden affordance is one the server refuses, a shown one is one it admits. * - * Harness note: `bootStack` disables the default-org bootstrap, so this file - * mints the organization and sets membership roles in system context — the - * shape `delegated-admin-invite.dogfood.test.ts` uses, and the only writer - * better-auth-managed tables accept (ADR-0092). + * Harness note: `bootStack` boots the production `single` shape, so the + * organization is the Default Organization the boot creates (ADR-0131 D3) and + * every sign-up is its member; this file sets membership roles in system + * context — the shape `delegated-admin-invite.dogfood.test.ts` uses, and the + * only writer better-auth-managed tables accept (ADR-0092). * * ## The one affordance no grade reaches: "Add Member" * @@ -144,7 +145,11 @@ describe('org-admin affordances follow the membership grade (served metadata × tokens.owner = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); - const org = await ql.insert('sys_organization', { name: 'Reach Org', slug: 'reach-org' }, { context: SYSTEM_CTX }); + // [ADR-0131 D3] The one organization of a `single` deployment is the + // Default Organization the boot created; the reconciler binds every + // sign-up below to it, so it is the organization the grades are measured in. + const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); orgId = String(org.id); const [ownerUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); diff --git a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts index a9442f45913..4573a768c98 100644 --- a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts @@ -35,9 +35,14 @@ * - the field that probe named is the platform's injected anchor * (`resolveInjectedColumnProvenance` answers `injected-unprovisioned`). * - * Booted with `orgContext`, the harness's switch for the real single-org - * default-organization bootstrap: it creates the organization and seats the - * admin as its owner, which is what lets better-auth delete it. + * Booted with `orgContext`, which asserts the admin is seated in the Default + * Organization. The organization deleted is a SECOND one the admin owns, not + * the Default Organization: under `single` the Default Organization owns every + * row the deployment wrote, the showcase's seed included (ADR-0131 D3), so + * deleting it runs those rows' own delete behaviour — a different question + * from the federated anchor this file pins. The cascade scan probes every + * reference to `sys_organization` on ANY organization's delete, so the + * second organization reaches the same scan with nothing else attached. * * The working directory is a temporary one. The showcase's external datasource * and its fixture both name a cwd-relative SQLite file, so this file's remote @@ -85,10 +90,14 @@ describe('[#21910] an organization delete with the showcase federated fixture pr token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); - const [org] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); - expect(org, 'PREMISE: the bootstrap created the default organization').toBeTruthy(); + const [defaultOrg] = await findRows(ql, 'sys_organization', { slug: 'default' }, 1); + expect(defaultOrg, 'PREMISE: the boot created the Default Organization').toBeTruthy(); + // The organization this file deletes: a second one, owned by the admin, + // that no row of the deployment belongs to (see the header). + const org = await ql.insert('sys_organization', { name: 'Org 21910', slug: 'org-21910' }, { context: SYSTEM_CTX }); orgId = String(org.id); const [admin] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + await ql.insert('sys_member', { user_id: String(admin?.id), organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); const seats = await findRows(ql, 'sys_member', { user_id: String(admin?.id), organization_id: orgId }, 5); expect(seats.map((m) => m.role), 'PREMISE: the admin owns the organization').toEqual(['owner']); }, 240_000); diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index 3d7d7219a38..bdcd2287a7b 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -62,7 +62,7 @@ import { attachmentManagerSet, } from './fixtures/attachments-fixture.js'; import { CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; -import { armedWhen, assertArmed, principalArmed, resolveAuthzFor } from './armed.js'; +import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; @@ -102,10 +102,16 @@ interface Booted { token: string; } -async function boot(orgContext: boolean, email: string): Promise { +/** + * Boot the stack and sign `email` up. [ADR-0131 D3] Every boot is the + * production `single` shape, so the sign-up is a member of the Default + * Organization; the OUTSIDE class is that user removed from it (an + * administrator's act), not an org-less boot, which no longer exists. + */ +async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'parent-refusal-')); const stack = await bootStack(stackDefinition as never, { - orgContext, + orgContext: inside, security: security(), extraPlugins: [ new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), @@ -113,7 +119,7 @@ async function boot(orgContext: boolean, email: string): Promise { ], }); await stack.signIn(); - const token = await stack.signUp(email); + let token = await stack.signUp(email); const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; @@ -122,6 +128,7 @@ async function boot(orgContext: boolean, email: string): Promise { expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: set.id }, { context: { ...SYS } }); } + if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } @@ -191,7 +198,7 @@ describe('parent-derived write refusal on an unreadable parent names nothing', ( "a principal OUTSIDE the ownership floor's domain — the class the by-id write pre-image check does not " + 'bind, so the attachment and comment gates answer it themselves', disarmedBy: - 'an org-bound boot of this half: the principal then holds org_member, the pre-image check refuses ' + + 'a principal still bound to the organization: it then holds org_member, the pre-image check refuses ' + 'first, and the outside-the-domain pins pass without either gate being asked', observe: () => resolveAuthzFor(outside.stack, outside.token), armed: (ctx) => !ctx.positions.includes('org_member') && GRANTS.every((g) => ctx.permissions.includes(g)), @@ -205,7 +212,7 @@ describe('parent-derived write refusal on an unreadable parent names nothing', ( permissions: GRANTS, control: "the by-id write pre-image check's not-visible refusal — the reference answer pin 1 compares with", disarmedBy: - 'an org-less boot of the reference half: no org_member, so the "reference" is the gate answering ' + + 'a reference principal outside the organization: no org_member, so the "reference" is the gate answering ' + 'itself and pin 1 compares a refusal with its own copy', }), ]); diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index eb8936e3f09..0063fd3c199 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -56,7 +56,7 @@ import { attachmentManagerSet, } from './fixtures/attachments-fixture.js'; import { CmtOpen, CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; -import { armedWhen, assertArmed, principalArmed, resolveAuthzFor } from './armed.js'; +import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; @@ -121,10 +121,16 @@ interface Booted { token: string; } -async function boot(orgContext: boolean, email: string): Promise { +/** + * Boot the stack and sign `email` up. [ADR-0131 D3] Every boot is the + * production `single` shape, so the sign-up is a member of the Default + * Organization; the OUTSIDE class is that user removed from it (an + * administrator's act), not an org-less boot, which no longer exists. + */ +async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'predicate-write-nm-')); const stack = await bootStack(stackDefinition as never, { - orgContext, + orgContext: inside, security: security(), extraPlugins: [ new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), @@ -132,7 +138,7 @@ async function boot(orgContext: boolean, email: string): Promise { ], }); await stack.signIn(); - const token = await stack.signUp(email); + let token = await stack.signUp(email); const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; @@ -141,6 +147,7 @@ async function boot(orgContext: boolean, email: string): Promise { expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: set.id }, { context: { ...SYS } }); } + if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, userId, token }; } @@ -323,7 +330,7 @@ describe('predicate write door: a row the caller cannot read is not matched', () await assertArmed([ armedWhen({ control: "a principal OUTSIDE the ownership floor's domain — no write-class row filter of the platform binds it", - disarmedBy: 'an org-bound boot of this half: the principal then holds org_member and the two classes collapse into one', + disarmedBy: 'a principal still bound to the organization: it then holds org_member and the two classes collapse into one', observe: () => resolveAuthzFor(outside.stack, outside.token), armed: (ctx) => !ctx.positions.includes('org_member') && GRANTS.every((g) => ctx.permissions.includes(g)), describe: (ctx) => `positions=${JSON.stringify(ctx.positions)} permissions=${JSON.stringify(ctx.permissions)}`, @@ -335,7 +342,7 @@ describe('predicate write door: a row the caller cannot read is not matched', () positions: ['org_member'], permissions: GRANTS, control: "a principal INSIDE the ownership floor's domain — the platform's write-class row filter binds it", - disarmedBy: 'an org-less boot of this half: no org_member, and the inside class measures the outside one twice', + disarmedBy: 'a principal outside the organization in this half: no org_member, and the inside class measures the outside one twice', }), ]); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index abefc23c939..c81e5c26b3b 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -63,6 +63,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { leaveOrganization } from './armed.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -75,6 +76,7 @@ const PASSWORD = 'Member-Pass-123'; const ORG_LESS_EMAIL = 'orgless-8158@verify.test'; const ORG_BOUND_EMAIL = 'orgbound-8158@verify.test'; const ORG_SCOPED_ORG_LESS_EMAIL = 'orgscoped-orgless-8158@verify.test'; +const OPERATOR_EMAIL = 'operator-8158@verify.test'; interface RuleRow { id: string; @@ -86,7 +88,12 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r let stack: VerifyStack; // eslint-disable-next-line @typescript-eslint/no-explicit-any let ql: any; - /** The harness admin: platform authority, and (by harness design) org-less. */ + /** + * A platform operator with no active organization: `admin_full_access` held + * globally, and no membership. [ADR-0131 D3] The harness admin can no longer + * play this part — every `single` boot makes them the Default Organization's + * owner, so their session carries it. + */ let platform: string; /** The exposed persona: GLOBAL `manage_sharing`, no membership, no active org. */ let orgLess: string; @@ -117,7 +124,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r beforeAll(async () => { stack = await bootStack(showcaseStack); - platform = await stack.signIn(); + await stack.signIn(); // the first user: the bootstrap account ql = await stack.kernel.getServiceAsync('objectql'); // Two tenants — the anti-vacuity premise. @@ -162,18 +169,38 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r user_id: userId, permission_set_id: psId, organization_id: ORG_A, }, { context: SYS }); } - // …and a membership for ONE of them. That single row is the whole - // difference between the two personas: `session.create.before` resolves it - // and stamps `activeOrganizationId`. + // [ADR-0131 D3] The membership reconciler bound every sign-up above to the + // Default Organization. The control persona keeps exactly ONE membership, + // in tenant A, so its own is removed first… + const ownMembers = await ql.find('sys_member', { where: { user_id: orgBoundUserId }, context: SYS }); + for (const m of Array.isArray(ownMembers) ? ownMembers : ownMembers?.records ?? []) { + await ql.delete('sys_member', { where: { id: m.id }, context: SYS }); + } + // …and given a membership in tenant A. That single row is the whole + // difference between the personas: `session.create.before` resolves it and + // stamps `activeOrganizationId`. await ql.insert('sys_member', { id: 'mem_8158_bound', organization_id: ORG_A, user_id: orgBoundUserId, role: 'member', }, { context: SYS }); - // Sign in AFTER the grants, so both sessions are minted by the same path - // with the membership state above already in place. - orgLess = await stack.signIn(ORG_LESS_EMAIL, PASSWORD); + // A platform operator outside the organization: `admin_full_access` held + // globally, the grant a platform administrator holds under `single`. + await stack.signUp(OPERATOR_EMAIL, PASSWORD); + const adminSet = await ql.findOne('sys_permission_set', { where: { name: 'admin_full_access' }, context: SYS }); + expect(adminSet?.id, 'admin_full_access is seeded').toBeTruthy(); + await ql.insert('sys_user_permission_set', { + user_id: await uid(OPERATOR_EMAIL), permission_set_id: adminSet.id, organization_id: null, + }, { context: SYS }); + + // Sign in AFTER the grants, so every session is minted by the same path + // with the membership state above already in place. The org-less personas + // and the operator are removed from the Default Organization first — a user + // removed from their organization, one of the ordinary shapes the header + // names — so their sessions carry no active organization. + orgLess = await leaveOrganization(stack, ORG_LESS_EMAIL, PASSWORD); orgBound = await stack.signIn(ORG_BOUND_EMAIL, PASSWORD); - orgScopedOrgLess = await stack.signIn(ORG_SCOPED_ORG_LESS_EMAIL, PASSWORD); + orgScopedOrgLess = await leaveOrganization(stack, ORG_SCOPED_ORG_LESS_EMAIL, PASSWORD); + platform = await leaveOrganization(stack, OPERATOR_EMAIL, PASSWORD); }, 180_000); afterAll(async () => { @@ -200,8 +227,12 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r const sessions = await ql.find('sys_session', { where: { user_id: orgLessUserId }, context: SYS }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; - expect(rows.length, 'the sign-in really did mint a session row').toBeGreaterThan(0); + const all: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; + // [ADR-0131 D3] The sign-up session was minted while the reconciler's + // membership held, and leaving the organization revoked it (#15784); the + // LIVE session is the one the persona signed in with afterwards. + const rows = all.filter((s) => !(s.revoked_at ?? s.revokedAt)); + expect(rows.length, 'the sign-in really did mint a live session row').toBeGreaterThan(0); for (const s of rows) { expect( s.active_organization_id ?? s.activeOrganizationId ?? null, @@ -309,8 +340,10 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r expect(Array.isArray(members) ? members : members?.records ?? []).toHaveLength(0); const sessions = await ql.find('sys_session', { where: { user_id: orgScopedOrgLessUserId }, context: SYS }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rows: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; - expect(rows.length, 'the sign-in really did mint a session row').toBeGreaterThan(0); + const all: any[] = Array.isArray(sessions) ? sessions : sessions?.records ?? []; + // The live session only — the sign-up one was revoked on leaving (see above). + const rows = all.filter((s) => !(s.revoked_at ?? s.revokedAt)); + expect(rows.length, 'the sign-in really did mint a live session row').toBeGreaterThan(0); for (const s of rows) { expect(s.active_organization_id ?? s.activeOrganizationId ?? null).toBeFalsy(); } diff --git a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts index dd7e06cfcce..49bfed71627 100644 --- a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts +++ b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts @@ -6,7 +6,8 @@ * Single-tenant deployments have no auto-stamp (OrgScopingPlugin is * multi-tenant-only), so a `required` `organization_id` made * sys_business_unit / sys_team uncreatable (VALIDATION_FAILED). The field is - * now optional; this proves the create path works single-tenant. + * now optional; this proves the create path works single-tenant. Since + * ADR-0131 D3 the row is owned by the Default Organization the boot creates. * * ADR-0092 update: `sys_team` is `managedBy: 'better-auth'`, so its generic * data-API insert is now REJECTED fail-closed for user contexts. As of #1591 @@ -30,17 +31,26 @@ describe('ADR-0057: org-scoped identity creatable single-tenant', () => { let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); // single-tenant: no org-scoping, no org row + stack = await bootStack(showcaseStack, {}); // single-tenant: no org-scoping; the Default Organization exists from the boot token = await stack.signIn(); }, 120_000); afterAll(async () => { await stack?.stop?.(); }); - it('creates a sys_business_unit with no organization_id', async () => { + it('creates a sys_business_unit, owned by the Default Organization', async () => { + // ADR-0057's property — creatable single-tenant, no VALIDATION_FAILED — + // still holds. [ADR-0131 D3 / D11] What changed is who owns the row: under + // `single` the Default Organization exists from the boot and the admin's + // session carries it, so the business unit is that organization's, never + // organization-less. const res = await stack.apiAs(token, 'POST', '/data/sys_business_unit', { name: 'Engineering', kind: 'department' }); expect(res.status).toBe(201); const body: any = await res.json(); - expect(body.record?.organization_id ?? null).toBeNull(); + const ql = await stack.kernel.getServiceAsync('objectql'); + const orgs = await ql.find('sys_organization', { where: { slug: 'default' }, limit: 1, context: { isSystem: true } }); + const org = (Array.isArray(orgs) ? orgs : orgs?.records ?? [])[0]; + expect(org?.id, 'the boot created the Default Organization').toBeTruthy(); + expect(body.record?.organization_id).toBe(org.id); }); it('sys_team: generic insert is guarded for users; org_id stays optional for system writes', async () => { diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index e5a83c3dcf8..f7c4dd71e9d 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -51,7 +51,7 @@ import { attachmentManagerSet, } from './fixtures/attachments-fixture.js'; import { CmtOpen, CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; -import { armedWhen, assertArmed, principalArmed, resolveAuthzFor } from './armed.js'; +import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; @@ -90,10 +90,16 @@ interface Booted { token: string; } -async function boot(orgContext: boolean, email: string): Promise { +/** + * Boot the stack and sign `email` up. [ADR-0131 D3] Every boot is the + * production `single` shape, so the sign-up is a member of the Default + * Organization; the OUTSIDE class is that user removed from it (an + * administrator's act), not an org-less boot, which no longer exists. + */ +async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'write-door-nf-')); const stack = await bootStack(stackDefinition as never, { - orgContext, + orgContext: inside, security: security(), extraPlugins: [ new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), @@ -101,7 +107,7 @@ async function boot(orgContext: boolean, email: string): Promise { ], }); await stack.signIn(); - const token = await stack.signUp(email); + let token = await stack.signUp(email); const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; @@ -110,6 +116,7 @@ async function boot(orgContext: boolean, email: string): Promise { expect(set?.id, `fixture permission set ${name} seeded`).toBeTruthy(); await ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: set.id }, { context: { ...SYS } }); } + if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } @@ -243,7 +250,7 @@ describe('write door: a row the caller cannot read answers what a missing id ans await assertArmed([ armedWhen({ control: "a principal OUTSIDE the ownership floor's domain — no write-class row filter binds it", - disarmedBy: 'an org-bound boot of this half: the principal then holds org_member and the two classes collapse into one', + disarmedBy: 'a principal still bound to the organization: it then holds org_member and the two classes collapse into one', observe: () => resolveAuthzFor(outside.stack, outside.token), armed: (ctx) => !ctx.positions.includes('org_member') && GRANTS.every((g) => ctx.permissions.includes(g)), describe: (ctx) => `positions=${JSON.stringify(ctx.positions)} permissions=${JSON.stringify(ctx.permissions)}`, @@ -255,7 +262,7 @@ describe('write door: a row the caller cannot read answers what a missing id ans positions: ['org_member'], permissions: GRANTS, control: "a principal INSIDE the ownership floor's domain — the write-class row filter binds it", - disarmedBy: 'an org-less boot of this half: no org_member, and the inside class measures the outside one twice', + disarmedBy: 'a principal outside the organization in this half: no org_member, and the inside class measures the outside one twice', }), ]); From 98a89bc5604aa5b4f9b75544fbc9226cd2fc5bd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 07:09:17 +0000 Subject: [PATCH 14/18] wip(changesets,dogfood): the verify changeset; the memory-driver consequence until C8; two harness comments corrected Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...uth-default-organization-boot-invariant.md | 2 +- ...5195-verify-bootstack-production-single.md | 17 ++++++++++++++ ...wcase-external-autoconnect.dogfood.test.ts | 23 +++++++++++-------- .../test/showcase-scope-depth.dogfood.test.ts | 2 +- 4 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 .changeset/15195-verify-bootstack-production-single.md diff --git a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md index cbaea63fe48..8e1cf6c0474 100644 --- a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md +++ b/.changeset/15195-plugin-auth-default-organization-boot-invariant.md @@ -15,6 +15,6 @@ Clause-②: yes (narrowing) - **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. - **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`. - **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched. -- **The in-memory driver.** `@objectstack/driver-memory` refuses any read the engine hands a tenant scope, and every session now carries the Default Organization, so a `single` deployment on that driver answers a signed-in user's data reads `503`. The platform admin met the same refusal before this change, once the Default Organization existed; a SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment. +- **The in-memory driver answers `503` until C8.** `@objectstack/driver-memory` refuses tenant-scoped reads, and every session now carries the Default Organization, so under `single` its signed-in reads answer `503` until ADR-0131 C8 (#15212, D8) gives `single` no read predicate. The platform admin met the same refusal before this change, once the Default Organization existed. A SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment meanwhile. **The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. diff --git a/.changeset/15195-verify-bootstack-production-single.md b/.changeset/15195-verify-bootstack-production-single.md new file mode 100644 index 00000000000..661a40f359c --- /dev/null +++ b/.changeset/15195-verify-bootstack-production-single.md @@ -0,0 +1,17 @@ +--- +'@objectstack/verify': minor +--- + +`bootStack` boots the production `single` shape: the Default Organization exists from the boot, every sign-up is its member, and the harness admin is its owner + +Clause-②: yes (narrowing) + + + +**BREAKING** for fixtures that relied on an organization-less `single` boot, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D11). + +- **What `bootStack` boots now.** Under `single`, `@objectstack/plugin-auth` creates the Default Organization before the seeds load, so every boot has one, and `bootStack` no longer turns off the platform admin's owner bind. The harness admin is the Default Organization's `owner`, every user a fixture signs up is its `member`, and their sessions carry it as the active organization. This is the shape `objectstack dev` and `objectstack serve` boot. Before, `bootStack` pinned `autoDefaultOrganization: false` and booted a `single` stack with no organization at all, a shape no deployment runs after this release. +- **`orgContext` asserts, it no longer switches.** `orgContext: true` keeps its refusal: the boot fails when the harness admin holds no membership, and it still refuses to compose with `multiTenant`. The bind itself happens on every boot. +- **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it. + +**The remedy.** A fixture that needs a principal outside the organization's `org_member` domain removes that user's membership (an administrator's act a real deployment performs) instead of booting without an organization. A fixture that minted its own `slug: 'default'` organization reads the one the boot created. A fixture that ran on `databaseDriver: 'memory'` and signs a user in meets the memory driver's tenant-scope refusal (`503`) until ADR-0131 C8; run that leg on the SQL in-memory driver. diff --git a/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts b/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts index cf4f0aadc3c..4ec39ddc42d 100644 --- a/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts @@ -49,16 +49,19 @@ describe('showcase: external datasource auto-connects with no onEnable bridge (A // What it does NOT cover: the organization wall. `bootStack(showcaseStack)` // above passes NO options, and `bootStack` requests // `OS_TENANCY_POSTURE = 'isolated'` only when `opts.multiTenant` is truthy - // (`packages/verify/src/harness.ts`, `requestIsolatedPosture`). It also - // boots AuthPlugin with `autoDefaultOrganization: false` and registers no - // organization plugin. So this fixture boots posture `single` — the boot - // log says so: `[security] tenancy posture 'single' — Layer 0 is inert` — - // with no active org. `execCtx.tenantId` is therefore undefined, which is - // the FIRST conjunct of `hasTenant` in `ObjectQLEngine.buildDriverOptions` - // (`packages/objectql/src/engine.ts`); it short-circuits false before the - // `isFederated` exemption is even reached, and the driver is handed no - // `tenantId` to scope by. **The org predicate is never emitted here, in - // either direction.** Measured on #7738: `engine.ts` was checked back out + // (`packages/verify/src/harness.ts`, `requestIsolatedPosture`), and it + // registers no organization plugin. So this fixture boots posture `single` + // — the boot log says so: `[security] tenancy posture 'single' — Layer 0 + // is inert` — and no organization wall is in force. [ADR-0131 D3] Since + // every `single` boot binds the admin to the Default Organization, the + // session carries an active org, `execCtx.tenantId` is defined, and + // `ObjectQLEngine.buildDriverOptions` (`packages/objectql/src/engine.ts`) + // reaches its `isFederated` exemption, which withholds `tenantId` from the + // federated object's driver. When #7738 was measured the harness booted + // org-less: `tenantId` was undefined, the FIRST conjunct of `hasTenant` + // short-circuited before that exemption, and **the org predicate was never + // emitted, in either direction.** Measured on #7738 (that org-less boot): + // `engine.ts` was checked back out // at pre-fix `main`, `@objectstack/objectql` rebuilt, and this file re-run // — 3 passed / 3 both WITH the fix and WITHOUT it. This assertion sat green // while a correctly-bound federated object answered 0 rows under a real diff --git a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts index 13f16ba41b3..4cf33775234 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -78,7 +78,7 @@ async function bootScopeWorld(scope: 'unit' | 'unit_and_below' | 'own_and_report // proofs blind to the contradiction they were supposed to cover: a resolver // obeying the old unconditional "null org ⇒ fail closed" obligation would // have returned owner-only on every single one of these calls (the showcase - // stack boots org-less), and this suite would still have been green. It now + // stack booted org-less then), and this suite would still have been green. It now // implements the posture-conditional rule verbatim, so the guarantee that // enterprise DEPTH works under `single` is actually pinned by the assertions // below rather than assumed. From 05dedeea79cf82ad84b02ee231021101891d15ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:36:26 +0000 Subject: [PATCH 15/18] =?UTF-8?q?test(verify):=20the=20default-boot=20case?= =?UTF-8?q?=20pins=20the=20owner=20bind=20=E2=80=94=20the=20admin=20owns?= =?UTF-8?q?=20the=20Default=20Organization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse verification of the harness line (the old `autoDefaultOrganization: !!opts.orgContext` pin restored) left this case green: the boot invariant creates the organization and the membership reconciler binds the admin either way, so the two assertions it held never read the line. The admin's membership role does: `owner` under the production shape, `member` under the old pin. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/verify/src/harness.org-context.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/verify/src/harness.org-context.test.ts b/packages/verify/src/harness.org-context.test.ts index ae749e7b0f5..252d51bb8ba 100644 --- a/packages/verify/src/harness.org-context.test.ts +++ b/packages/verify/src/harness.org-context.test.ts @@ -172,6 +172,22 @@ describe('bootStack orgContext (#7762)', () => { const orgs = rowsOf(await ql.find('sys_organization', { limit: 10, context: SYS })); expect(orgs.map((o) => o.slug)).toEqual(['default']); expect(await sessionOrgId(stack)).toBe(orgs[0].id); + // The harness line this case pins is the owner bind (ADR-0093 D7), on + // for every boot: the admin OWNS the Default Organization. The two + // assertions above hold without it (the boot invariant creates the + // organization and the membership reconciler binds the admin), but as + // `member` — which is what an `objectstack dev` admin never is. + const users = rowsOf( + await ql.find('sys_user', { where: { email: 'admin@objectos.ai' }, limit: 1, context: SYS }), + ); + const memberships = rowsOf( + await ql.find('sys_member', { + where: { user_id: users[0]?.id, organization_id: orgs[0].id }, + limit: 10, + context: SYS, + }), + ); + expect(memberships.map((m) => m.role)).toEqual(['owner']); } finally { await stack.stop(); } From 9aee4d05f13e612be5acf024dd0fbe1285704313 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:47:56 +0000 Subject: [PATCH 16/18] docs(audits): the tenant-audit census regenerated after the second main merge, with the two system writes C1 adds (234 to 236) Generated tables written by `node scripts/tenant-audit-census.mjs --write` on the committed merge; the hand-written prose figures the gate holds to the census moved with them (erased receivers 48 to 50, decidable 154 to 156, elevated 123 to 125, population 234 to 236). Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 36 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 18 +++++----- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 740fa04e643..b0925093257 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 48 of them — just over a fifth +typed `any` have no type to read, and there are 50 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **52 of the 234 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 236 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **234** | +| 175 write call sites | quoted in the merged changeset | **236** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 234** decidable, **80** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 103 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **156 of 236** decidable, **80** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 103 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 48 sites reached +one, and both are counted in the generated tables below: the 50 sites reached through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -123 of 234 (53%) as decidably elevated, with 103 more whose elevation is a +125 of 236 (53%) as decidably elevated, with 103 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 234`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 236`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **31 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **234** | -| …whose object name is statically decidable | 154 | +| write call sites on the application surface | **236** | +| …whose object name is statically decidable | 156 | | …whose object name is chosen at run time | 80 | -| …against an object with tenancy ENABLED | 153 | +| …against an object with tenancy ENABLED | 155 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 174 | +| threading a tenant context | 176 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 52 | | …of those, against a decidably tenancy-enabled object | 31 | -| threading a decidably ELEVATED (`isSystem`) context | 123 | +| threading a decidably ELEVATED (`isSystem`) context | 125 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 103 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 186 | -| receiver erased, placed by the object NAME | 28 | +| receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 104 | +| object name spelled inline | 106 | | object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 19 | | object name is some other run-time expression | 61 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `0328884e5`. +Measured on 2026-10-08 at `4b40ca31f`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 617 | +| tracked non-test sources scanned | 618 | | engine-shaped types recognised | 70 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 161 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 4589c664314..129ff421142 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 234 | -| Object name statically decidable | 154 | +| Write call sites | 236 | +| Object name statically decidable | 156 | | Object name chosen at run time | 80 | -| Against a tenancy-enabled object | 153 | +| Against a tenancy-enabled object | 155 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 174 | +| Threading a tenant context | 176 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 52 | | …and decidably tenancy-enabled | 31 | -| Threading a decidably elevated context | 123 | +| Threading a decidably elevated context | 125 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 103 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `0328884e5`. +Measured on 2026-10-08 at `4b40ca31f`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 617 | +| tracked non-test sources scanned | 618 | | engine-shaped types recognised | 70 | | declared objects in the registry | 117 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 161 | ## Every site @@ -133,7 +133,9 @@ Measured on 2026-10-08 at `0328884e5`. | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/default-organization-invariant.ts` | `insert` | `sys_organization` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `update` | `sys_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-backfill-ledger.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 | From b7d0b3469e9b83a661e2b34198e0167b9066d86a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:56:50 +0000 Subject: [PATCH 17/18] =?UTF-8?q?test(plugin-approvals,plugin-security):?= =?UTF-8?q?=20two=20engine=20rigs=20boot=20the=20production=20single=20sha?= =?UTF-8?q?pe=20=E2=80=94=20the=20Default=20Organization=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rigs register `sys_organization` and then system-insert a tenant-scoped row on an install that holds no organization: the shape ADR-0131 C1 refuses (D9, `no-organization`) and no production `single` boot reaches, because the auth plugin's boot invariant creates the Default Organization first (D3). - plugin-approvals `status-mirror-cascade.integration.test.ts`: provisions `sys_organization` and seeds the Default Organization before the first insert; `sys_organization` leaves the expected-absent probe list, by that channel's own contract (a table that started resolving is provisioned now). - plugin-security `claim-seed-ownership-warm-boot.test.ts`: every boot finds or creates the Default Organization, as the invariant does on every boot. The subjects (the approval cascade's identity, the warm-boot seed claim) are unchanged; the fixtures changed. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../status-mirror-cascade.integration.test.ts | 62 +++++++++++++------ .../claim-seed-ownership-warm-boot.test.ts | 17 +++++ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts index a3bf8ac1ffe..16811ba3b5a 100644 --- a/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts @@ -64,11 +64,19 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * ONLY read refusals whose log frames may be withheld here. * * `beforeEach` boots a kernel with no datasource and attaches sqlite late, then - * syncs its objects. Every approval decision below probes `sys_organization` - * (`ObjectQL.probeInstallOrganizations`) and `sys_approval_delegation`, which - * `ApprovalService.lookupActiveDelegation` reads best-effort on each decision. - * Both are fail-soft, so the reads are EXPECTED — but the driver and the engine - * each log the fault on the way out. + * syncs its objects. Every approval decision below reads + * `sys_approval_delegation`, which `ApprovalService.lookupActiveDelegation` + * reads best-effort on each decision. It is fail-soft, so the read is EXPECTED + * — but the driver and the engine each log the fault on the way out. + * + * ⚠️ [ADR-0131 D3] `sys_organization` USED TO BE on this list, as the second + * expected refusal (`ObjectQL.probeInstallOrganizations`). It is not any more: + * under `single` the Default Organization exists from the boot (the auth + * plugin's boot invariant), and a system insert into a tenant-scoped object in + * a composition that registers the organization object is refused when the + * install holds none (D9). So this fixture now provisions `sys_organization` + * and seeds the Default Organization, as the production boot does — and, by + * this channel's own contract, a table that started resolving leaves the list. * * ⚠️ [#17985] The five authz tables `resolveUserAuthzGrants` reads USED TO BE on * this list. They are not any more, because this fixture now provisions them @@ -83,8 +91,9 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * before this fixture provisioned anything, a run emitted 25 `refused a read on * ''` driver lines and 25 matching engine frames — sys_user 5 / sys_member 4 * / sys_position 4 / sys_user_position 4 / sys_user_permission_set 4 / - * sys_organization 2 / sys_approval_delegation 2. The 21 authz ones are gone; - * the remaining two channels are the ones declared below. + * sys_organization 2 / sys_approval_delegation 2. The 21 authz ones are gone, + * and so are the two `sys_organization` ones (ADR-0131 D3, above); the one + * remaining channel is the one declared below. * * ## Why a capture instead of the blanket `silent` this replaces * @@ -98,7 +107,6 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); * happen. A capture nobody asserts is a mute. */ const EXPECTED_ABSENT_PROBE_TABLES = [ - 'sys_organization', 'sys_approval_delegation', ] as const; @@ -311,20 +319,36 @@ describe('an approval decision cascades as the deciding user (#3783)', () => { for (const o of authzResolverObjects) { objectql.registry.registerObject(o.def as any, o.owner); } - // Real DDL for all four objects — including the three sys_approval_* tables - // the ApprovalService writes through. + // [ADR-0131 D3] The organization object, provisioned: the production + // `single` boot holds it and its Default Organization row (seeded below). + // Spelled locally with the columns the boot invariant writes, for the same + // no-dependency-edge reason as `authzResolverObjects`. + objectql.registry.registerObject( + { + name: 'sys_organization', + label: 'Organization', + fields: { name: { type: 'text', label: 'Name' }, slug: { type: 'text', label: 'Slug' } }, + } as any, + '@objectstack/plugin-auth', + ); + // Real DDL for every object registered so far — including the three + // sys_approval_* tables the ApprovalService writes through. await objectql.syncSchemas(); - // [#21516] The two probes this fixture EXPECTS to be refused (above) are - // registered here, AFTER the DDL, so they stay unprovisioned. The engine + // [ADR-0131 D3] The Default Organization the auth plugin's boot invariant + // guarantees before any seed or request: the one organization every system + // insert below derives (D9), as it does in production. + await data.insert( + 'sys_organization', + { id: 'org_default', name: 'Default Organization', slug: 'default' }, + { context: { isSystem: true } }, + ); + // [#21516] The probe this fixture EXPECTS to be refused (above) is + // registered here, AFTER the DDL, so it stays unprovisioned. The engine // refuses a name its registry does not hold before any driver, so the - // single-tenant org probe and the delegation lookup reach the driver — and - // its missing-table refusal — only through registered objects, as they do - // in production (the approvals plugin registers `sys_approval_delegation`). + // delegation lookup reaches the driver — and its missing-table refusal — + // only through a registered object, as it does in production (the + // approvals plugin registers `sys_approval_delegation`). objectql.registry.registerObject(SysApprovalDelegation as any, 'approvals-test', 'approvals-test'); - objectql.registry.registerObject( - { name: 'sys_organization', label: 'Organization', fields: { name: { type: 'text', label: 'Name' } } } as any, - 'approvals-test', - ); automation.registerFlow('on_approved', onApprovedFlow as any); svc = new ApprovalService({ engine: objectql }); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts index 80f39337a3c..468ff30d39e 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-warm-boot.test.ts @@ -106,9 +106,26 @@ async function openEngine(file: string): Promise { } as any); await engine.syncSchemas(); cleanups.push(() => engine.destroy()); + await ensureDefaultOrganization(engine); return engine; } +/** + * [ADR-0131 D3] Every `single` boot finds or creates the Default Organization + * before any seed settles — the auth plugin's boot invariant, which this + * plugin-only rig does not compose. Spelled here with the columns the invariant + * writes, so the rig gains no dependency edge onto `@objectstack/plugin-auth`. + * It runs on EVERY boot, as the invariant does: the warm boot finds the row the + * first boot created. Without it, a system insert into `crm_case` is refused + * (D9) — the composition registers `sys_organization` and the install would + * hold none, a shape no production `single` boot reaches. + */ +async function ensureDefaultOrganization(engine: any): Promise { + const found = await engine.find('sys_organization', { where: { slug: 'default' }, limit: 1 }, SYS); + if (Array.isArray(found) && found.length > 0) return; + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS); +} + /** * One process's `SecurityPlugin` over `engine`, with the kernel's phases as * separate steps. `fire` runs the handlers subscribed SO FAR — a hook From ede1fbd3459d826e884d5c5ba3e3e0476edd0961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:43:53 +0000 Subject: [PATCH 18/18] test(service-automation): the SecurityPlugin flip rig holds the Default Organization, the member's own The rig composes the identity objects (so `sys_organization` is registered) and no auth plugin, then runs a user-less system flow whose `create_record` is a system insert into a tenant-scoped object: refused under ADR-0131 C1 (D9, `no-organization`), a shape no production `single` boot reaches. The rig now seeds the Default Organization as `org_1`, the member's organization, and the NULL-born case pins the row's derived organization so the 403 is decided by the stamp columns alone. The lean rig above (no organization object) is unchanged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/runas-system-stamping.integration.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts index 34b563c2d68..a58e9560709 100644 --- a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts +++ b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts @@ -263,6 +263,17 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur ql.registerDriver(driver, true); ql.registry.registerObject(crmTask as any, 'stamping-test', 'stamping-test'); await ql.syncSchemas(); + // [ADR-0131 D3] A production `single` boot holds the Default Organization + // before any flow runs: the auth plugin's boot invariant, which this rig + // (identity objects, no auth plugin) does not compose. It is the member's + // organization, `org_1`, the deployment's one organization. Without it the + // user-less run's system insert is refused (D9): this composition + // registers `sys_organization`, and the install would hold none. + await ql.insert( + 'sys_organization', + { id: 'org_1', name: 'Default Organization', slug: 'default' }, + { context: { isSystem: true } }, + ); } const SYS = { isSystem: true } as const; @@ -304,6 +315,10 @@ describe('the #5494 admission flip: row content, not caller, decides (real Secur expect(res.success).toBe(true); const row = await rowByTitle('flip B'); expect(row!.created_by ?? null).toBeNull(); + // [ADR-0131 D9] The user-less system insert derives the install's one + // organization, the member's own, so the denial below is decided by the + // stamp columns alone and not by an organization boundary. + expect(row!.organization_id).toBe('org_1'); // The SAME member context that succeeded above is denied here: the only // difference between the two attempts is the row's stamp columns — the