From f976990c8ec9689e90c108217304b7508ab6ccef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:47:52 +0000 Subject: [PATCH 1/5] feat(spec, metadata-protocol): add a locale axis to seed datasets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — schema + loader halves; tests, docs and changeset to follow. Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude --- packages/metadata-protocol/src/seed-loader.ts | 126 +++++++++++++++++- packages/spec/src/data/seed-loader.zod.ts | 18 +++ packages/spec/src/data/seed.zod.ts | 33 +++++ 3 files changed, 175 insertions(+), 2 deletions(-) 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/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.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. From 60bfaa80c04ce8af83522d048f2f7d56fe442067 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:56:57 +0000 Subject: [PATCH 2/5] test(seed): pin the locale axis; docs, liveness ledger and changeset Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude --- .changeset/seed-locale-axis.md | 16 + content/docs/data-modeling/seed-data.mdx | 55 +++ .../src/seed-loader-locale-scope.test.ts | 323 ++++++++++++++++++ packages/spec/authorable-surface/data.json | 2 + packages/spec/liveness/seed.json | 9 +- packages/spec/src/data/seed.test.ts | 57 ++++ 6 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 .changeset/seed-locale-axis.md create mode 100644 packages/metadata-protocol/src/seed-loader-locale-scope.test.ts 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/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/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/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', From d621dec9a48cc1ca5291f69d42ec0ebec7ead9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:00:30 +0000 Subject: [PATCH 3/5] chore(spec): regenerate reference docs, skill refs and liveness counts Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude --- content/docs/references/data/seed-loader.mdx | 3 +++ content/docs/references/data/seed.mdx | 1 + content/docs/references/kernel/manifest.mdx | 1 + packages/spec/liveness/state-counts.md | 4 ++-- skills/objectstack-data/references/_index.md | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) 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/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 85b774d338..0256a558cd 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** | **848** | **5** | **1** | **92** | **12** | **958** | +| **total** | **848** | **6** | **1** | **92** | **12** | **959** | 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 From 35929cd379c6f35eaebe2a414b24737712d5cf4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:22:26 +0000 Subject: [PATCH 4/5] chore: re-anchor system-context census lines and pin the new engine doubles Both are mechanical, gate-driven repairs of this change's own side effects: the +51 lines in seed-loader.ts moved three cited anchors, and the new locale-scope test pins engine doubles the contract ledger had not recorded. Claude-Session: https://claude.ai/code/session_013r78utTbiWqxghcuRJxfZf Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 2 +- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f61cbb4b79..0dd31fb281 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -194,7 +194,7 @@ assuming `isSystem` covers it is a documented source of bugs. | Assumption | Reality | Anchor | |:---|:---|:---| -| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | +| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2083` (rationale at `:1993`–`1995`, #3760), `flow.zod.ts:743` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10398`–`10415` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 6a0b08e46a..f7a106c80a 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", From fa5d801454c5e65c811b354ead9dba892fa570cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:57:06 +0000 Subject: [PATCH 5/5] chore(spec): regenerate liveness state counts after the second main sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen:liveness-counts` recomputed from the merged ledger: the only stale artifact this round. The total row is the union of both sides — main's #16784 re-grade of ActionSchema operation/patch (planned -> live, 848 -> 850) plus this branch's experimental `seed.locale` row (exp 5 -> 6, classified 958 -> 959). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mr75Roij7XFWE6Cn2UyBq6 --- packages/spec/liveness/state-counts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 55d80b44cc..f62d091407 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -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** |