diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 62d9409b697..b192e8154a2 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -132,7 +132,7 @@ that silently does not happen. | 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` | | 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` | | 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | | 26b | API key restore guard does not refuse | plugin-auth | Get: a write setting `revoked: false` on a `sys_api_key` row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set `revoked: false` | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | diff --git a/packages/objectql/src/engine-driver-options.ts b/packages/objectql/src/engine-driver-options.ts new file mode 100644 index 00000000000..88621412bec --- /dev/null +++ b/packages/objectql/src/engine-driver-options.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25522 cut 12] The driver-options and engine-posture bodies of `ObjectQL`, + * moved out of `engine.ts` whole: `buildDriverOptions` (the `DriverOptions` + * every driver call receives: the transaction handle, the tenant scope, the + * timezone and the tenant-audit fill-ins), `driverReadScope` (the Layer 0 + * verdict projected onto a read's driver scope), `engineLayer0Verdict` (that + * verdict over the engine's own facts), `wallExemptedOperation` (did the + * wall's exemption lift this operation's scope), `driverWriteTarget` (the + * write target of an insert or upsert) and `resolveEnginePosture` (the + * tenancy posture in force), with the one module helper only they use: + * `accessibleOrganizationsOf`. + * + * A MOVE, not a redesign. Each exported function below is the body of the + * `ObjectQL` method of the same name as it stood, re-indented one level and + * otherwise unedited. Each method's docblock stays on the method in + * `engine.ts`, which is still the contract. The helper moves with its + * docblock, byte for byte, and is not exported. + * + * Nothing stays behind in this section: every member of it moved. + * + * ## The transaction seam's same-origin half (ADR-0119, ADR-0067) + * + * `buildDriverOptions` threads the open transaction handle only to the driver + * that owns it (`transactionCoversDriverFor`, which stays in the class). The + * ADR anchor entry for this file carries that half of the paragraph the + * `engine.ts` entry carried; the `enforceTransactionOrigin` half stays there. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts`, applied per method as + * `engine-file-reference-hydration.ts` applies it. The class keeps all six + * methods with name, modifiers, signature, docblock and position, and each + * body is one literal of exactly the {@link DriverOptionsHost} members that + * method reads, plus a `.call`. The fields a body reads (`txStore`, + * `_registry`, `tenancyPostureProvider`) go as GETTERS, so each read happens + * when the body makes it, as it did. The methods they call + * (`isClosedUnitHandle`, `transactionCoversDriverFor`, `driverWriteTarget`, + * `driverReadScope`, `wallExemptedOperation`, `engineLayer0Verdict`, + * `resolveEnginePosture`) go as late-bound closures over the instance, so a + * double or spy set on the engine is still the one a call reaches. + * + * This module imports the engine only as a TYPE, so there is no module cycle + * at run time, and nothing re-exports it, so the published surface does not + * move. + */ + +import { isTenancyDisabled } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { + // [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's + // Layer 0 — see {@link buildDriverOptions}. + computeTenantLayer0Verdict, +} from '@objectstack/core'; +import { resolveTenantFieldName } from './tenancy/system-write-organization.js'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { + normalizeTenancyPosture, + TenantLayer0VerdictSchema, + type TenancyPosture, + type TenantLayer0Verdict, +} from '@objectstack/spec/security'; +import { isFederatedObject } from './federated-object.js'; +import type { ObjectQL } from './engine.js'; + +/** + * The `ObjectQL` members the functions below read — every one of them, and no + * other; each function's `this` is the `Pick` of the members IT reads. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's type on the class is a change here too, and naming a member the + * class does not have is a compile error. See the module header for which + * members are getters. + */ +export interface DriverOptionsHost { + readonly txStore: ObjectQL['txStore']; + readonly _registry: ObjectQL['_registry']; + readonly tenancyPostureProvider: ObjectQL['tenancyPostureProvider']; + isClosedUnitHandle: ObjectQL['isClosedUnitHandle']; + transactionCoversDriverFor: ObjectQL['transactionCoversDriverFor']; + driverWriteTarget: ObjectQL['driverWriteTarget']; + driverReadScope: ObjectQL['driverReadScope']; + wallExemptedOperation: ObjectQL['wallExemptedOperation']; + engineLayer0Verdict: ObjectQL['engineLayer0Verdict']; + resolveEnginePosture: ObjectQL['resolveEnginePosture']; +} + +/** + * [ADR-0105 D2] The context's organization access set + * (`ExecutionContext.accessible_org_ids`), stringified — `undefined` when + * absent or empty, which the verdict reads as "no membership" under `group`. + */ +function accessibleOrganizationsOf(execCtx: ExecutionContext | undefined): string[] | undefined { + const set = (execCtx as { accessible_org_ids?: unknown } | undefined)?.accessible_org_ids; + return Array.isArray(set) && set.length > 0 ? set.map(String) : undefined; +} + +/** The body of `ObjectQL#buildDriverOptions`; the method's docblock there is its contract. */ +export function buildDriverOptions( + this: Pick, + object: string, + execCtx?: ExecutionContext, + base?: any, + scope: { writeTarget?: boolean; recordedVerdict?: unknown } = {}, +): any { + // The open transaction may arrive explicitly via the context, or ambiently + // via txStore when an internal query runs during a transactional write + // (ADR-0034). Explicit wins; ambient is the safety net. [#22855] A handle + // whose unit has closed is no handle: work the unit started and did not + // await runs unenrolled (`runOpenedUnit`). + const explicit = execCtx?.transaction; + const tx = explicit !== undefined && !this.isClosedUnitHandle(explicit) + ? explicit + : this.txStore.getStore()?.transaction; + // [#5351] SAME-ORIGIN GATE. A transaction handle is a property of ONE + // driver's connection; handing it to a different driver does not put that + // driver's statement inside the transaction, it executes the statement on + // the WRONG CONNECTION — measured on knex/SQLite as `no such table` against + // a database that never held the object. The write path's + // `enforceTransactionOrigin` has already refused a business write by the + // time we get here (and let a system ledger through by decision), so this + // is the structural half: whatever survives to here, the handle only ever + // reaches the driver that owns it. It covers READS too, which have no gate + // of their own and were riding the same wrong connection. + const hasTx = tx !== undefined && this.transactionCoversDriverFor(object, tx); + const objectSchema = this._registry.getObject(object) as any; + // `isFederatedObject` is the same predicate every schema-sync seam routes a + // federated object by — one spelling of "this schema is the remote's", + // not a second reading of it. + const isFederated = isFederatedObject(objectSchema); + const tenantObject = !isTenancyDisabled(objectSchema) && !isFederated; + const tenantScope = tenantObject + ? scope.writeTarget + ? this.driverWriteTarget(execCtx) + : this.driverReadScope(execCtx, scope.recordedVerdict) + : undefined; + const hasTenant = tenantScope !== undefined; + const hasTz = execCtx?.timezone !== undefined; + const isSystem = execCtx?.isSystem === true; + // [ADR-0131 D9/D13] The tenant-audit's scope is the object's SCHEMA: a + // tenant column (the registered schema's, as `resolveTenantFieldName` + // reads it) on an object that is not federated. That is the same reading + // the system-insert rule takes, and there is no second, per-table one — + // the #13491 ledger that used to narrow this by name retired with D13. An + // object is outside the audit only by declaring itself outside the + // machinery (`tenancy.enabled: false`, `systemFields.tenant: false`, no + // tenant field, `external`), which `scripts/platform-object-tenancy-census.json` + // enumerates for the platform namespaces. + const isTenantAuditInScope = + resolveTenantFieldName(objectSchema) !== null && + !isFederated; + // [ADR-0131 D8 · 8c-1] The wall's exemption, declared to the driver. An + // exempt platform admin's predicate write reaches the driver with no + // `tenantId` because the wall lifted the scope on purpose, and the + // driver's `[tenant-audit]` line tells such a write to declare + // `bypassTenantAudit: true` — an option no REST caller can set. The + // exemption is decided once, by `computeTenantLayer0Verdict`, and arrives + // here as the recorded verdict; this reads it, it does not re-decide it + // (see {@link wallExemptedOperation}). + const wallExempt = + tenantObject && isTenantAuditInScope && !scope.writeTarget && + this.wallExemptedOperation(execCtx, scope.recordedVerdict); + const preserveAudit = (execCtx as any)?.preserveAudit === true; + if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return base; + const opts: any = base && typeof base === 'object' ? { ...base } : {}; + if (hasTx && opts.transaction === undefined) { + opts.transaction = tx; + } + if (hasTenant && opts.tenantId === undefined) { + opts.tenantId = tenantScope.tenantId; + } + if (hasTenant && opts.tenantIds === undefined && tenantScope.tenantIds !== undefined) { + opts.tenantIds = tenantScope.tenantIds; + } + if (hasTz && opts.timezone === undefined) { + // Thread the business timezone so date-dependent driver generation + // (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly. + opts.timezone = execCtx!.timezone; + } + if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) { + // [#13491 → ADR-0131 D13] The isSystem mute, cut by the object's + // schema alone (2026-08-31 ruling, execution point 1: the OBJECT decides, + // never the caller's flag). + // + // It used to mute EVERY elevated write, whatever the object was. The + // census cited in commit e49d98896's message measured what that cost: 135 of 175 write call sites + // (77%) were silenced here — at the control's LARGEST gate, sitting + // ahead of the condition the control is about — and the control has + // never produced a finding, while all five known instances of the defect + // were found by a person reading call sites. + // + // What still mutes: an object with no tenant field at all (nothing to be + // unscoped from — the driver would exit at `resolveTenantField` anyway) + // and a federated one. What does not: any object with a tenant column, + // platform namespace or not — a name prefix, or a hand-kept list of + // names, decides nothing here. A caller that means it still passes + // `bypassTenantAudit: true` explicitly and is honoured above — this + // branch only fills in a value nobody supplied. + // + // ⚠️ Direction, per execution point 3: what this ADDS is a driver WARN + // line. It changes nothing about what any write touches. + opts.bypassTenantAudit = true; + } + if (wallExempt && opts.bypassTenantAudit === undefined) { + // The same fill-in-only rule as the isSystem mute above: a caller's own + // value wins. Diagnostics only — the driver's scope is already the + // verdict's (no `tenantId`), and this option silences a warning and + // touches nothing else; on a read it is inert. + opts.bypassTenantAudit = true; + } + if (preserveAudit && opts.preserveAudit === undefined) { + // Historical import (#3493): let the driver keep a supplied `updated_at` + // instead of force-stamping now on the update path. + opts.preserveAudit = true; + } + return opts; +} + +/** The body of `ObjectQL#driverReadScope`; the method's docblock there is its contract. */ +export function driverReadScope( + this: Pick, + execCtx: ExecutionContext | undefined, + recordedVerdict: unknown, +): { tenantId: string; tenantIds?: string[] } | undefined { + const activeOrganization = + execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' + ? String(execCtx.tenantId) + : undefined; + const recorded = recordedVerdict === undefined ? undefined : TenantLayer0VerdictSchema.safeParse(recordedVerdict); + const verdict = recorded?.success ? recorded.data : this.engineLayer0Verdict(execCtx, activeOrganization); + switch (verdict.kind) { + case 'none': + return undefined; + case 'organization': + return { tenantId: verdict.organizationId }; + case 'organizations': + return { tenantId: activeOrganization ?? verdict.organizationIds[0], tenantIds: [...verdict.organizationIds] }; + case 'deny': + return activeOrganization !== undefined ? { tenantId: activeOrganization } : undefined; + } +} + +/** The body of `ObjectQL#engineLayer0Verdict`; the method's docblock there is its contract. */ +export function engineLayer0Verdict( + this: Pick, + execCtx: ExecutionContext | undefined, + activeOrganization: string | undefined, +): TenantLayer0Verdict { + return computeTenantLayer0Verdict({ + tenancyPosture: this.resolveEnginePosture(), + organizationId: activeOrganization, + accessibleOrgIds: accessibleOrganizationsOf(execCtx), + // Left to the driver, which resolves the tenant column from the + // table it actually built (`resolveTenantField`): an object with no + // such column is unscoped there whatever this says. `undefined` is + // the verdict's "assume a tenant object", as Layer 0 reads an + // unresolved field set. + objectHasOrgIdField: undefined, + tenancyDisabled: false, + // The engine holds no permission sets: no exemption is ITS to grant. + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); +} + +/** The body of `ObjectQL#wallExemptedOperation`; the method's docblock there is its contract. */ +export function wallExemptedOperation( + this: Pick, + execCtx: ExecutionContext | undefined, + recordedVerdict: unknown, +): boolean { + if (recordedVerdict === undefined) return false; + const recorded = TenantLayer0VerdictSchema.safeParse(recordedVerdict); + if (!recorded.success || recorded.data.kind !== 'none') return false; + const activeOrganization = + execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' + ? String(execCtx.tenantId) + : undefined; + return this.engineLayer0Verdict(execCtx, activeOrganization).kind !== 'none'; +} + +/** The body of `ObjectQL#driverWriteTarget`; the method's docblock there is its contract. */ +export function driverWriteTarget( + this: Pick, + execCtx: ExecutionContext | undefined, +): { tenantId: string; tenantIds?: string[] } | undefined { + if (execCtx?.tenantId === undefined) return undefined; + const tenantIds = + this.resolveEnginePosture() === 'group' ? accessibleOrganizationsOf(execCtx) : undefined; + return { tenantId: execCtx.tenantId as string, ...(tenantIds ? { tenantIds: [...tenantIds] } : {}) }; +} + +/** The body of `ObjectQL#resolveEnginePosture`; the method's docblock there is its contract. */ +export function resolveEnginePosture( + this: Pick, +): TenancyPosture { + const injected = normalizeTenancyPosture(this.tenancyPostureProvider?.()); + if (injected) return injected; + try { + return resolveTenancyPosture(); + } catch { + return 'isolated'; + } +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d68a6760fbd..a17c9e9b93e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -29,7 +29,7 @@ import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/ // [#22646] The ADR-0112 catalog — `rejectCredentialAggregation`'s refusal // envelope carries `INVALID_FIELD` from it rather than a bare `Error`. import { StandardErrorCode } from '@objectstack/spec/api'; -import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. import { @@ -148,6 +148,10 @@ import { assertReferencesResolve, masterDetailHeadersCallerReads } from './engin // [#25522 cut 11] File-field hydration on read; the methods of the same names // below hand each body the members it reads (`FileReferenceHydrationHost`). import { callerMayReadSysFile, readParentDerivedFiles, resolveFileReferences, servableFileRows, serveRefusedFileHydration } from './engine-file-reference-hydration.js'; +// [#25522 cut 12] The driver options every driver call receives, and the tenancy +// posture and Layer 0 verdict they are scoped by; the methods of the same names +// below hand each body the members it reads (`DriverOptionsHost`). +import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Verdict, resolveEnginePosture, wallExemptedOperation } from './engine-driver-options.js'; import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -177,9 +181,6 @@ import { // The data door's object-existence 404, shared for the same reason: an // in-process verb refuses a name the registry does not resolve with it. objectNotFoundError, - // [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's - // Layer 0 — see {@link buildDriverOptions}. - computeTenantLayer0Verdict, // [#24497] The ONE grants resolver, the one the request path seeds a // context's grants, peer list and email (#24639) through — see `ScopedContext[SEED_USER_GRANTS]`. resolveUserAuthzGrants, @@ -260,11 +261,8 @@ import { SystemWriteOrganizationRequiredError, ORGANIZATION_OBJECT, } from './tenancy/system-write-organization.js'; -import { resolveTenancyPosture } from '@objectstack/types'; import { - normalizeTenancyPosture, postureEnforcesWall, - TenantLayer0VerdictSchema, type TenancyPosture, type TenantLayer0Verdict, postureUsesUnionScope, @@ -4403,16 +4401,6 @@ function staticReadonlyStoredView( ) ?? data; } -/** - * [ADR-0105 D2] The context's organization access set - * (`ExecutionContext.accessible_org_ids`), stringified — `undefined` when - * absent or empty, which the verdict reads as "no membership" under `group`. - */ -function accessibleOrganizationsOf(execCtx: ExecutionContext | undefined): string[] | undefined { - const set = (execCtx as { accessible_org_ids?: unknown } | undefined)?.accessible_org_ids; - return Array.isArray(set) && set.length > 0 ? set.map(String) : undefined; -} - /** * What the engine knows about the transaction it opened, beyond the handle * itself (#4619, ADR-0119 D1 follow-up). @@ -7225,117 +7213,19 @@ export class ObjectQL implements IObjectQLEngine { base?: any, scope: { writeTarget?: boolean; recordedVerdict?: unknown } = {}, ): any { - // The open transaction may arrive explicitly via the context, or ambiently - // via txStore when an internal query runs during a transactional write - // (ADR-0034). Explicit wins; ambient is the safety net. [#22855] A handle - // whose unit has closed is no handle: work the unit started and did not - // await runs unenrolled (`runOpenedUnit`). - const explicit = execCtx?.transaction; - const tx = explicit !== undefined && !this.isClosedUnitHandle(explicit) - ? explicit - : this.txStore.getStore()?.transaction; - // [#5351] SAME-ORIGIN GATE. A transaction handle is a property of ONE - // driver's connection; handing it to a different driver does not put that - // driver's statement inside the transaction, it executes the statement on - // the WRONG CONNECTION — measured on knex/SQLite as `no such table` against - // a database that never held the object. The write path's - // `enforceTransactionOrigin` has already refused a business write by the - // time we get here (and let a system ledger through by decision), so this - // is the structural half: whatever survives to here, the handle only ever - // reaches the driver that owns it. It covers READS too, which have no gate - // of their own and were riding the same wrong connection. - const hasTx = tx !== undefined && this.transactionCoversDriverFor(object, tx); - const objectSchema = this._registry.getObject(object) as any; - // `isFederatedObject` is the same predicate every schema-sync seam routes a - // federated object by — one spelling of "this schema is the remote's", - // not a second reading of it. - const isFederated = isFederatedObject(objectSchema); - const tenantObject = !isTenancyDisabled(objectSchema) && !isFederated; - const tenantScope = tenantObject - ? scope.writeTarget - ? this.driverWriteTarget(execCtx) - : this.driverReadScope(execCtx, scope.recordedVerdict) - : undefined; - const hasTenant = tenantScope !== undefined; - const hasTz = execCtx?.timezone !== undefined; - const isSystem = execCtx?.isSystem === true; - // [ADR-0131 D9/D13] The tenant-audit's scope is the object's SCHEMA: a - // tenant column (the registered schema's, as `resolveTenantFieldName` - // reads it) on an object that is not federated. That is the same reading - // the system-insert rule takes, and there is no second, per-table one — - // the #13491 ledger that used to narrow this by name retired with D13. An - // object is outside the audit only by declaring itself outside the - // machinery (`tenancy.enabled: false`, `systemFields.tenant: false`, no - // tenant field, `external`), which `scripts/platform-object-tenancy-census.json` - // enumerates for the platform namespaces. - const isTenantAuditInScope = - resolveTenantFieldName(objectSchema) !== null && - !isFederated; - // [ADR-0131 D8 · 8c-1] The wall's exemption, declared to the driver. An - // exempt platform admin's predicate write reaches the driver with no - // `tenantId` because the wall lifted the scope on purpose, and the - // driver's `[tenant-audit]` line tells such a write to declare - // `bypassTenantAudit: true` — an option no REST caller can set. The - // exemption is decided once, by `computeTenantLayer0Verdict`, and arrives - // here as the recorded verdict; this reads it, it does not re-decide it - // (see {@link wallExemptedOperation}). - const wallExempt = - tenantObject && isTenantAuditInScope && !scope.writeTarget && - this.wallExemptedOperation(execCtx, scope.recordedVerdict); - const preserveAudit = (execCtx as any)?.preserveAudit === true; - if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return base; - const opts: any = base && typeof base === 'object' ? { ...base } : {}; - if (hasTx && opts.transaction === undefined) { - opts.transaction = tx; - } - if (hasTenant && opts.tenantId === undefined) { - opts.tenantId = tenantScope.tenantId; - } - if (hasTenant && opts.tenantIds === undefined && tenantScope.tenantIds !== undefined) { - opts.tenantIds = tenantScope.tenantIds; - } - if (hasTz && opts.timezone === undefined) { - // Thread the business timezone so date-dependent driver generation - // (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly. - opts.timezone = execCtx!.timezone; - } - if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) { - // [#13491 → ADR-0131 D13] The isSystem mute, cut by the object's - // schema alone (2026-08-31 ruling, execution point 1: the OBJECT decides, - // never the caller's flag). - // - // It used to mute EVERY elevated write, whatever the object was. The - // census cited in commit e49d98896's message measured what that cost: 135 of 175 write call sites - // (77%) were silenced here — at the control's LARGEST gate, sitting - // ahead of the condition the control is about — and the control has - // never produced a finding, while all five known instances of the defect - // were found by a person reading call sites. - // - // What still mutes: an object with no tenant field at all (nothing to be - // unscoped from — the driver would exit at `resolveTenantField` anyway) - // and a federated one. What does not: any object with a tenant column, - // platform namespace or not — a name prefix, or a hand-kept list of - // names, decides nothing here. A caller that means it still passes - // `bypassTenantAudit: true` explicitly and is honoured above — this - // branch only fills in a value nobody supplied. - // - // ⚠️ Direction, per execution point 3: what this ADDS is a driver WARN - // line. It changes nothing about what any write touches. - opts.bypassTenantAudit = true; - } - if (wallExempt && opts.bypassTenantAudit === undefined) { - // The same fill-in-only rule as the isSystem mute above: a caller's own - // value wins. Diagnostics only — the driver's scope is already the - // verdict's (no `tenantId`), and this option silences a warning and - // touches nothing else; on a read it is inert. - opts.bypassTenantAudit = true; - } - if (preserveAudit && opts.preserveAudit === undefined) { - // Historical import (#3493): let the driver keep a supplied `updated_at` - // instead of force-stamping now on the update path. - opts.preserveAudit = true; - } - return opts; + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const readTxStore = () => this.txStore; + const readRegistry = () => this._registry; + const host: ThisParameterType = { + isClosedUnitHandle: (...a) => this.isClosedUnitHandle(...a), + get txStore() { return readTxStore(); }, + transactionCoversDriverFor: (...a) => this.transactionCoversDriverFor(...a), + get _registry() { return readRegistry(); }, + driverWriteTarget: (...a) => this.driverWriteTarget(...a), + driverReadScope: (...a) => this.driverReadScope(...a), + wallExemptedOperation: (...a) => this.wallExemptedOperation(...a), + }; + return buildDriverOptions.call(host, object, execCtx, base, scope); } /** @@ -7352,22 +7242,11 @@ export class ObjectQL implements IObjectQLEngine { execCtx: ExecutionContext | undefined, recordedVerdict: unknown, ): { tenantId: string; tenantIds?: string[] } | undefined { - const activeOrganization = - execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' - ? String(execCtx.tenantId) - : undefined; - const recorded = recordedVerdict === undefined ? undefined : TenantLayer0VerdictSchema.safeParse(recordedVerdict); - const verdict = recorded?.success ? recorded.data : this.engineLayer0Verdict(execCtx, activeOrganization); - switch (verdict.kind) { - case 'none': - return undefined; - case 'organization': - return { tenantId: verdict.organizationId }; - case 'organizations': - return { tenantId: activeOrganization ?? verdict.organizationIds[0], tenantIds: [...verdict.organizationIds] }; - case 'deny': - return activeOrganization !== undefined ? { tenantId: activeOrganization } : undefined; - } + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const host: ThisParameterType = { + engineLayer0Verdict: (...a) => this.engineLayer0Verdict(...a), + }; + return driverReadScope.call(host, execCtx, recordedVerdict); } /** @@ -7379,21 +7258,11 @@ export class ObjectQL implements IObjectQLEngine { execCtx: ExecutionContext | undefined, activeOrganization: string | undefined, ): TenantLayer0Verdict { - return computeTenantLayer0Verdict({ - tenancyPosture: this.resolveEnginePosture(), - organizationId: activeOrganization, - accessibleOrgIds: accessibleOrganizationsOf(execCtx), - // Left to the driver, which resolves the tenant column from the - // table it actually built (`resolveTenantField`): an object with no - // such column is unscoped there whatever this says. `undefined` is - // the verdict's "assume a tenant object", as Layer 0 reads an - // unresolved field set. - objectHasOrgIdField: undefined, - tenancyDisabled: false, - // The engine holds no permission sets: no exemption is ITS to grant. - posturePermitsCrossTenant: false, - isPlatformAdmin: false, - }); + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const host: ThisParameterType = { + resolveEnginePosture: (...a) => this.resolveEnginePosture(...a), + }; + return engineLayer0Verdict.call(host, execCtx, activeOrganization); } /** @@ -7413,14 +7282,11 @@ export class ObjectQL implements IObjectQLEngine { * that does not parse answers false too — junk never lifts anything. */ private wallExemptedOperation(execCtx: ExecutionContext | undefined, recordedVerdict: unknown): boolean { - if (recordedVerdict === undefined) return false; - const recorded = TenantLayer0VerdictSchema.safeParse(recordedVerdict); - if (!recorded.success || recorded.data.kind !== 'none') return false; - const activeOrganization = - execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' - ? String(execCtx.tenantId) - : undefined; - return this.engineLayer0Verdict(execCtx, activeOrganization).kind !== 'none'; + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const host: ThisParameterType = { + engineLayer0Verdict: (...a) => this.engineLayer0Verdict(...a), + }; + return wallExemptedOperation.call(host, execCtx, recordedVerdict); } /** @@ -7432,10 +7298,11 @@ export class ObjectQL implements IObjectQLEngine { private driverWriteTarget( execCtx: ExecutionContext | undefined, ): { tenantId: string; tenantIds?: string[] } | undefined { - if (execCtx?.tenantId === undefined) return undefined; - const tenantIds = - this.resolveEnginePosture() === 'group' ? accessibleOrganizationsOf(execCtx) : undefined; - return { tenantId: execCtx.tenantId as string, ...(tenantIds ? { tenantIds: [...tenantIds] } : {}) }; + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const host: ThisParameterType = { + resolveEnginePosture: (...a) => this.resolveEnginePosture(...a), + }; + return driverWriteTarget.call(host, execCtx); } /** @@ -7460,13 +7327,12 @@ export class ObjectQL implements IObjectQLEngine { * be worst, so it degrades to the walled reading and the write is refused. */ private resolveEnginePosture(): TenancyPosture { - const injected = normalizeTenancyPosture(this.tenancyPostureProvider?.()); - if (injected) return injected; - try { - return resolveTenancyPosture(); - } catch { - return 'isolated'; - } + // [#25522 cut 12] The body moved to `engine-driver-options.ts` (see `DriverOptionsHost`). + const readTenancyPostureProvider = () => this.tenancyPostureProvider; + const host: ThisParameterType = { + get tenancyPostureProvider() { return readTenancyPostureProvider(); }, + }; + return resolveEnginePosture.call(host); } /** diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 24908f43341..0040e5cf2d4 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -185,11 +185,11 @@ const READERS: Record = { }, // ── Readers that already asked whether the object is federated ────────── - 'engine.ts#buildDriverOptions :: isFederatedObject()': { + 'engine-driver-options.ts#buildDriverOptions :: isFederatedObject()': { disposition: 'exempt', why: 'the organization wall a read carries is withheld from a federated object', }, - 'engine.ts#buildDriverOptions :: resolveTenantFieldName()': { + 'engine-driver-options.ts#buildDriverOptions :: resolveTenantFieldName()': { disposition: 'exempt', why: 'resolves the wall column only after the federated exemption', }, diff --git a/scripts/adr-anchors/packages__objectql__src__engine-driver-options.ts.json b/scripts/adr-anchors/packages__objectql__src__engine-driver-options.ts.json new file mode 100644 index 00000000000..8eb9e85fd71 --- /dev/null +++ b/scripts/adr-anchors/packages__objectql__src__engine-driver-options.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/objectql/src/engine-driver-options.ts", + "adrs": [ + "ADR-0067", + "ADR-0119" + ], + "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `buildDriverOptions` threads the ambient handle ONLY to the driver that owns it, compared by instance identity via `TransactionScope` — handing it to another driver does not put that driver inside the transaction, it runs the statement on the wrong connection. Never restore the unconditional handle lift." +} diff --git a/scripts/adr-anchors/packages__objectql__src__engine.ts.json b/scripts/adr-anchors/packages__objectql__src__engine.ts.json index 6d4002ec8fa..24eb8f73584 100644 --- a/scripts/adr-anchors/packages__objectql__src__engine.ts.json +++ b/scripts/adr-anchors/packages__objectql__src__engine.ts.json @@ -5,5 +5,5 @@ "ADR-0067", "ADR-0119" ], - "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `buildDriverOptions` threads the ambient handle ONLY to the driver that owns it, compared by instance identity via `TransactionScope` — handing it to another driver does not put that driver inside the transaction, it runs the statement on the wrong connection. `enforceTransactionOrigin` then decides the write: an append-only system ledger (`lifecycle.class` in `SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads — never a second copy) is CARVED OUT and executed outside the transaction, surviving rollback; any other cross-driver write is REFUSED before hooks run. Never add a companion transaction on the second driver (no two-phase commit in IDataDriver) and never restore the unconditional handle lift." + "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `enforceTransactionOrigin` then decides the write: an append-only system ledger (`lifecycle.class` in `SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads — never a second copy) is CARVED OUT and executed outside the transaction, surviving rollback; any other cross-driver write is REFUSED before hooks run. Never add a companion transaction on the second driver (no two-phase commit in IDataDriver)." } diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index 8d0c0f8fb48..a23d291fdbe 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -437,7 +437,7 @@ export const NON_READ_ANCHORS = [ rowSeams: ['`owner_id` is not auto-stamped on INSERT', 'The whole security middleware short-circuits'], }, { - file: 'packages/objectql/src/engine.ts', + file: 'packages/objectql/src/engine-driver-options.ts', symbol: 'buildDriverOptions', collapsesOntoRead: true, why: 'row 24 -- the early return the tenant-audit read feeds, and where `bypassTenantAudit` is threaded to the driver',