diff --git a/.changeset/seed-locale-axis.md b/.changeset/seed-locale-axis.md
new file mode 100644
index 0000000000..246d614809
--- /dev/null
+++ b/.changeset/seed-locale-axis.md
@@ -0,0 +1,16 @@
+---
+"@objectstack/spec": minor
+"@objectstack/metadata-protocol": minor
+---
+
+Seed datasets gain a `locale` filter axis, composed with `env` by the loader.
+
+An app shipping demo data for two language markets — the same records, different display strings — had no declarative way to say which dataset applies. `SeedSchema` is a `strictObject`, so the app could not add the key itself; the selection had to happen in application code while the config was assembled. That is the wrong layer twice over: the choice is cached in the build output (switching markets means deleting `dist`), and because every profile is an `upsert` and the loader only writes, the other market's rows stay resident in the database.
+
+- **`Seed.locale?: string[]`** — BCP-47 tags scoping the dataset to one or more language markets. **Omitted means every locale.** Unlike `env`, whose three environments are a closed set that can be spelled out as a default, locales are open-ended tags with no enumerable universe — so absence, not a default array, is what carries "unrestricted". An empty array is rejected: a dataset that applies nowhere is an authoring mistake, the same reasoning that already governs a composite `externalId`. `locales`, `language` and `languages` are aliased onto it, matching the existing `environment` / `environments` → `env` pair.
+- **`SeedLoaderConfig.locale?: string`** — the tag the load filters on.
+- **The loader composes both axes by conjunction.** A dataset is loaded when it passes `env` **and** `locale`; neither axis can rescue a dataset the other excluded. `filterByLocale` mirrors `filterByEnv` down to the reporting posture — skipping is the declared, intended outcome, so it logs at `info`, but it always names what it dropped. Tags compare case-insensitively (BCP-47 casing is a convention, not part of a tag's identity) and otherwise exactly: `['zh']` does not match `zh-CN`, and widening that would be the lenient consumer-side fallback the contract-first rule forbids.
+
+The platform still translates nothing and merges nothing. The app authors both record sets; this adds only the axis that selects between them.
+
+**What is not wired yet, stated plainly.** The locale axis is evaluated against `config.locale`, and no first-party call site supplies one — the runtime wiring that would resolve it from the stack's configured locale is a separate change in `packages/runtime`. An embedding host that passes `config.locale` itself gets the full behaviour today; on the default boot path the axis is inert. That is the shape `Seed.env` was in before framework#4704, so it is not left silent: a load carrying locale-scoped datasets and no `config.locale` warns naming each dataset it let through and the config key that would make the scope take effect. The liveness ledger records `seed.locale` as `experimental` for exactly this reason, with the consumer side cited and the producer gap spelled out, rather than claiming `live` on a correct-but-insufficient consumer pointer.
diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx
index df9654b89a..5b9fb36ec8 100644
--- a/content/docs/data-modeling/seed-data.mdx
+++ b/content/docs/data-modeling/seed-data.mdx
@@ -177,6 +177,53 @@ defineSeed(TestUser, {
---
+## Locale Scoping
+
+The `locale` array scopes a dataset to one or more language markets, as BCP-47
+tags. It is a second filter axis alongside `env`, and the two **compose**: a
+dataset loads when it passes `env` *and* `locale`.
+
+Omitting `locale` means **every locale** — unlike `env` there is no default
+array, because locales are open-ended tags with no closed set to spell out.
+
+```typescript
+// Reference data — every market (locale omitted)
+defineSeed(Country, {
+ records: [{ code: 'US', name: 'United States' }],
+});
+
+// The Chinese market's demo plans
+defineSeed(Plan, {
+ locale: ['zh-CN'],
+ records: [{ name: '专业版', price: 99 }],
+});
+
+// The same plans for English-speaking markets
+defineSeed(Plan, {
+ locale: ['en', 'en-GB'],
+ records: [{ name: 'Professional', price: 15 }],
+});
+```
+
+Tags are matched **case-insensitively** (`zh-cn` and `zh-CN` are the same tag)
+and otherwise **exactly** — `['zh']` does not match a loading locale of
+`zh-CN`. List every tag the dataset is for.
+
+The platform does not translate anything. `locale` only selects between record
+sets you authored yourself; both sets stay in your source tree, and the choice
+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.
+
+
+ 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.
+
+
+---
+
## Type Safety
`defineSeed()` infers valid field keys from the object definition you pass as the
@@ -443,6 +490,13 @@ Keep demo and test-only records out of production by setting `env: ['dev', 'test
System bootstrap data that must exist in production should omit `env` (or explicitly
set `['prod', 'dev', 'test']`).
+### Ship one dataset per market, not one build per market
+
+When the same records need different display strings per language, author both
+datasets and scope each with `locale`. Selecting between them in application
+code instead bakes the choice into your build output and leaves the other
+market's rows resident in the database on a switch.
+
### Use `upsert` by default
`upsert` is idempotent and the safest default. Only change the mode when the use
@@ -477,6 +531,7 @@ function defineSeed<
externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name'
mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
+ locale?: string[]; // BCP-47 tags; omitted = every locale
records: Array>>;
}
): Seed
diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx
index 1d9e7c2722..b6d6f0f32b 100644
--- a/content/docs/references/data/seed-loader.mdx
+++ b/content/docs/references/data/seed-loader.mdx
@@ -196,6 +196,7 @@ Seed data loader configuration
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
+| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |
@@ -228,6 +229,7 @@ Seed loader request with datasets and configuration
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
+| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
| **records** | `Record[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
@@ -248,6 +250,7 @@ Seed loader request with datasets and configuration
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
+| **locale** | `string` | optional | Only load datasets scoped to this locale (BCP-47 tag) |
| **organizationId** | `string` | optional | Target organization id for per-tenant seed replay |
| **identity** | `{ user?: object; org?: object }` | optional | Identity bound to os.user / os.org when resolving CEL seed values |
diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx
index 88a25b1a00..21dce6e57c 100644
--- a/content/docs/references/data/seed.mdx
+++ b/content/docs/references/data/seed.mdx
@@ -31,6 +31,7 @@ const result = SeedSchema.parse(data);
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
+| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
| **records** | `Record[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx
index ab9d0adc2d..40658d652e 100644
--- a/content/docs/references/kernel/manifest.mdx
+++ b/content/docs/references/kernel/manifest.mdx
@@ -88,6 +88,7 @@ Structured plugin permission grants (ADR-0025 §3.2)
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
+| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale |
| **records** | `Record[]` | ✅ | Data records |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
diff --git a/packages/metadata-protocol/src/seed-loader-locale-scope.test.ts b/packages/metadata-protocol/src/seed-loader-locale-scope.test.ts
new file mode 100644
index 0000000000..5d619224d3
--- /dev/null
+++ b/packages/metadata-protocol/src/seed-loader-locale-scope.test.ts
@@ -0,0 +1,323 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { SeedLoaderService } from './seed-loader.js';
+import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
+import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
+
+/**
+ * `Seed.locale` is a dataset filter axis, composed with `Seed.env` (#16510).
+ *
+ * An app shipping demo data for two language markets used to have to pick
+ * between the two seed profiles while its config was ASSEMBLED — which bakes
+ * the choice into `dist` (switching profiles means deleting the build output)
+ * and leaves the other profile's rows resident in the database, because every
+ * profile is an upsert and the loader only writes.
+ *
+ * The axis moves that decision to load time. Deliberately NOT in scope: the
+ * platform translates nothing and merges nothing — the app authors both record
+ * sets and this only selects between them.
+ *
+ * Mirrors `seed-loader-env-scope.test.ts`, the `env`-axis precedent, and pins
+ * the outcomes SEPARATELY for the same reason it does: a filter that dropped
+ * everything, or nothing, would satisfy any one of them alone.
+ * 1. locale-scoped dataset + matching locale → seeded
+ * 2. locale-scoped dataset + non-matching locale → NOT seeded
+ * 3. no `locale` declared → seeded under every locale
+ * 4. no `config.locale` → axis inert, but LOUD
+ * 5. the two axes COMPOSE by conjunction — neither rescues what the other cut
+ */
+
+function createLogger() {
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
+}
+
+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;
+ }),
+ update: vi.fn(async (_o: string, data: any, options?: any) => {
+ assertEngineUpdateDispatch(data, options);
+ return data;
+ }),
+ delete: vi.fn(async (_objectName: string, options?: any) => {
+ assertEngineDeleteDispatch(options);
+ return { deleted: 1 };
+ }),
+ 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' } } },
+ demo_user: { name: 'demo_user', 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;
+}
+
+const BASE_CONFIG = {
+ dryRun: false,
+ haltOnError: false,
+ multiPass: true,
+ defaultMode: 'upsert',
+ batchSize: 1000,
+ transaction: false,
+} as any;
+
+/** Unscoped on BOTH axes — the schema defaults. */
+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 thing. */
+const PLAN_EN_DATASET = {
+ object: 'plan_en',
+ externalId: 'name',
+ mode: 'upsert',
+ locale: ['en', 'en-GB'],
+ records: [{ name: 'Professional' }],
+};
+
+async function seedUnder(locale: string | undefined, seeds: any[], configOverrides: any = {}) {
+ const { engine, store } = createEngine();
+ const logger = createLogger();
+ const result = await new SeedLoaderService(engine, createMetadata(), logger).load({
+ seeds,
+ config: { ...BASE_CONFIG, ...(locale === undefined ? {} : { locale }), ...configOverrides },
+ } as any);
+
+ return { result, store, logger };
+}
+
+const seededObjects = (store: Record) => Object.keys(store).sort();
+
+const localeWarning = (logger: any) =>
+ logger.warn.mock.calls.map((c: any[]) => String(c[0])).find((m: string) => m.includes('No locale was supplied'));
+
+describe('Seed.locale scopes a dataset to a language market (#16510)', () => {
+ // The env axis reads NODE_ENV, so pin it to a determinate value: these cases
+ // are about the LOCALE axis, and an indeterminate environment would put a
+ // second warning in the log that the assertions here should not have to
+ // step around.
+ let savedNodeEnv: string | undefined;
+
+ beforeEach(() => {
+ savedNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ });
+
+ afterEach(() => {
+ if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = savedNodeEnv;
+ });
+
+ // ── 1. The capability itself ────────────────────────────────────────────
+
+ it('seeds only the dataset scoped to the loading locale', async () => {
+ const { result, store } = await seedUnder('zh-CN', [ACCOUNT_DATASET, PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+
+ expect(seededObjects(store)).toEqual(['account', 'plan_zh']);
+ expect(store.plan_en).toBeUndefined();
+ expect(result.summary.objectsProcessed).toBe(2);
+ });
+
+ it('seeds the OTHER dataset under the other locale', async () => {
+ const { store } = await seedUnder('en', [ACCOUNT_DATASET, PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+
+ expect(seededObjects(store)).toEqual(['account', 'plan_en']);
+ expect(store.plan_zh).toBeUndefined();
+ });
+
+ // The two above must DIFFER — a filter that dropped every scoped dataset, or
+ // none of them, would satisfy one of them alone.
+ it('produces a different result per locale', async () => {
+ const zh = await seedUnder('zh-CN', [PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+ const en = await seedUnder('en', [PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+
+ expect(seededObjects(zh.store)).toEqual(['plan_zh']);
+ expect(seededObjects(en.store)).toEqual(['plan_en']);
+ expect(seededObjects(zh.store)).not.toEqual(seededObjects(en.store));
+ });
+
+ it('honours a multi-locale scope that includes the loading locale', async () => {
+ const underGb = await seedUnder('en-GB', [PLAN_EN_DATASET]);
+ const underZh = await seedUnder('zh-CN', [PLAN_EN_DATASET]);
+
+ expect(underGb.store.plan_en).toHaveLength(1);
+ expect(underZh.store.plan_en).toBeUndefined();
+ });
+
+ // ── 2. Unscoped datasets are untouched (no behaviour change) ────────────
+
+ it('treats a dataset with no locale key as applying to every locale', async () => {
+ for (const locale of ['zh-CN', 'en', 'ja-JP']) {
+ const { store } = await seedUnder(locale, [ACCOUNT_DATASET]);
+ expect(store.account, `account should seed under locale=${locale}`).toHaveLength(1);
+ }
+ });
+
+ // ── 3. Tag matching: case-insensitive, and otherwise exact ──────────────
+
+ it('matches BCP-47 tags case-insensitively', async () => {
+ // RFC 5646 casing is a convention, not part of the tag's identity.
+ const { store } = await seedUnder('zh-cn', [PLAN_ZH_DATASET]);
+ expect(store.plan_zh).toHaveLength(1);
+ });
+
+ it('does NOT treat a primary language as matching a region-qualified tag', async () => {
+ // `zh` is a different tag from `zh-CN`. Declaring both is the author's job;
+ // widening the match here would be exactly the lenient consumer-side
+ // fallback the contract-first rule forbids.
+ const { store } = await seedUnder('zh', [PLAN_ZH_DATASET]);
+ expect(store.plan_zh).toBeUndefined();
+ });
+
+ // ── 4. No locale supplied: inert, but loud ─────────────────────────────
+
+ it('seeds every dataset BUT warns when no config.locale was supplied', async () => {
+ const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET, PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+
+ // Inert: the pre-existing behaviour for a host that knows nothing about
+ // locales; fail-closed here would silently drop rows on every such host.
+ expect(seededObjects(store)).toEqual(['account', 'plan_en', 'plan_zh']);
+
+ // Loud: names the datasets AND the remedy. `Seed.env` spent releases being
+ // authorable and inert with no diagnostic at all (framework#4704).
+ const warning = localeWarning(logger);
+ expect(warning).toBeDefined();
+ expect(warning).toContain('plan_zh');
+ expect(warning).toContain('plan_en');
+ expect(warning).toContain('config.locale');
+ // The unscoped dataset is not the operator's problem — don't name it.
+ expect(warning).not.toContain('account (locale:');
+ });
+
+ it('stays SILENT when no locale was supplied and nothing is locale-scoped', async () => {
+ const { store, logger } = await seedUnder(undefined, [ACCOUNT_DATASET]);
+
+ expect(store.account).toHaveLength(1);
+ expect(localeWarning(logger)).toBeUndefined();
+ });
+
+ it('does not warn once a locale IS supplied', async () => {
+ const { logger } = await seedUnder('zh-CN', [PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+ expect(localeWarning(logger)).toBeUndefined();
+ });
+
+ // ── 5. Skipping is reported, never mysterious ──────────────────────────
+
+ it('names every locale-skipped dataset so missing rows are one log line to explain', async () => {
+ const { logger } = await seedUnder('zh-CN', [PLAN_ZH_DATASET, PLAN_EN_DATASET]);
+
+ const info = logger.info.mock.calls.map((c: any[]) => String(c[0])).find((m: string) => m.includes("Locale 'zh-CN'"));
+ expect(info).toBeDefined();
+ expect(info).toContain('plan_en');
+ expect(info).toContain('skipped');
+ });
+
+ // ── 6. The two axes compose by CONJUNCTION ─────────────────────────────
+
+ describe('composition with the env axis', () => {
+ /** Scoped on both axes: dev-only, Chinese-only. */
+ const DEV_ZH = {
+ object: 'demo_user',
+ externalId: 'name',
+ mode: 'upsert',
+ env: ['dev'],
+ locale: ['zh-CN'],
+ records: [{ name: '演示用户' }],
+ };
+
+ it('seeds a doubly-scoped dataset when it passes BOTH axes', async () => {
+ process.env.NODE_ENV = 'development';
+ const { store } = await seedUnder('zh-CN', [DEV_ZH]);
+ expect(store.demo_user).toHaveLength(1);
+ });
+
+ it('drops it when it passes env but FAILS locale', async () => {
+ process.env.NODE_ENV = 'development';
+ const { store } = await seedUnder('en', [DEV_ZH]);
+ expect(store.demo_user).toBeUndefined();
+ });
+
+ it('drops it when it passes locale but FAILS env', async () => {
+ process.env.NODE_ENV = 'production';
+ const { store } = await seedUnder('zh-CN', [DEV_ZH]);
+ expect(store.demo_user).toBeUndefined();
+ });
+
+ it('drops it when it fails both', async () => {
+ process.env.NODE_ENV = 'production';
+ const { store } = await seedUnder('en', [DEV_ZH]);
+ expect(store.demo_user).toBeUndefined();
+ });
+
+ // All four outcomes above must be reachable from ONE dataset definition —
+ // otherwise a filter that ignored one axis entirely would still pass three
+ // of the four.
+ it('distinguishes all four (env × locale) outcomes', async () => {
+ const seededIn = async (nodeEnv: string, locale: string) => {
+ process.env.NODE_ENV = nodeEnv;
+ const { store } = await seedUnder(locale, [DEV_ZH]);
+ return store.demo_user !== undefined;
+ };
+
+ expect([
+ await seededIn('development', 'zh-CN'),
+ await seededIn('development', 'en'),
+ await seededIn('production', 'zh-CN'),
+ await seededIn('production', 'en'),
+ ]).toEqual([true, false, false, false]);
+ });
+ });
+});
diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts
index c08c1f0977..7772349ec0 100644
--- a/packages/metadata-protocol/src/seed-loader.ts
+++ b/packages/metadata-protocol/src/seed-loader.ts
@@ -268,6 +268,51 @@ function isEnvScopedDataset(dataset: Seed): boolean {
return ALL_SEED_ENVS.some(e => !declared.includes(e));
}
+/**
+ * Fold a BCP-47 tag for comparison.
+ *
+ * Tags are case-INSENSITIVE by definition (RFC 5646 §2.1.1: the recommended
+ * casing is a convention, not part of the identity), so `zh-cn` and `zh-CN` are
+ * the same locale and must compare equal. That is normalization of a
+ * case-insensitive identifier, not consumer-side tolerance of a second dialect
+ * of our own contract — nothing else about the tag is rewritten, so `zh` still
+ * does NOT match `zh-CN`.
+ */
+function normalizeLocaleTag(tag: string): string {
+ return tag.trim().toLowerCase();
+}
+
+/**
+ * Does this dataset apply to `locale`?
+ *
+ * A dataset carrying no `locale` at all is unrestricted — and unlike `env`,
+ * that is the SCHEMA's own answer rather than a default array: locales are
+ * open-ended BCP-47 tags with no enumerable universe to default to, so
+ * `SeedSchema.locale` is optional and absence is what carries "every locale".
+ */
+function datasetAllowsLocale(dataset: Seed, locale: string): boolean {
+ const declared = dataset.locale as string[] | undefined;
+ if (!Array.isArray(declared)) return true;
+ const wanted = normalizeLocaleTag(locale);
+ return declared.some(tag => normalizeLocaleTag(tag) === wanted);
+}
+
+/**
+ * True when a dataset declared a locale scope at all — i.e. the only datasets
+ * for which a resolvable locale changes anything, and therefore the only ones
+ * worth warning about when none was supplied. The mirror of
+ * {@link isEnvScopedDataset}, simpler only because absence IS the unrestricted
+ * spelling here.
+ */
+function isLocaleScopedDataset(dataset: Seed): boolean {
+ return Array.isArray(dataset.locale as string[] | undefined);
+}
+
+/** Render a dataset's declared locale scope for a log line. */
+function localeScopeLabel(dataset: Seed): string {
+ return `${dataset.object} (locale: ${(dataset.locale as string[]).join(', ')})`;
+}
+
/**
* SeedLoaderService — Runtime implementation of ISeedLoaderService
*
@@ -468,6 +513,12 @@ export class SeedLoaderService implements ISeedLoaderService {
// read at all. Gating at those call sites instead would leave call site
// seven free to re-open the same hole (framework#4704).
const config = this.resolveEnvConfig(request.config, request.seeds);
+ // The locale axis has nothing to resolve FROM — there is no `NODE_ENV` of
+ // locales, and minting one would trade a declared-and-unset key for another
+ // — so it is inert unless the host supplies `config.locale`. That is
+ // exactly the shape `Seed.env` was in before framework#4704, which is why
+ // it is signposted here rather than left silent.
+ this.warnOnUnresolvedLocaleScope(config, request.seeds);
const allErrors: ReferenceResolutionError[] = [];
const allResults: SeedLoadResultParsed[] = [];
// Per-load counter — a service instance can be reused across loads.
@@ -486,8 +537,8 @@ export class SeedLoaderService implements ISeedLoaderService {
this.fallbackOrgId =
config.organizationId == null ? await this.resolveSoleOrganizationId() : undefined;
- // 1. Filter datasets by environment
- const datasets = this.filterByEnv(request.seeds, config.env);
+ // 1. Filter datasets by the scope axes (environment AND locale)
+ const datasets = this.filterDatasets(request.seeds, config);
if (datasets.length === 0) {
return this.buildEmptyResult(config, Date.now() - startTime);
@@ -2475,6 +2526,77 @@ export class SeedLoaderService implements ISeedLoaderService {
return config;
}
+ /**
+ * Say so when datasets narrowed their locale scope and no locale was supplied.
+ *
+ * The locale axis is PERMISSIVE when indeterminate, for the same reason the
+ * environment axis is: fail-closed would drop every locale-scoped dataset on
+ * a host that simply does not pass a locale, which is a silent data-loss
+ * regression strictly worse than the over-seeding it prevents.
+ *
+ * What it is not allowed to be is silent. `Seed.env` spent releases
+ * authorable, defaulted, type-checked and completely inert because no call
+ * site ever passed `config.env` (framework#4704) — and an author writing
+ * `locale: ['zh-CN']` who silently receives every dataset is that failure
+ * again, one axis over. Unlike `env` there is no `NODE_ENV` to resolve from,
+ * so the remedy this names is the config key rather than a variable to
+ * export.
+ */
+ private warnOnUnresolvedLocaleScope(config: SeedLoaderConfigParsed, seeds: Seed[]): void {
+ if (config.locale) return;
+
+ const scoped = seeds.filter(isLocaleScopedDataset);
+ if (scoped.length === 0) return;
+
+ this.logger.warn(
+ `[SeedLoader] No locale was supplied — this load carries no \`config.locale\`, so ` +
+ `${scoped.length} locale-scoped dataset(s) were seeded for EVERY locale instead of only ` +
+ `where they are declared: ${scoped.map(localeScopeLabel).join('; ')}. Pass ` +
+ `\`config.locale\` (a BCP-47 tag, e.g. the stack's \`i18n.defaultLocale\`) to make ` +
+ `\`Seed.locale\` take effect.`,
+ { scoped: scoped.map(d => d.object) },
+ );
+ }
+
+ /**
+ * Drop datasets that do not apply to this load, on every scope axis.
+ *
+ * The axes COMPOSE by conjunction: a dataset is kept when it passes `env`
+ * **and** `locale`. They stay separate functions — and separate log lines —
+ * because the two answer different operator questions ("why are my demo rows
+ * missing in production" vs "why did the Chinese dataset load"), and a single
+ * merged message would have to name a reason it did not measure.
+ */
+ private filterDatasets(datasets: Seed[], config: SeedLoaderConfigParsed): Seed[] {
+ return this.filterByLocale(this.filterByEnv(datasets, config.env), config.locale);
+ }
+
+ /**
+ * Drop datasets that do not apply to the resolved locale.
+ *
+ * The mirror of {@link filterByEnv}, down to the reporting posture: skipping
+ * is the declared, intended outcome of `locale: ['zh-CN']`, so it logs at
+ * `info` — but it always NAMES what it dropped.
+ */
+ private filterByLocale(datasets: Seed[], locale?: string): Seed[] {
+ if (!locale) return datasets;
+
+ const kept: Seed[] = [];
+ const skipped: Seed[] = [];
+ for (const dataset of datasets) {
+ (datasetAllowsLocale(dataset, locale) ? kept : skipped).push(dataset);
+ }
+
+ if (skipped.length > 0) {
+ this.logger.info(
+ `[SeedLoader] Locale '${locale}': skipped ${skipped.length} dataset(s) scoped to other ` +
+ `locales: ${skipped.map(localeScopeLabel).join('; ')}`,
+ { locale, skipped: skipped.map(d => d.object) },
+ );
+ }
+ return kept;
+ }
+
/**
* Drop datasets that do not apply to the resolved environment.
*
diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json
index eec19e9464..5e552d2b60 100644
--- a/packages/spec/authorable-surface/data.json
+++ b/packages/spec/authorable-surface/data.json
@@ -801,6 +801,7 @@
"data/Seed:_provenance",
"data/Seed:env",
"data/Seed:externalId",
+ "data/Seed:locale",
"data/Seed:mode",
"data/Seed:object",
"data/Seed:records",
@@ -824,6 +825,7 @@
"data/SeedLoaderConfig:env",
"data/SeedLoaderConfig:haltOnError",
"data/SeedLoaderConfig:identity",
+ "data/SeedLoaderConfig:locale",
"data/SeedLoaderConfig:multiPass",
"data/SeedLoaderConfig:organizationId",
"data/SeedLoaderConfig:transaction",
diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json
index a25907adf9..08bf5a786d 100644
--- a/packages/spec/liveness/seed.json
+++ b/packages/spec/liveness/seed.json
@@ -1,6 +1,6 @@
{
"type": "seed",
- "_note": "SeedSchema. Fully live — the smallest and healthiest surface in the ledger. 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 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.",
"props": {
"object": {
"status": "live",
@@ -28,6 +28,13 @@
"producer": "packages/metadata-protocol/src/seed-loader.ts#resolveEnvConfig (`load()` resolves the comparison environment ITSELF — `resolveSeedEnvFromNodeEnv` off NODE_ENV — before anything reads config, rather than trusting a caller to pass it, and warns by name when it cannot and env-scoped datasets exist)",
"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",
+ "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."
+ },
"records": {
"status": "live",
"verifiedAt": "2026-08-28",
diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md
index da72b8c131..f62d091407 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 | 0 | 0 | 0 | 0 | 12 |
+| `seed` | 12 | 1 | 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** | **5** | **1** | **92** | **10** | **958** |
+| **total** | **850** | **6** | **1** | **92** | **10** | **959** |
diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts
index 8b8deabd8f..9b92135e2f 100644
--- a/packages/spec/src/data/seed-loader.zod.ts
+++ b/packages/spec/src/data/seed-loader.zod.ts
@@ -2,6 +2,7 @@
import { z } from 'zod';
import { SeedSchema, SeedMode } from './seed.zod';
+import { LocaleSchema } from '../system/translation.zod';
/**
* # Seed Loader Protocol
@@ -267,6 +268,23 @@ export const SeedLoaderConfigSchema = lazySchema(() => z.object({
env: z.enum(['prod', 'dev', 'test']).optional()
.describe('Only load datasets matching this environment'),
+ /**
+ * Locale filter. Only datasets whose `Seed.locale` scope includes this locale
+ * are loaded; a dataset declaring no `locale` is unrestricted and always
+ * passes this axis.
+ *
+ * The two axes COMPOSE — a dataset is loaded when it passes `env` **and**
+ * `locale` — so neither can rescue a dataset the other excluded.
+ *
+ * When not specified the locale axis is inert and every dataset passes it,
+ * which is the pre-existing behaviour for a host that knows nothing about
+ * locales. The loader says so out loud when, and only when, some dataset
+ * actually narrowed its locale scope: a silently inert filter is how
+ * `Seed.env` spent releases being authorable and unenforced (framework#4704).
+ */
+ locale: LocaleSchema.optional()
+ .describe('Only load datasets scoped to this locale (BCP-47 tag)'),
+
/**
* Target organization for per-tenant seed loading.
*
diff --git a/packages/spec/src/data/seed.test.ts b/packages/spec/src/data/seed.test.ts
index e848f4f9fc..f07e7e2976 100644
--- a/packages/spec/src/data/seed.test.ts
+++ b/packages/spec/src/data/seed.test.ts
@@ -242,6 +242,63 @@ describe('SeedSchema', () => {
})).toThrow();
});
+ // ── Locale scope (#16510) ────────────────────────────────────────────────
+ //
+ // The shape is `strictObject`, so before this key existed an app could not
+ // add it at all: `locale` was REJECTED, and the only place to select between
+ // two language markets' datasets was application code, at config-assembly
+ // time. These pin the accept/reject behaviour that changed.
+
+ describe('locale scope', () => {
+ const withLocale = (locale: unknown) =>
+ SeedSchema.safeParse({ object: 'plan', locale, records: [] });
+
+ it('accepts a locale scope', () => {
+ // Key REACHABILITY: the shape is strict, so a key it does not declare
+ // surfaces as `unrecognized_keys`. Assert the WHOLE parse succeeds, so
+ // this cannot pass while the key is rejected for some other reason.
+ const parsed = withLocale(['zh-CN']);
+ expect(parsed.success).toBe(true);
+ expect(parsed.success && parsed.data.locale).toEqual(['zh-CN']);
+ });
+
+ it('accepts a multi-locale scope', () => {
+ const parsed = withLocale(['en', 'en-GB']);
+ expect(parsed.success).toBe(true);
+ expect(parsed.success && parsed.data.locale).toEqual(['en', 'en-GB']);
+ });
+
+ it('leaves locale ABSENT when it is not written — absence is "every locale"', () => {
+ // Deliberately NOT defaulted the way `env` is: locales are open-ended
+ // BCP-47 tags with no enumerable universe to spell out as a default, so
+ // the unrestricted spelling has to be absence.
+ const parsed = SeedSchema.parse({ object: 'plan', records: [] });
+ expect(parsed.locale).toBeUndefined();
+ expect('locale' in parsed).toBe(false);
+ });
+
+ it('rejects an empty locale array — a dataset that applies nowhere is a mistake', () => {
+ expect(withLocale([]).success).toBe(false);
+ });
+
+ it('rejects a bare string — the scope is a LIST, like env', () => {
+ expect(withLocale('zh-CN').success).toBe(false);
+ });
+
+ it('points the plural and language spellings at `locale`', () => {
+ for (const alias of ['locales', 'language', 'languages']) {
+ const parsed = SeedSchema.safeParse({
+ object: 'plan',
+ [alias]: ['zh-CN'],
+ records: [],
+ });
+ expect(parsed.success, `${alias} should be rejected`).toBe(false);
+ const message = parsed.success ? '' : JSON.stringify(parsed.error.issues);
+ expect(message, `${alias} should be pointed at \`locale\``).toContain('locale');
+ }
+ });
+ });
+
it('should handle large datasets', () => {
const largeDataset = SeedSchema.parse({
object: 'bulk_data',
diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts
index 5fbf6b38c3..ee9b191f1f 100644
--- a/packages/spec/src/data/seed.zod.ts
+++ b/packages/spec/src/data/seed.zod.ts
@@ -9,6 +9,7 @@ import { z } from 'zod';
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
+import { LocaleSchema } from '../system/translation.zod';
export const SeedMode = z.enum([
'insert', // Try to insert, fail on duplicate
'update', // Only update found records, ignore new
@@ -48,6 +49,9 @@ export const SeedSchema = lazySchema(() => strictObject({
conflict: 'mode',
environment: 'env',
environments: 'env',
+ locales: 'locale',
+ language: 'locale',
+ languages: 'locale',
},
}, {
/**
@@ -89,6 +93,35 @@ export const SeedSchema = lazySchema(() => strictObject({
*/
env: z.array(z.enum(['prod', 'dev', 'test'])).default(['prod', 'dev', 'test']).describe('Applicable environments'),
+ /**
+ * Locale Scope
+ *
+ * Which locales this dataset applies to, as BCP-47 tags (`['zh-CN']`,
+ * `['en', 'en-GB']`). The loader drops a dataset whose scope does not include
+ * the locale it is loading for, so an app that ships one demo dataset per
+ * language market declares both and lets the runtime pick — rather than
+ * selecting between them while the config is assembled, which bakes the
+ * choice into `dist` and leaves the other market's rows resident in the
+ * database.
+ *
+ * **Omitted means every locale.** Unlike {@link SeedSchema.shape.env}, whose
+ * environments are a closed set of three and can therefore be spelled out as
+ * a default, locales are open-ended BCP-47 tags with no enumerable universe —
+ * so absence, not a default array, is what carries "unrestricted". An empty
+ * array is rejected rather than read as "no locale": a dataset that applies
+ * nowhere is an authoring mistake, and the same reasoning already governs a
+ * composite `externalId`.
+ *
+ * Tags are matched case-insensitively (`zh-cn` and `zh-CN` are the same tag
+ * per BCP-47) and otherwise exactly: `['zh']` does not match a runtime locale
+ * of `zh-CN`. Declare every tag the dataset is for.
+ *
+ * The platform translates nothing. This is the axis that SELECTS between
+ * record sets the app authored itself.
+ */
+ locale: z.array(LocaleSchema).min(1).optional()
+ .describe('Applicable locales (BCP-47 tags); omitted applies to every locale'),
+
/**
* The Payload
* Array of raw JSON objects matching the Object Schema.
diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json
index 0a2552f374..937240daef 100644
--- a/scripts/engine-double-contract.pinned.json
+++ b/scripts/engine-double-contract.pinned.json
@@ -1451,6 +1451,21 @@
"verb": "update",
"pinned": 1
},
+ {
+ "file": "packages/metadata-protocol/src/seed-loader-locale-scope.test.ts",
+ "verb": "delete",
+ "pinned": 1
+ },
+ {
+ "file": "packages/metadata-protocol/src/seed-loader-locale-scope.test.ts",
+ "verb": "findOne",
+ "pinned": 1
+ },
+ {
+ "file": "packages/metadata-protocol/src/seed-loader-locale-scope.test.ts",
+ "verb": "update",
+ "pinned": 1
+ },
{
"file": "packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts",
"verb": "delete",
diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md
index 90f801016c..bc75258863 100644
--- a/skills/objectstack-data/references/_index.md
+++ b/skills/objectstack-data/references/_index.md
@@ -45,6 +45,7 @@ from `node_modules` — there is no local copy in the skill bundle.
- `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities
- `node_modules/@objectstack/spec/src/shared/value-domain.zod.ts` — Standard value domains: one closed vocabulary and one membership predicate for settings and fields.
- `node_modules/@objectstack/spec/src/system/deploy-bundle.zod.ts` — Deploy Bundle Protocol
+- `node_modules/@objectstack/spec/src/system/translation.zod.ts` — Exports: LocaleSchema, FieldTranslationSchema, ActionResultDialogTranslationSchema, ObjectTranslationDataSchema, TranslationDataSchema
- `node_modules/@objectstack/spec/src/ui/action-params.zod.ts` — The action DISPATCH contract: what the platform validates on the way in, and
- `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Exports: ActionParamSchema, ActionType, ActionLocationSchema, ActionAiSchema, ActionSchema
- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas