From 3abf652651885dbf56753c14697409e8a2ef8b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:44:11 +0000 Subject: [PATCH 1/7] wip: wire config.locale at the three runtime seed-load call sites --- packages/runtime/src/app-plugin.ts | 65 +++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index ab41efde4d..71123c8a6e 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1215,6 +1215,11 @@ export class AppPlugin implements Plugin { const sharedDatasets = mergeSeedDatasets(ctx, normalizedDatasets); const loggerRef = ctx.logger; + // [#16595] Same capture posture as `loggerRef`: the replayer + // outlives `start()` and is invoked by SecurityPlugin's + // sys_organization hook, so the locale is resolved once, here, + // from the boot bundle rather than re-read per replay. + const seedLocale = this.resolveSeedLocale(); const replayer = async (organizationId: string) => { if (!organizationId) return { inserted: 0, updated: 0, skipped: 0, errors: [] as any[] }; const md = ctx.getService('metadata') as IMetadataService | undefined; @@ -1244,6 +1249,13 @@ export class AppPlugin implements Plugin { // unless a seed embeds `cel`os.user.id`` — see the // lazy guard where it is resolved. identity: seedIdentity, + // [#16595] `Seed.locale`'s producer. Spread rather + // than written as `locale: seedLocale` so an app + // with no `i18n.defaultLocale` sends NO key at all + // — `undefined` and absent parse the same here, but + // absence is what the loader's unresolved-scope + // warning is keyed on. + ...(seedLocale ? { locale: seedLocale } : {}), }, }); const result = await seedLoader.load(request); @@ -1319,9 +1331,17 @@ export class AppPlugin implements Plugin { if (metadata) { const seedLoader = new SeedLoaderService(ql, metadata, ctx.logger); const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data'); + // [#16595] `Seed.locale`'s producer on the DEFAULT boot + // path — see {@link resolveSeedLocale}. + const seedLocale = this.resolveSeedLocale(); const request = SeedLoaderRequestSchema.parse({ seeds: normalizedDatasets, - config: { defaultMode: 'upsert', multiPass: true, identity: seedIdentity }, + config: { + defaultMode: 'upsert', + multiPass: true, + identity: seedIdentity, + ...(seedLocale ? { locale: seedLocale } : {}), + }, }); const result = await seedLoader.load(request); const { totalInserted, totalUpdated, totalSkipped, totalErrored } = result.summary; @@ -1577,6 +1597,38 @@ export class AppPlugin implements Plugin { return postureEnforcesWall(resolveTenancyPosture()); } + /** + * The producer half of `Seed.locale` (#16595) — the BCP-47 tag every seed + * load started by this plugin filters on, read off the app's declared + * `i18n.defaultLocale`. + * + * Same source, same spelling and the same envelope-vs-collection posture as + * {@link loadTranslations}' `setDefaultLocale` call: `i18n` is an ENVELOPE + * key, so it is read off `this.bundle` (with the legacy nested-manifest + * fallback) and NOT through `this.collections`. + * + * ⛔ Resolved HERE rather than inside `SeedLoaderService.load()`, which is + * where the sibling `env` axis resolves ITSELF (`resolveEnvConfig`, off + * `NODE_ENV`). That asymmetry is forced, not a style choice: `env` has an + * ambient, process-wide source the loader can read on its own, and a locale + * has none — the only thing that knows which locale this stack runs in is + * the app config the loader is never handed. So this axis needs a real + * producer at the call sites, which is what #16595 is. + * + * Returns `undefined` — never a `'en'` default — when the app declares no + * locale. Absence is the loader's UNRESTRICTED spelling ("seed every + * dataset"), i.e. today's behaviour; defaulting to `'en'` would silently + * DROP a `locale: ['zh-CN']` dataset on every stack that never declared an + * `i18n` block, turning a wiring change into a data change. + * `SeedLoaderService#warnOnUnresolvedLocaleScope` is what keeps that + * absence loud rather than silent. + */ + private resolveSeedLocale(): string | undefined { + const i18nConfig = this.bundle?.i18n || (this.bundle?.manifest || this.bundle)?.i18n; + const declared = i18nConfig?.defaultLocale; + return typeof declared === 'string' && declared.length > 0 ? declared : undefined; + } + /** * 15.1 third-party eval — dev hot-reload of a NEW object registered its * metadata (and, via ObjectQL's `metadata:reloaded` hook, created its @@ -1635,9 +1687,18 @@ export class AppPlugin implements Plugin { } const seedLoader = new SeedLoaderService(ql, metadata, ctx.logger); const { SeedLoaderRequestSchema } = await import('@objectstack/spec/data'); + // [#16595] `Seed.locale`'s producer on the dev hot-reload + // path. Resolved from the BOOT bundle, not from `payload`: a + // reload re-parses the artifact's metadata collections, and + // `i18n` is an envelope key that does not travel in it. + const seedLocale = this.resolveSeedLocale(); const request = SeedLoaderRequestSchema.parse({ seeds, - config: { defaultMode: 'upsert', multiPass: true }, + config: { + defaultMode: 'upsert', + multiPass: true, + ...(seedLocale ? { locale: seedLocale } : {}), + }, }); const result = await seedLoader.load(request); const { totalInserted, totalUpdated, totalErrored } = result.summary; From 82ddb3c7d41935a17ae411d8540a236bf212b692 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:02:05 +0000 Subject: [PATCH 2/7] test(runtime): pin the Seed.locale producer at all three app-plugin call sites --- .../app-plugin.seed-locale-producer.test.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 packages/runtime/src/app-plugin.seed-locale-producer.test.ts diff --git a/packages/runtime/src/app-plugin.seed-locale-producer.test.ts b/packages/runtime/src/app-plugin.seed-locale-producer.test.ts new file mode 100644 index 0000000000..2c2ff5c278 --- /dev/null +++ b/packages/runtime/src/app-plugin.seed-locale-producer.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppPlugin } from './app-plugin'; +import type { PluginContext } from '@objectstack/core'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; + +/** + * `Seed.locale`'s PRODUCER (#16595) — the half `#16510` / PR #16592 deliberately + * left out of its own file surface. + * + * The consumer landed complete: `SeedLoaderService` reads `Seed.locale`, filters + * on `SeedLoaderConfig.locale`, composes the axis with `env` by conjunction, and + * `seed-loader-locale-scope.test.ts` pins every one of those outcomes. None of + * that made authoring `locale` do anything, because the second input the effect + * depends on — `config.locale` — had no supplier: every first-party call site + * built its request without one, so `filterByLocale` returned its input on its + * first line and `dataset.locale` was never read at all. + * + * ⭐ That is the EXACT shape `Seed.env` spent releases in (framework#4704, + * ledgered as `packages/spec/liveness/seed.json`'s specimen for the `producer` + * field, #4837), and it is why the assertions below are written against ROWS + * REACHING THE ENGINE rather than against the request object. A test that + * asserted "the config carries a locale" would be green on a build where the + * loader ignored it, which is the failure this whole family exists to catch: + * a green suite is not evidence an axis bites. + * + * The three call sites this pins are the three request builders in + * `app-plugin.ts` — inline boot seed, per-org replayer, dev hot-reload seeder. + * They are NOT all six that build a `SeedLoaderRequest` in this repo; the other + * three (package apply, draft publish, marketplace install) are publish/install + * -time paths in other declared file surfaces, and the ledger row records that + * split rather than claiming it away. + * + * ⛔ Not housed in `seed-loader.test.ts` — the natural home, held by PR #16783. + */ + +/** + * A read/insert-only engine double. It deliberately declares NEITHER `delete` + * NOR `update`: the seeds below are `upsert` into an empty store, so both + * dispatch verbs are unreachable, and `check:engine-double-contract`'s two + * slices are keyed on exactly those members. `findOne` still routes through the + * real producer-side predicate, as every double in this repo does. + */ +function createEngine() { + const store: Record = {}; + let idCounter = 0; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }), + findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return null; + }), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); + store[objectName].push(...records); + return records; + } + const record = { id: `gen-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + + return { engine, store }; +} + +function createMetadata(): IMetadataService { + const objects: Record = { + account: { name: 'account', fields: { name: { type: 'text' } } }, + plan_zh: { name: 'plan_zh', fields: { name: { type: 'text' } } }, + plan_en: { name: 'plan_en', fields: { name: { type: 'text' } } }, + }; + return { + getObject: vi.fn(async (name: string) => objects[name]), + listObjects: vi.fn(async () => Object.values(objects)), + register: vi.fn(async () => {}), + get: vi.fn(async () => undefined), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +/** Unscoped on both axes — loads under every locale. */ +const ACCOUNT_DATASET = { + object: 'account', + externalId: 'name', + mode: 'upsert', + records: [{ name: 'Acme Corporation' }], +}; + +/** The Chinese market's copy of the demo plans. */ +const PLAN_ZH_DATASET = { + object: 'plan_zh', + externalId: 'name', + mode: 'upsert', + locale: ['zh-CN'], + records: [{ name: '专业版' }], +}; + +/** The English market's copy of the same demo plans. */ +const PLAN_EN_DATASET = { + object: 'plan_en', + externalId: 'name', + mode: 'upsert', + locale: ['en'], + records: [{ name: 'Professional' }], +}; + +const ALL_DATASETS = [ACCOUNT_DATASET, PLAN_ZH_DATASET, PLAN_EN_DATASET]; + +describe('AppPlugin supplies SeedLoaderConfig.locale (#16595)', () => { + const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS; + const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED; + const OLD_NODE_ENV = process.env.NODE_ENV; + + let engine: IDataEngine; + let store: Record; + let metadata: IMetadataService; + let logger: { info: any; warn: any; error: any; debug: any }; + let services: Map; + let hooks: Map any>>; + + const makeContext = (): PluginContext => ({ + logger, + registerService: vi.fn((name: string, svc: unknown) => { services.set(name, svc); }), + getService: vi.fn((name: string) => { + if (name === 'objectql') return engine; + if (name === 'metadata') return metadata; + return services.get(name); + }), + getServices: vi.fn(() => services), + hook: vi.fn((event: string, fn: (payload: any) => any) => { + const list = hooks.get(event) ?? []; + list.push(fn); + hooks.set(event, list); + }), + trigger: vi.fn(), + }) as unknown as PluginContext; + + /** `objects`, in engine-insert order, that actually received rows. */ + const seededObjects = () => Object.keys(store).sort(); + + const bundle = (i18n?: unknown, datasets = ALL_DATASETS) => ({ + id: 'locale-producer-app', + ...(i18n ? { i18n } : {}), + data: datasets, + }); + + beforeEach(() => { + process.env.OS_INLINE_SEED_BUDGET_MS = '60000'; + delete process.env.OS_MULTI_ORG_ENABLED; + ({ engine, store } = createEngine()); + metadata = createMetadata(); + logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + services = new Map(); + hooks = new Map(); + }); + + afterEach(() => { + if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS; + else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET; + if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; + if (OLD_NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = OLD_NODE_ENV; + }); + + // ── Call site 1: the inline boot seed ─────────────────────────────────── + describe('inline boot seed', () => { + /** + * ⭐ THE NEGATIVE LEG. `plan_zh` is a dataset that loads TODAY on this + * boot path — before the wiring, `config.locale` was undefined, the + * filter returned its input, and its row landed on an `en` stack. It + * must not any more. + */ + it('drops a locale-scoped dataset the declared `i18n.defaultLocale` excludes', async () => { + const plugin = new AppPlugin(bundle({ defaultLocale: 'en' })); + + await plugin.start(makeContext()); + + expect(seededObjects()).toEqual(['account', 'plan_en']); + expect(store.plan_zh).toBeUndefined(); + }); + + it('keeps the dataset the declared locale selects, and drops the other market', async () => { + const plugin = new AppPlugin(bundle({ defaultLocale: 'zh-CN' })); + + await plugin.start(makeContext()); + + expect(seededObjects()).toEqual(['account', 'plan_zh']); + expect(store.plan_en).toBeUndefined(); + expect(store.plan_zh).toEqual([expect.objectContaining({ name: '专业版' })]); + }); + + /** + * The absence case, and the reason `warnOnUnresolvedLocaleScope` STAYS. + * An app that declares no `i18n` block sends no `locale` key at all, so + * the axis is inert and every dataset loads — the pre-#16510 behaviour, + * unchanged. Wiring a producer must not silently start dropping rows on + * stacks that never opted in, and the loader still says so out loud. + */ + it('sends no locale — and the loader warns — when the app declares none', async () => { + const plugin = new AppPlugin(bundle(undefined)); + + await plugin.start(makeContext()); + + expect(seededObjects()).toEqual(['account', 'plan_en', 'plan_zh']); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No locale was supplied'), + expect.anything(), + ); + }); + + /** An empty / non-string `defaultLocale` is an absence, not a filter. */ + it('treats a blank `defaultLocale` as no locale rather than as a filter', async () => { + const plugin = new AppPlugin(bundle({ defaultLocale: '' })); + + await plugin.start(makeContext()); + + expect(seededObjects()).toEqual(['account', 'plan_en', 'plan_zh']); + }); + + /** The legacy nested-manifest bundle shape resolves the same key. */ + it('reads `i18n.defaultLocale` off a nested `manifest` bundle too', async () => { + const plugin = new AppPlugin({ + manifest: { id: 'locale-producer-app', i18n: { defaultLocale: 'zh-CN' } }, + data: ALL_DATASETS, + }); + + await plugin.start(makeContext()); + + expect(seededObjects()).toEqual(['account', 'plan_zh']); + }); + }); + + // ── Call site 2: the per-org replayer ─────────────────────────────────── + describe('per-org replayer (multi-tenant provisioning)', () => { + it('filters the replayed datasets by the declared locale', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + const plugin = new AppPlugin(bundle({ defaultLocale: 'en' })); + + await plugin.start(makeContext()); + // Multi-tenant boot writes nothing inline — the replayer is the path. + expect(seededObjects()).toEqual([]); + + const replayer = services.get('seed-replayer') as (orgId: string) => Promise; + expect(typeof replayer).toBe('function'); + await replayer('org_1'); + + expect(seededObjects()).toEqual(['account', 'plan_en']); + expect(store.plan_zh).toBeUndefined(); + }); + }); + + // ── Call site 3: the dev hot-reload seeder ────────────────────────────── + describe('dev hot-reload seeder', () => { + it('filters the newly-registered objects’ seeds by the declared locale', async () => { + process.env.NODE_ENV = 'development'; + // Boot with NO datasets so every object below is "first seen". + const plugin = new AppPlugin(bundle({ defaultLocale: 'en' }, [])); + + await plugin.start(makeContext()); + + const reloaded = hooks.get('metadata:reloaded') ?? []; + expect(reloaded).toHaveLength(1); + + await reloaded[0]!({ + metadata: { + objects: [{ name: 'account' }, { name: 'plan_zh' }, { name: 'plan_en' }], + data: ALL_DATASETS, + }, + }); + + expect(seededObjects()).toEqual(['account', 'plan_en']); + expect(store.plan_zh).toBeUndefined(); + }); + }); +}); From 9eea0349aec7e24b2f847f50ae0fc42dd66bcb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:06:37 +0000 Subject: [PATCH 3/7] feat(spec): flip liveness seed.locale experimental -> live with a producer pointer --- packages/spec/liveness/seed.json | 9 +++++---- packages/spec/liveness/state-counts.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json index 08bf5a786d..6aa6a28523 100644 --- a/packages/spec/liveness/seed.json +++ b/packages/spec/liveness/seed.json @@ -1,6 +1,6 @@ { "type": "seed", - "_note": "SeedSchema. Live throughout except `locale`, which is `experimental` with its reason spelled out in its own row (consumer closed, producer not — the #4837 shape, declared rather than hidden). Consumer: SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts), reached on BOTH authoring paths: (1) boot/replay — the stack's `data:` collection lands in `manifest.data`, app-plugin.ts normalizes it and calls seedLoader.load() (packages/runtime/src/app-plugin.ts:832, :971), plus the per-org replayer registered for tenant provisioning; (2) runtime drafts — publishMetaItem applies a published `seed` draft through the same loader (packages/metadata-protocol/src/protocol.ts:6764, `skipSeedApply` opt-out for package batches). Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all six `path:NNN` citations in this file — five `evidence`, one `producer` — were re-anchored to their consuming symbols. Every one was wrong and every one was IN RANGE, in a 2,680-line file.", + "_note": "SeedSchema. Live throughout. `locale` was the one `experimental` row and 2026-09-09 (#16595) closed it, on the producer side — see that row. Consumer: SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts), reached on BOTH authoring paths: (1) boot/replay — the stack's `data:` collection lands in `manifest.data`, app-plugin.ts normalizes it and calls seedLoader.load() (packages/runtime/src/app-plugin.ts:832, :971), plus the per-org replayer registered for tenant provisioning; (2) runtime drafts — publishMetaItem applies a published `seed` draft through the same loader (packages/metadata-protocol/src/protocol.ts:6764, `skipSeedApply` opt-out for package batches). Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all six `path:NNN` citations in this file — five `evidence`, one `producer` — were re-anchored to their consuming symbols. Every one was wrong and every one was IN RANGE, in a 2,680-line file.", "props": { "object": { "status": "live", @@ -29,11 +29,12 @@ "note": "THE SPECIMEN THIS FIELD EXISTS FOR (#4837). Until #4704 this row was `live` on the consumer pointer alone and the verdict was FALSE: the cited line really did call filterByEnv, but none of the SIX call sites that build a SeedLoaderRequest (app boot, per-org replay, hot reload, package apply, draft publish, marketplace install) passed `env` — so `config.env` was permanently undefined, filterByEnv returned its input on its first line, and `dataset.env` was never read at all. `seed-loader.test.ts` passed throughout, because it supplies `config.env` itself: it exercised a mechanism nothing fed. #4704 fixed the wiring INSIDE `load()`, the one funnel every seeding path goes through, so call site seven cannot reopen the hole. Re-verified 2026-08-09 with both sides cited. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, BOTH halves. The evidence line `:191` had rotted onto `datasetAllowsEnv`'s own DOCBLOCK — the read was extracted out of `filterByEnv` into that helper — and the producer line `:174` onto `resolveSeedEnvFromNodeEnv`'s docblock while its parenthetical named `resolveEnvConfig, :1809`: a real symbol beside a line ~2,140 lines away from it. The specimen this row exists for (#4837) is unchanged; what had rotted is only where its two halves point, which is the failure the two-half shape was built to make visible. Re-closed by hand against 8cb96ec41." }, "locale": { - "status": "experimental", - "verifiedAt": "2026-09-07", + "status": "live", + "verifiedAt": "2026-09-09", "evidenceScope": "in-repo", "evidence": "packages/metadata-protocol/src/seed-loader.ts#datasetAllowsLocale (`const declared = dataset.locale` — the one read of the key; absence is the unrestricted spelling, because locales have no enumerable universe to default to the way `env` does); packages/metadata-protocol/src/seed-loader.ts#filterByLocale (drops the datasets it excludes, and always NAMES what it dropped)", - "note": "EXPERIMENTAL rather than `live`, and the distinction is exactly what the `producer` field one row up exists to force. The CONSUMER side is closed and tested (packages/metadata-protocol/src/seed-loader-locale-scope.test.ts pins both axes composing by conjunction, the inert-axis case, and the warning): the loader reads the key and reports every dataset it drops. The PRODUCER side is NOT closed in this repo — the second input the effect depends on is `SeedLoaderConfig.locale`, and none of the call sites that build a SeedLoaderRequest passes one yet (the three `seedLoader.load(request)` sites in packages/runtime/src/app-plugin.ts — boot, per-org replay, hot reload — plus the draft-publish path in packages/metadata-protocol/src/protocol.ts), so on the first-party boot path authoring `locale` changes nothing today. That is the shape `Seed.env` was in before framework#4704, so it is recorded as what it is instead of published `live` on a correct-but-insufficient consumer pointer (#4837). An embedding host that passes `config.locale` itself gets the full behaviour now, which is why this is not `planned` — nothing refuses the key, the loader honours it. What keeps it from being a SILENT no-op, the part #4704 did not have, is `SeedLoaderService#warnOnUnresolvedLocaleScope`: a load carrying locale-scoped datasets and no `config.locale` warns by name and names the remedy. Re-classify to `live` with a `producer` pointer when the runtime wiring lands; that wiring is in packages/runtime, outside the declared file surface of the PR that introduced this row, and is filed as its own card there." + "producer": "packages/runtime/src/app-plugin.ts#resolveSeedLocale (AppPlugin resolves the load-time locale ITSELF off the app's declared `i18n.defaultLocale` — the same ENVELOPE key, read the same way `loadTranslations` reads it for `setDefaultLocale` — and threads it into all THREE `SeedLoaderRequest`s this plugin builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. A blank or undeclared `defaultLocale` sends NO `locale` key rather than an `'en'` default, because absence is the loader's unrestricted spelling and a default would have turned a wiring change into a data change)", + "note": "FLIPPED experimental → live 2026-09-09 by #16595, which supplied the missing PRODUCER. The consumer half (#16510 / PR #16592) was always closed and tested; what was missing was the second input the effect depends on, `SeedLoaderConfig.locale`, which no first-party call site passed — so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all on the default boot path. That is the same shape `Seed.env` was in before framework#4704, which is why this row was recorded as `experimental` instead of published `live` on a correct-but-insufficient consumer pointer (#4837). EVIDENCE FOR THE FLIP, and it is a NEGATIVE leg rather than a green suite: `packages/runtime/src/app-plugin.seed-locale-producer.test.ts` asserts on the rows that reach the engine, not on the request object, and an ablation that neutralises `resolveSeedLocale` (returning `undefined`) turns 5 of its 7 cases red in the inert direction — a `locale: ['zh-CN']` dataset loads on an `en` stack. All three of this plugin's call sites go red, so the pin covers each one. ⚠️ THE SCOPE OF THIS `live`, stated rather than left to be discovered: this repo has SIX request builders, the same six the `env` row one entry up enumerates. #16595 wired the THREE in `packages/runtime/src/app-plugin.ts` — the default boot path, which is what makes authoring `locale` change runtime behaviour and therefore what makes this row `live`. The other three are publish/install-time paths and still pass no locale: packages/runtime/src/domains/packages.ts#applyPublishedSeeds (package apply), packages/metadata-protocol/src/protocol.ts#applySeedBodies (draft publish) and packages/cloud-connection/src/marketplace-install-local-plugin.ts#runInlineSeed (marketplace install). None of the three is handed the stack config, and none passes `env` either, so this is the SAME frontier the `live` `env` row already sits behind rather than a new one — but `env` crosses it because `resolveEnvConfig` can read an ambient `NODE_ENV` inside `load()`, and a locale has no ambient source, so it cannot be closed the same way. Filed as its own card. ⚠️ AND THEREFORE `SeedLoaderService#warnOnUnresolvedLocaleScope` STAYS (the judgement #16595 was asked to make, decided with its reason recorded here rather than deleted silently): it is not a signpost for an unwired state that has now gone away. It is the live diagnostic for the three call sites above, for any embedding host that builds its own request, and for a stack that declares no `i18n` block — all of which still reach `load()` with locale-scoped datasets and no `config.locale`. Deleting it would make exactly those paths silently inert, which is the property this row spent a release being. REPOINTED while flipping: the pre-flip text of this note named four call sites (the three in app-plugin.ts plus draft publish) and called the `protocol.ts` one unconfirmed. Re-derived against `main` at bb7d91f19f: the `protocol.ts` site is REAL — the earlier negative came from grepping `seedLoader.load`, and that call site names its local `loader` — and the enumeration was short by two (package apply, marketplace install). The full set is the six named above, found by scanning `SeedLoaderRequestSchema` rather than a variable name." }, "records": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index f62d091407..5575daa719 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -51,7 +51,7 @@ for both corollaries. | `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | | `job` | 15 | 0 | 0 | 1 | 0 | 16 | | `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | -| `seed` | 12 | 1 | 0 | 0 | 0 | 13 | +| `seed` | 13 | 0 | 0 | 0 | 0 | 13 | | `translation` | 23 | 0 | 0 | 0 | 2 | 25 | | `validation` | 18 | 0 | 0 | 0 | 0 | 18 | | `api` | 25 | 0 | 0 | 1 | 2 | 28 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **850** | **6** | **1** | **92** | **10** | **959** | +| **total** | **851** | **5** | **1** | **92** | **10** | **959** | From f5fe81989ed39999540386fdcaf272ae81ed913d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:08:38 +0000 Subject: [PATCH 4/7] docs: changeset for the Seed.locale producer wiring --- .changeset/seed-locale-producer-wiring.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/seed-locale-producer-wiring.md diff --git a/.changeset/seed-locale-producer-wiring.md b/.changeset/seed-locale-producer-wiring.md new file mode 100644 index 0000000000..458f5d4984 --- /dev/null +++ b/.changeset/seed-locale-producer-wiring.md @@ -0,0 +1,18 @@ +--- +"@objectstack/runtime": minor +"@objectstack/spec": patch +--- + +`AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + +The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + +- **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. +- **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. +- **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + +`SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + +The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + +⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. From 581ef0747efd577587bacd92e5e046e5c0d9560e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:22:53 +0000 Subject: [PATCH 5/7] chore: register the new pinned engine double for the locale-producer test --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6475778e8b..ef9dad6957 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3321,6 +3321,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/runtime/src/app-plugin.seed-locale-producer.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/domains/meta-published-runtime-publish.test.ts", "verb": "delete", From d2aad7b3746ad7cc00a8c882d6f7822944a0b220 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:36:00 +0000 Subject: [PATCH 6/7] docs(data-modeling): name i18n.defaultLocale as the seed locale's default supplier --- content/docs/data-modeling/seed-data.mdx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 5b9fb36ec8..4c1352e1d0 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -215,11 +215,21 @@ is made when the seeds load rather than when your config is assembled — so switching markets does not mean rebuilding, and the axis is evaluated in the one layer that could ever reconcile rows already written for another market. + + **Where the loading locale comes from.** On the normal boot path your stack + supplies it: the runtime reads your app's `i18n.defaultLocale` and passes it to + the seed loader, so declaring a locale on a dataset takes effect with no extra + wiring. Declare no `i18n` block and no locale is sent at all — which is the + unrestricted setting, not a filter. + + The axis is evaluated against the seed loader's `config.locale`. A host that - supplies no locale gets **every** dataset, and the loader warns naming each - locale-scoped dataset it let through — so a scope that is not taking effect is - one log line to diagnose rather than a silent no-op. + supplies no locale — an app with no declared `i18n.defaultLocale`, or an + embedding host that builds its own seed-load request — gets **every** dataset, + and the loader warns naming each locale-scoped dataset it let through, so a + scope that is not taking effect is one log line to diagnose rather than a + silent no-op. --- From 06a16c71839eb48d9003a3e80009cdab7632fe32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:26:46 +0000 Subject: [PATCH 7/7] docs(liveness, changeset): state what seed.locale's live asserts and does not, and flag the release-note reconciliation --- .changeset/seed-locale-producer-wiring.md | 2 ++ packages/spec/liveness/seed.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/seed-locale-producer-wiring.md b/.changeset/seed-locale-producer-wiring.md index 458f5d4984..a5e6555ec4 100644 --- a/.changeset/seed-locale-producer-wiring.md +++ b/.changeset/seed-locale-producer-wiring.md @@ -15,4 +15,6 @@ The locale filter axis landed complete on the consumer side: the loader reads `S The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. +⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json index 6aa6a28523..c86a909bc1 100644 --- a/packages/spec/liveness/seed.json +++ b/packages/spec/liveness/seed.json @@ -34,7 +34,7 @@ "evidenceScope": "in-repo", "evidence": "packages/metadata-protocol/src/seed-loader.ts#datasetAllowsLocale (`const declared = dataset.locale` — the one read of the key; absence is the unrestricted spelling, because locales have no enumerable universe to default to the way `env` does); packages/metadata-protocol/src/seed-loader.ts#filterByLocale (drops the datasets it excludes, and always NAMES what it dropped)", "producer": "packages/runtime/src/app-plugin.ts#resolveSeedLocale (AppPlugin resolves the load-time locale ITSELF off the app's declared `i18n.defaultLocale` — the same ENVELOPE key, read the same way `loadTranslations` reads it for `setDefaultLocale` — and threads it into all THREE `SeedLoaderRequest`s this plugin builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. A blank or undeclared `defaultLocale` sends NO `locale` key rather than an `'en'` default, because absence is the loader's unrestricted spelling and a default would have turned a wiring change into a data change)", - "note": "FLIPPED experimental → live 2026-09-09 by #16595, which supplied the missing PRODUCER. The consumer half (#16510 / PR #16592) was always closed and tested; what was missing was the second input the effect depends on, `SeedLoaderConfig.locale`, which no first-party call site passed — so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all on the default boot path. That is the same shape `Seed.env` was in before framework#4704, which is why this row was recorded as `experimental` instead of published `live` on a correct-but-insufficient consumer pointer (#4837). EVIDENCE FOR THE FLIP, and it is a NEGATIVE leg rather than a green suite: `packages/runtime/src/app-plugin.seed-locale-producer.test.ts` asserts on the rows that reach the engine, not on the request object, and an ablation that neutralises `resolveSeedLocale` (returning `undefined`) turns 5 of its 7 cases red in the inert direction — a `locale: ['zh-CN']` dataset loads on an `en` stack. All three of this plugin's call sites go red, so the pin covers each one. ⚠️ THE SCOPE OF THIS `live`, stated rather than left to be discovered: this repo has SIX request builders, the same six the `env` row one entry up enumerates. #16595 wired the THREE in `packages/runtime/src/app-plugin.ts` — the default boot path, which is what makes authoring `locale` change runtime behaviour and therefore what makes this row `live`. The other three are publish/install-time paths and still pass no locale: packages/runtime/src/domains/packages.ts#applyPublishedSeeds (package apply), packages/metadata-protocol/src/protocol.ts#applySeedBodies (draft publish) and packages/cloud-connection/src/marketplace-install-local-plugin.ts#runInlineSeed (marketplace install). None of the three is handed the stack config, and none passes `env` either, so this is the SAME frontier the `live` `env` row already sits behind rather than a new one — but `env` crosses it because `resolveEnvConfig` can read an ambient `NODE_ENV` inside `load()`, and a locale has no ambient source, so it cannot be closed the same way. Filed as its own card. ⚠️ AND THEREFORE `SeedLoaderService#warnOnUnresolvedLocaleScope` STAYS (the judgement #16595 was asked to make, decided with its reason recorded here rather than deleted silently): it is not a signpost for an unwired state that has now gone away. It is the live diagnostic for the three call sites above, for any embedding host that builds its own request, and for a stack that declares no `i18n` block — all of which still reach `load()` with locale-scoped datasets and no `config.locale`. Deleting it would make exactly those paths silently inert, which is the property this row spent a release being. REPOINTED while flipping: the pre-flip text of this note named four call sites (the three in app-plugin.ts plus draft publish) and called the `protocol.ts` one unconfirmed. Re-derived against `main` at bb7d91f19f: the `protocol.ts` site is REAL — the earlier negative came from grepping `seedLoader.load`, and that call site names its local `loader` — and the enumeration was short by two (package apply, marketplace install). The full set is the six named above, found by scanning `SeedLoaderRequestSchema` rather than a variable name." + "note": "FLIPPED experimental → live 2026-09-09 by #16595, which supplied the missing PRODUCER. The consumer half (#16510 / PR #16592) was always closed and tested; what was missing was the second input the effect depends on, `SeedLoaderConfig.locale`, which no first-party call site passed — so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all on the default boot path. That is the same shape `Seed.env` was in before framework#4704, which is why this row was recorded as `experimental` instead of published `live` on a correct-but-insufficient consumer pointer (#4837). EVIDENCE FOR THE FLIP, and it is a NEGATIVE leg rather than a green suite: `packages/runtime/src/app-plugin.seed-locale-producer.test.ts` asserts on the rows that reach the engine, not on the request object, and an ablation that neutralises `resolveSeedLocale` (returning `undefined`) turns 5 of its 7 cases red in the inert direction — a `locale: ['zh-CN']` dataset loads on an `en` stack. All three of this plugin's call sites go red, so the pin covers each one. ⚠️ WHAT THIS `live` ASSERTS, AND WHAT IT DOES NOT — read this before citing the row. It ASSERTS the ledger's own criterion and nothing wider: authoring `locale` changes runtime behaviour, on the boot path a `defineStack()` app actually boots through, and the `producer` field names the code that makes that true. ⛔ It does NOT assert that every path which can reach `SeedLoaderService.load()` supplies a locale — three do not, they are named below, and a reader who takes `live` as \"the axis is honoured everywhere\" is reading something this row does not say. That is the distinction between this row and the #4837 specimen one entry up: `Seed.env` was `live` while ZERO of six call sites supplied it, so authoring the key changed nothing anywhere and the verdict was false in the only sense the criterion has. Here it is true, and bounded, and the bound is written down. ⚠️ THE SCOPE OF THIS `live`, stated rather than left to be discovered: this repo has SIX request builders, the same six the `env` row one entry up enumerates. #16595 wired the THREE in `packages/runtime/src/app-plugin.ts` — the default boot path, which is what makes authoring `locale` change runtime behaviour and therefore what makes this row `live`. The other three are publish/install-time paths and still pass no locale: packages/runtime/src/domains/packages.ts#applyPublishedSeeds (package apply), packages/metadata-protocol/src/protocol.ts#applySeedBodies (draft publish) and packages/cloud-connection/src/marketplace-install-local-plugin.ts#runInlineSeed (marketplace install). None of the three is handed the stack config, and none passes `env` either, so this is the SAME frontier the `live` `env` row already sits behind rather than a new one — but `env` crosses it because `resolveEnvConfig` can read an ambient `NODE_ENV` inside `load()`, and a locale has no ambient source, so it cannot be closed the same way. Filed as its own card. ⚠️ AND THEREFORE `SeedLoaderService#warnOnUnresolvedLocaleScope` STAYS (the judgement #16595 was asked to make, decided with its reason recorded here rather than deleted silently): it is not a signpost for an unwired state that has now gone away. It is the live diagnostic for the three call sites above, for any embedding host that builds its own request, and for a stack that declares no `i18n` block — all of which still reach `load()` with locale-scoped datasets and no `config.locale`. Deleting it would make exactly those paths silently inert, which is the property this row spent a release being. REPOINTED while flipping: the pre-flip text of this note named four call sites (the three in app-plugin.ts plus draft publish) and called the `protocol.ts` one unconfirmed. Re-derived against `main` at bb7d91f19f: the `protocol.ts` site is REAL — the earlier negative came from grepping `seedLoader.load`, and that call site names its local `loader` — and the enumeration was short by two (package apply, marketplace install). The full set is the six named above, found by scanning `SeedLoaderRequestSchema` rather than a variable name." }, "records": { "status": "live",