Skip to content

Commit d196748

Browse files
fix(core)!: the localization sys_setting fallback reads the caller organization only (ADR-0131 D8) (#25301)
Fixes #25248 Part of #15212. Filed from the contract review of #25226 (record 6343868210, ③ 3a). Claim: 6350248910. Owed under ADR-0131 D8. ## Measurement (step 1, on `main` at `1eb1afae07`) `resolveLocalizationContextUncached` reads `sys_setting` directly when the settings service does not answer. On `main` that read is `scope: 'tenant'` with no organization filter, and it takes the first row per key. | how the fallback is reached | reached on a served composition? | leaks on `main`? | |:--|:--|:--| | settings service absent | only `sys_setting`'s own registrar is `@objectstack/service-settings` (`manifest.ts`), so without it the read fails ("object not found"), memoized as a backend fault, and `UTC` / `en-US` answer | no rows to leak, unless a host registers `sys_setting` itself | | `getMany` throws: `localization` namespace not registered yet (bind window), or a partial manifest | yes | **yes** | | `getMany` throws: the organization source's `AuthzStoreUnavailableError` (#25226, a caller that names no organization under `single`), or the posture source throws | yes | **yes** | | a healthy service | never: the manifest's `UTC` / `en-US` defaults always answer, so the fallback is skipped | no | | lean embedding (no `sys_organization`) | when the service throws, as above | NULL rows are the legitimate population there | | each posture | the fallback ignored the posture entirely | **yes**, under `single`, `group` and `isolated` | **Two organizations, each with a tenant `localization.timezone` row: can a user of one read the other's?** Yes. A scratch probe (not committed) used #25226's rig: a real `ObjectKernel` + `ObjectQL` + `SettingsServicePlugin`, with only the driver a Map. - `isolated`, `org_a` and `org_b`, the settings service throwing (namespace not bound), caller `org_a`: `main` answers **`Asia/Tokyo`** (`org_b`'s row). This branch answers `Europe/Paris` (`org_a`'s). - `single`, two organizations, the organization read failing, caller names none: `main` answers `org_b`'s `Asia/Tokyo`. This branch answers `UTC`. - Control, a healthy service: both answer `UTC`, and the fallback is not reached. The success cache can then hold a leaked answer for up to `OS_LOCALIZATION_CACHE_TTL_MS`. ## What changes (step 2): scoped, not retired The fallback now reads with the reach `SettingsService` reads with since #25226 (`localizationFallbackReach`): | caller | rows read | |:--|:--| | names organization X (any posture) | `organization_id = X` | | names none, `single`, D9 derives X | `organization_id = X` | | names none, `single`, zero or several organizations | none (built-ins) | | names none, walled (`group` / `isolated`) | none, and no query is issued | | names none, the organization read fails | none, never a guess | | names none, no `sys_organization` registered (lean embedding, #25133 letter A) | `organization_id` NULL only | - D9's answer comes from `resolveGuestOrganization`, the predicate in this file that the settings plugin also wires. - The posture is the **requested** one (`resolveTenancyPosture()`), read the same way this file's platform-standing anchor reads it: no query, and a deployment that asked for a wall stays walled while running degraded. The fallback takes no posture input from the service it stands in for. - The organization goes into the `where` **and** is compared in memory, following `preferredRow`'s rule. - A failed organization derivation does not populate the failure memo. It is not the direct read's own backend fault (#10221 / #11877 unchanged). **Why not retire it.** Two things still use it. The #10221 failure memo is keyed on this direct read and keeps the "no such table" log line from repeating. And the bind-window answer is still the caller's own stored value, which the #11580 re-resolve then replaces. Scoping keeps both, with no other organization's row and no NULL row outside lean. Clause-②: no (narrowing) Metadata-protocol: none A localization read stops admitting another organization's row, or a NULL row outside lean embedding. The changeset ships `@objectstack/core` as `patch` with the BREAKING banner, the FROM/TO and the remedy. ADR-0087 disposition: `not-required (no-migration-prescription)`. ## Pins, and the ablation `resolve-localization-fallback-organization.pin.test.ts` (new, 14 cases): - own organization, with the settings service absent and throwing `AuthzStoreUnavailableError`; - no fallback to another organization, or to a NULL row; - the in-memory compare, behind an engine that drops the filter; - `single` with one, several or no organizations; - `isolated` and `group`, for a caller naming none and a caller naming its own organization; - a failed organization read; - lean. Existing fixtures in `resolve-authz-context.test.ts`, `resolve-localization-cache.test.ts` and plugin-hono-server's `current-user-endpoints-localization.test.ts` gave their rows no organization while their caller named `o1` / `org_lang`. Their rows now carry the caller's organization. Nothing else in them changed. Ablation, from the committed state, each restored with `git checkout HEAD --` (`git status` clean afterwards): | ablation | pins red | |:--|:--| | A1: `main`'s fallback (no organization at all) | **12 / 14** | | A2: no in-memory compare | 1 / 14 (the filter-dropping engine) | | A3: the wall ignored for a caller naming none | 2 / 14 (`isolated`, `group`) | | A4: the lean NULL arm removed | 1 / 14 (lean) | ## Proof (head `0139af1fe3`) - **`@objectstack/core`:** - `pnpm typecheck`: exit 0. - vitest: 117 files / 2734 tests pass. - **Downstream consumers:** - plugin-hono-server: 448 / 448 after the fixture change, and `pnpm typecheck` exit 0. - mcp: 532 / 532. - plugin-audit: 985 / 985. - rest: 6607 pass, 344 skipped. - **`node scripts/pm/dispatch-gates.mjs --commands`:** 70 derived. Each exit code was captured before any pipe, and all 70 now exit 0. - First pass: 66 exit 0. - `check:objectql-double-limit` and `check:where-matcher` were red on the new pin's engine double. They are fixed in `8c59890e8f` and `0139af1fe3`, and both now exit 0. - `check-plugin-teardown-shape --self-test` exited 3 (shallow clone). After `git fetch --unshallow` it exits 0. - `check:dual-build-cjs-loads` exited 3 (no full build). After `pnpm build` it exits 0. - **`check-governed-merges --branch`:** NOT governed (0 of 6 paths). 336 changed lines (+305 / −31). Not Tier H/S. - **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`, both fixture legs and the fresh store): 16 / 16 pass. - **R1, fresh boot:** a production `os serve` of the showcase on an empty store under `single`: - ready in 10 s, health 200; - sign-up 200, sign-in 200; - `GET /api/v1/auth/me/localization` 200 (`UTC` / `en-US`). - **R4, a walled production `os serve`** (`NODE_ENV=production`, `OS_PLATFORM_OWNER_EMAIL`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, a 32-byte `OS_SECRET_KEY`), on an empty store: - `isolated` and `group`: each ready in 9 s, health 200, sign-up 200, sign-in 200, `me/localization` 200. - `isolated`: `PUT /api/settings/localization` with `timezone: Asia/Tokyo` answered 200, and the next `me/localization` read answered `Asia/Tokyo`. - ⚠ The showcase declares no `@objectstack/organizations`, and a walled `os serve` refuses without it. For R4 only, the dependency was added locally. `package.json` and the lockfile were restored afterwards. None of it is in the diff. ## Acceptance notes - `main` moved past `1eb1afae07` (to `f47f7f222f`). None of its commits touch these files, so no merge was taken (§10). - **Seat amendment · 2026-10-22T23:10Z** (epic PM `session_01Y5583bs2RDd53MH6kM1ca3`). The body was written at head `0139af1fe3`, and three facts above have aged: - `Clause-②` is corrected from `yes (narrowing)` to `no (narrowing)`, per the contract review 6353329341: the diff widens no surface. The changeset line follows in the dev's next commit. - The diff is now 7 files, +313 / −36. `417fe64988` adds `packages/runtime/src/security/resolve-execution-context.test.ts`, a fixture whose rows now carry the caller's organization. - `main` was merged in at `83ff895f4b` (`origin/main` `09e07c259a`), so the "no merge was taken" line above is superseded. - The final head's proof is in the os-dev-report on #25248. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DydJpxUXfktYEyRLvwbAEF --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21a25dd commit d196748

7 files changed

Lines changed: 313 additions & 36 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core)!: `resolveLocalizationContext`'s direct `sys_setting` fallback reads the caller's organization's rows only, never another organization's row or a row with no organization outside a lean embedding
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Which stored `sys_setting` row the localization fallback takes changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, error code or route is removed or renamed: `resolveLocalizationContext` keeps its signature. The remedy for the rows this stops reading is the existing organization-ownership ceremony, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** read narrowing (ADR-0131 D8 "no layer has a NULL arm"). It changes which stored row a localization read takes when the settings service cannot answer, not what a metadata author can write, so it ships as `patch` with this banner.
13+
14+
- FROM: when the settings service was absent or threw (a namespace not yet bound, a failed organization or posture read), `resolveLocalizationContext` read `sys_setting` tenant rows for `timezone` / `locale` / `currency` with no organization filter and took the first row per key. A signed-in user of one organization could be answered with another organization's timezone, locale or currency, or with a row written with no organization, and the success cache could hold that answer.
15+
- TO: the fallback reads with the reach the settings service reads with:
16+
- A caller that names an organization reads that organization's rows, under every posture.
17+
- A caller that names none reads the one organization ADR-0131 D9 derives under `single`. Behind a wall (`group` / `isolated`, the requested posture), at zero or several organizations, or when the organization read fails, it reads nothing and the built-ins `UTC` / `en-US` answer.
18+
- A composition that registers no `sys_organization` (a lean embedding) reads the rows written with no organization, the only rows it can hold.
19+
20+
**Who is affected.** Only a deployment whose settings service fails a localization read, and only for an organization with no tenant localization row of its own, or a store holding organization-less tenant localization rows. A healthy settings service never reaches this read.
21+
22+
**The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each localization value again from inside the organization it belongs to (Setup → Localization, or `PUT /api/settings/localization` with that organization active).

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,9 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
219219
it('reads sys_setting once (all three keys) when no settings service is wired', async () => {
220220
const ql = makeCountingQl({
221221
sys_setting: [
222-
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
223-
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
224-
{ namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' },
222+
{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' },
223+
{ namespace: 'localization', key: 'locale', scope: 'tenant', organization_id: 'o1', value: 'ja-JP' },
224+
{ namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'JPY' },
225225
],
226226
});
227227
const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' });
@@ -283,7 +283,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
283283
};
284284
const ql = makeCountingQl({
285285
sys_setting: [
286-
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' },
286+
{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Europe/Paris' },
287287
],
288288
});
289289
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
@@ -505,7 +505,7 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
505505
// the same staleness property through the invalidation instead of through the
506506
// absence of a cache.
507507
it('without an engine write-epoch seam, a successful read is never cached: a value change between two calls is visible on the very next call', async () => {
508-
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }];
508+
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }];
509509
const ql = makeCountingQl({ sys_setting: rows });
510510
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
511511
expect(first.timezone).toBe('UTC');
@@ -523,11 +523,11 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
523523
// unconfigured tenant must be visible on the very next call, same as the
524524
// value-change case above. (#11966: same seam-absent arm as that case.)
525525
it('without a seam, a legitimate empty result is not cached either: the first write for a previously-unconfigured tenant is visible immediately', async () => {
526-
const rows: Array<{ namespace: string; key: string; scope: string; value: string }> = [];
526+
const rows: Array<{ namespace: string; key: string; scope: string; organization_id: string; value: string }> = [];
527527
const ql = makeCountingQl({ sys_setting: rows });
528528
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
529529
expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
530-
rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' });
530+
rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' });
531531
const second = await resolveLocalizationContext({ ql, tenantId: 'o1' });
532532
expect(second.timezone).toBe('Asia/Tokyo');
533533
expect(ql.counts.sys_setting).toBe(2);
@@ -627,7 +627,7 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
627627
// has nothing to do with the backend.
628628
it('a settings refusal never freezes a SUCCESSFUL direct read: a row change is visible on the very next call', async () => {
629629
const settings = makeBindWindowSettings(); // stays unbound → refuses every call
630-
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'UTC' }];
630+
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }];
631631
const ql = makeCountingQl({ sys_setting: rows });
632632

633633
const first = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2386,7 +2386,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE
23862386
* Resolve workspace localization defaults (reference `timezone` / `locale` /
23872387
* `currency`). Canonical path is the `localization` SettingsManifest (cascade:
23882388
* platform default → global → tenant); falls back to direct tenant-scoped
2389-
* `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
2389+
* `sys_setting` rows of the caller's organization only (ADR-0131 D8 — see
2390+
* {@link localizationFallbackReach}), then the built-ins `UTC` / `en-US`.
2391+
* Never throws.
23902392
*
23912393
* The DIRECT `sys_setting` read failing outright (backend fault — table
23922394
* missing, connection refused, etc.) is memoized for
@@ -2461,6 +2463,56 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput
24612463
return value;
24622464
}
24632465

2466+
/**
2467+
* [ADR-0131 D8/D9] Which `sys_setting` tenant rows the direct localization
2468+
* fallback in {@link resolveLocalizationContextUncached} may draw on — the
2469+
* reach `SettingsService` reads the same rows with, so the fallback can never
2470+
* answer what the service it stands in for would refuse:
2471+
*
2472+
* - `own` — one organization's rows: the caller's, under every posture; or,
2473+
* for a caller that names none where no wall stands, the one organization
2474+
* D9's predicate derives ({@link resolveGuestOrganization}).
2475+
* - `no-organization-object` — the composition registers no organization
2476+
* object (the lean embedding, kept by ADR-0131's D9 note): the rows written
2477+
* with no organization, the only rows it can hold.
2478+
* - `unowned` — nothing to read: a caller that names no organization behind a
2479+
* wall, where D9 derives none (zero organizations, or several), or where
2480+
* the derivation could not be asked (a failed organization read). The
2481+
* built-in defaults answer.
2482+
*
2483+
* The posture is the REQUESTED one ({@link resolveTenancyPosture}), as the
2484+
* platform-standing anchor in this file reads it: a deployment that asked for
2485+
* a wall stays walled here even while running degraded, and the posture costs
2486+
* no query. This fallback is reached only when the settings service is absent
2487+
* or has just failed, so it takes no inputs that service would supply.
2488+
*/
2489+
async function localizationFallbackReach(
2490+
ql: any,
2491+
tenantId: string | undefined,
2492+
): Promise<
2493+
| { kind: 'own'; organizationId: string }
2494+
| { kind: 'no-organization-object' }
2495+
| { kind: 'unowned' }
2496+
> {
2497+
if (typeof tenantId === 'string' && tenantId !== '') return { kind: 'own', organizationId: tenantId };
2498+
try {
2499+
const decision = await resolveGuestOrganization(ql, resolveTenancyPosture());
2500+
if (decision.kind === 'derived') return { kind: 'own', organizationId: decision.organizationId };
2501+
if (decision.kind === 'no-organization-object') return { kind: 'no-organization-object' };
2502+
} catch {
2503+
// The organization could not be derived (`AuthzStoreUnavailableError`):
2504+
// no organization's row is read on a guess. Not the direct read's own
2505+
// backend fault, so it does not populate the failure memo (#10221).
2506+
}
2507+
return { kind: 'unowned' };
2508+
}
2509+
2510+
/** A `sys_setting` row's organization, with "no organization" spelled one way. */
2511+
function settingRowOrganizationOf(row: any): string | null {
2512+
const organizationId = row?.organization_id;
2513+
return typeof organizationId === 'string' && organizationId !== '' ? organizationId : null;
2514+
}
2515+
24642516
async function resolveLocalizationContextUncached(
24652517
input: ResolveLocalizationInput,
24662518
): Promise<{ value: LocalizationResult; backendFailed: boolean }> {
@@ -2525,6 +2577,10 @@ async function resolveLocalizationContextUncached(
25252577
// Settings service unavailable → direct read. Still not a backend fault,
25262578
// so it does not populate the memo either (#11877).
25272579
}
2580+
// [ADR-0131 D8] The organization this read may draw on — the reach the
2581+
// settings service itself reads with (#25226): never another organization's
2582+
// row, and a row with no organization only where no organization can exist.
2583+
const reach = await localizationFallbackReach(ql, tenantId);
25282584
// One read for all three keys instead of a query per key (`$in` on `key`).
25292585
// Inlined (rather than the shared `tryFind`) so a genuine backend fault —
25302586
// as opposed to a legitimate empty result — is visible to the caller above,
@@ -2534,15 +2590,23 @@ async function resolveLocalizationContextUncached(
25342590
// query-options-erasure guard: that cast is a distinct, counted erasure
25352591
// site, not implied by an already-`any` receiver).
25362592
let rows: any[] = [];
2537-
if (ql && typeof ql.find === 'function') {
2593+
if (reach.kind !== 'unowned' && ql && typeof ql.find === 'function') {
2594+
const organization_id = reach.kind === 'own' ? reach.organizationId : null;
25382595
try {
25392596
let result = await ql.find('sys_setting', {
2540-
where: { namespace: 'localization', key: { $in: ['timezone', 'locale', 'currency'] }, scope: 'tenant' },
2597+
where: {
2598+
namespace: 'localization',
2599+
key: { $in: ['timezone', 'locale', 'currency'] },
2600+
scope: 'tenant',
2601+
organization_id,
2602+
},
25412603
limit: 10,
25422604
context: { isSystem: true },
25432605
});
25442606
if (result && (result as any).value) result = (result as any).value;
2545-
rows = Array.isArray(result) ? result : [];
2607+
// The organization is compared HERE too, not trusted to the filter —
2608+
// the settings service's own rule (`preferredRow`).
2609+
rows = (Array.isArray(result) ? result : []).filter((r) => settingRowOrganizationOf(r) === organization_id);
25462610
} catch {
25472611
// THE backend fault the failure memo exists for (#10221).
25482612
backendFailed = true;

0 commit comments

Comments
 (0)